@bitkyc08/opencodex 2.53.0-preview.20260913 → 2.54.0-preview.20260914
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-D7ynYo2K.js → index-B4VYfZcY.js} +2 -2
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/devin.ts +39 -7
- package/src/cli/capabilities.ts +4 -2
- package/src/cli/catalog.ts +39 -10
- package/src/cli/dispatch.ts +19 -64
- package/src/cli/doctor.ts +1 -1
- package/src/cli/internal-command.ts +44 -0
- package/src/cli/registry.ts +9 -7
- package/src/cli/restart-scope.ts +184 -0
- package/src/codex/app-server-processes.ts +20 -5
- package/src/codex/app-server-restart-service.ts +29 -0
- package/src/codex/catalog/provider-fetch.ts +13 -5
- package/src/codex/catalog/sync.ts +12 -3
- package/src/codex/desktop-app/darwin.ts +268 -0
- package/src/codex/desktop-app/handoff.ts +303 -0
- package/src/codex/desktop-app/linux.ts +388 -0
- package/src/codex/desktop-app/lock.ts +226 -0
- package/src/codex/desktop-app/types.ts +141 -0
- package/src/codex/desktop-app/windows.ts +239 -0
- package/src/codex/desktop-app-restart.ts +264 -279
- package/src/codex/inject.ts +83 -21
- package/src/codex/sync.ts +16 -22
- package/src/generated/compatibility-version.json +58 -22
- package/src/lib/codex-restart-contract.ts +31 -0
- package/src/providers/registry.ts +13 -12
- package/src/server/responses/core.ts +62 -4
- package/src/server/responses/encrypted-payload.ts +161 -0
- package/src/server/responses.ts +1 -1
- package/src/types/provider.ts +6 -5
- package/src/web-search/index.ts +14 -67
- package/src/web-search/passthrough-bridge.ts +256 -32
- package/src/web-search/sidecar-providers.ts +76 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The restart scope a command was asked for, and the post-write restart itself.
|
|
3
|
+
*
|
|
4
|
+
* Its own module because `sync`, `sync-cache` and `catalog pull` all need it, and
|
|
5
|
+
* having `catalog.ts` import it from `dispatch.ts` would make the two files circular.
|
|
6
|
+
*/
|
|
7
|
+
import { afterCatalogWriteHandleAppServers } from "../codex/app-server-processes";
|
|
8
|
+
import type { AfterCatalogWriteAppServerResult } from "../codex/app-server-processes";
|
|
9
|
+
import type { DesktopAppRestartResult } from "../codex/desktop-app-restart";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Which restart a command was asked for.
|
|
13
|
+
*
|
|
14
|
+
* `--restart-codex` used to mean app-server-only, and the desktop restart was a
|
|
15
|
+
* separate Windows-only opt-in. That split existed because quitting the app ends live
|
|
16
|
+
* conversations, which is a bigger consent than restarting a background helper. The
|
|
17
|
+
* reasoning was sound and is superseded by an explicit maintainer decision:
|
|
18
|
+
* `--restart-codex` now means the app is fully stopped and started again. The narrow
|
|
19
|
+
* behaviour did not disappear, it moved to a flag that names it.
|
|
20
|
+
*
|
|
21
|
+
* One reader for every command, so `sync`, `sync-cache` and `catalog pull` cannot
|
|
22
|
+
* drift in what the same flag means.
|
|
23
|
+
*/
|
|
24
|
+
export interface RestartScope {
|
|
25
|
+
/** Signal matching app-server / code-mode-host processes. */
|
|
26
|
+
appServers: boolean;
|
|
27
|
+
/** Fully quit and relaunch the Codex desktop app. */
|
|
28
|
+
desktopApp: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readRestartScope(
|
|
32
|
+
args: readonly string[],
|
|
33
|
+
log: Pick<Console, "error">,
|
|
34
|
+
): RestartScope {
|
|
35
|
+
const appServerOnly = args.includes("--restart-app-server-only");
|
|
36
|
+
const legacyDesktop = args.includes("--restart-desktop-app");
|
|
37
|
+
const restartCodex = args.includes("--restart-codex");
|
|
38
|
+
if (legacyDesktop) {
|
|
39
|
+
log.error(
|
|
40
|
+
"--restart-desktop-app is deprecated: --restart-codex now restarts the Codex "
|
|
41
|
+
+ "desktop app on every platform. The flag still works and will be removed in a "
|
|
42
|
+
+ "future release.",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (appServerOnly && (restartCodex || legacyDesktop)) {
|
|
46
|
+
// Contradictory scopes, and the NARROW one wins. Losing live conversations is
|
|
47
|
+
// unrecoverable; a stale model picker is not. A user who typed the app-server-only
|
|
48
|
+
// flag asked not to lose them.
|
|
49
|
+
log.error(
|
|
50
|
+
"--restart-app-server-only overrides --restart-codex/--restart-desktop-app; "
|
|
51
|
+
+ "the desktop app was left running.",
|
|
52
|
+
);
|
|
53
|
+
return { appServers: true, desktopApp: false };
|
|
54
|
+
}
|
|
55
|
+
if (appServerOnly) return { appServers: true, desktopApp: false };
|
|
56
|
+
if (restartCodex || legacyDesktop) return { appServers: true, desktopApp: true };
|
|
57
|
+
return { appServers: false, desktopApp: false };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface RestartScopeOutcome {
|
|
61
|
+
appServers?: AfterCatalogWriteAppServerResult;
|
|
62
|
+
desktopApp?: DesktopAppRestartResult;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The post-write restart, for every command that performs one.
|
|
67
|
+
*
|
|
68
|
+
* App-servers that belong to the desktop tree are excluded when a desktop restart is
|
|
69
|
+
* also going to run: the app-server is a CHILD of the app on every platform, so
|
|
70
|
+
* signalling it first and then quitting the app interrupts the operator's in-flight
|
|
71
|
+
* turn twice in one command. A discovery or probe failure yields no exclusion, which
|
|
72
|
+
* is the safe direction - a missed exclusion costs an extra interruption, a wrong one
|
|
73
|
+
* leaves a stale app-server serving a roster that no longer exists.
|
|
74
|
+
*/
|
|
75
|
+
export async function handleRestartScopeAfterWrite(
|
|
76
|
+
scope: RestartScope,
|
|
77
|
+
log: Pick<Console, "log" | "error">,
|
|
78
|
+
): Promise<RestartScopeOutcome> {
|
|
79
|
+
const { listCodexDesktopAppPids } = await import("../codex/desktop-app-restart");
|
|
80
|
+
// KNOWN LIMITATION on Windows. The exclusion matches pids against the discovered
|
|
81
|
+
// desktop tree, and the Windows probe enumerates only ChatGPT.exe, while Windows
|
|
82
|
+
// app-servers run as codex.exe / codex-code-mode-host. They therefore never match and
|
|
83
|
+
// still receive SIGTERM before the app quits, so Windows keeps the double interruption
|
|
84
|
+
// this exclusion removes on macOS and Linux - where the app-server executable does live
|
|
85
|
+
// under the bundle or install root and is enumerated.
|
|
86
|
+
//
|
|
87
|
+
// Closing it means widening the Windows probe past ChatGPT.exe, which is the same query
|
|
88
|
+
// that decides what may be killed, so it needs its own verification rather than being
|
|
89
|
+
// appended here. The restart itself is correct on Windows either way; the cost is one
|
|
90
|
+
// extra interrupted turn.
|
|
91
|
+
const excludePids = scope.desktopApp ? (listCodexDesktopAppPids() ?? []) : [];
|
|
92
|
+
const appServers = afterCatalogWriteHandleAppServers({
|
|
93
|
+
restart: scope.appServers, log, excludePids,
|
|
94
|
+
});
|
|
95
|
+
const desktopApp = scope.desktopApp ? await handleDesktopAppRestart(log) : undefined;
|
|
96
|
+
return { appServers, desktopApp };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Report the outcome of a desktop-app restart. Kept next to the callers so every
|
|
101
|
+
* command tells the user the same thing.
|
|
102
|
+
*/
|
|
103
|
+
export async function handleDesktopAppRestart(
|
|
104
|
+
log: Pick<Console, "log" | "error">,
|
|
105
|
+
): Promise<DesktopAppRestartResult> {
|
|
106
|
+
const { restartCodexDesktopApp } = await import("../codex/desktop-app-restart");
|
|
107
|
+
const { startDesktopRestartHandoff } = await import("../codex/desktop-app/handoff");
|
|
108
|
+
const result = restartCodexDesktopApp({
|
|
109
|
+
// The CLI is the one caller whose exit is exactly the signal the helper waits for,
|
|
110
|
+
// so it is the one caller allowed to hand off. The management service is not (it
|
|
111
|
+
// runs in a proxy that never exits) and the helper itself is not (recursion).
|
|
112
|
+
startHandoff: () => {
|
|
113
|
+
const outcome = startDesktopRestartHandoff();
|
|
114
|
+
return outcome.kind === "started"
|
|
115
|
+
? { helperPid: outcome.helperPid, logPath: outcome.logPath }
|
|
116
|
+
: null;
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
switch (result.reason) {
|
|
120
|
+
case "unsupported_platform":
|
|
121
|
+
log.error(
|
|
122
|
+
`Restarting the Codex desktop app is not supported on ${process.platform}; `
|
|
123
|
+
+ "nothing was stopped.",
|
|
124
|
+
);
|
|
125
|
+
return result;
|
|
126
|
+
case "restart_in_flight":
|
|
127
|
+
log.error(
|
|
128
|
+
"Another Codex desktop-app restart is already running; this one did nothing. "
|
|
129
|
+
+ "Wait for it to finish and check again.",
|
|
130
|
+
);
|
|
131
|
+
return result;
|
|
132
|
+
case "relaunch_failed":
|
|
133
|
+
log.error(
|
|
134
|
+
"The Codex desktop app was stopped but could not be started again. Launch it manually.",
|
|
135
|
+
);
|
|
136
|
+
return result;
|
|
137
|
+
case "package_discovery_failed":
|
|
138
|
+
log.error(
|
|
139
|
+
"Could not identify the installed Codex desktop package. Quit and relaunch the desktop app "
|
|
140
|
+
+ "manually to refresh the model picker.",
|
|
141
|
+
);
|
|
142
|
+
return result;
|
|
143
|
+
case "handoff_started":
|
|
144
|
+
// Saying the session will end is the point. The operator is about to lose the
|
|
145
|
+
// terminal they typed into, and a message that omits that reads as a hang.
|
|
146
|
+
log.log(
|
|
147
|
+
"This command is running inside the Codex app, so the restart was handed off to "
|
|
148
|
+
+ `a detached helper (pid ${result.handoff?.helperPid ?? 0}). The app will quit and `
|
|
149
|
+
+ `relaunch in a moment; this session will end with it. Outcome: ${result.handoff?.logPath ?? ""}`,
|
|
150
|
+
);
|
|
151
|
+
return result;
|
|
152
|
+
case "self_ancestry":
|
|
153
|
+
log.error(
|
|
154
|
+
"Refusing to restart the desktop app because this command is running inside it, "
|
|
155
|
+
+ "and the restart could not be handed off to a detached helper. "
|
|
156
|
+
+ "Run 'ocx sync --restart-codex' from a terminal outside the app instead.",
|
|
157
|
+
);
|
|
158
|
+
return result;
|
|
159
|
+
case "process_probe_failed":
|
|
160
|
+
// Distinct from `no_targets`: we could not look, which is not the same as looking and
|
|
161
|
+
// finding nothing. Saying "not running" here sent users away believing there was nothing
|
|
162
|
+
// to restart (#2557).
|
|
163
|
+
log.error(
|
|
164
|
+
"Could not enumerate Codex desktop processes, so the app was not restarted. "
|
|
165
|
+
+ "Quit and relaunch the desktop app manually to refresh the model picker.",
|
|
166
|
+
);
|
|
167
|
+
return result;
|
|
168
|
+
case "no_targets":
|
|
169
|
+
log.log("Codex desktop app is not running; nothing to restart.");
|
|
170
|
+
return result;
|
|
171
|
+
case "targets_survived":
|
|
172
|
+
log.error(
|
|
173
|
+
`Codex desktop app PID(s) ${result.surviving.join(", ")} did not exit, so it was not relaunched. `
|
|
174
|
+
+ "Quit the desktop app manually to refresh the model picker.",
|
|
175
|
+
);
|
|
176
|
+
return result;
|
|
177
|
+
default:
|
|
178
|
+
if (result.relaunch === "started") {
|
|
179
|
+
log.log("Codex desktop app restarted; its model picker will re-read the catalog.");
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
@@ -17,8 +17,8 @@ import {
|
|
|
17
17
|
import { readCodexCatalogPath } from "./catalog/parsing";
|
|
18
18
|
|
|
19
19
|
export const STALE_CODEX_APP_SERVER_HINT =
|
|
20
|
-
"If Codex still shows an older model list, restart
|
|
21
|
-
+ "
|
|
20
|
+
"If Codex still shows an older model list, run `ocx sync --restart-codex`: it restarts the long-lived app-server "
|
|
21
|
+
+ "processes and fully restarts the Codex desktop app, whose model picker is what actually holds the stale list.";
|
|
22
22
|
|
|
23
23
|
/** Attach the shared dashboard hint only after a catalog or models_cache write. */
|
|
24
24
|
export function attachStaleAppServerHint<T extends {
|
|
@@ -563,8 +563,8 @@ export function formatStaleCodexAppServerWarning(
|
|
|
563
563
|
return (
|
|
564
564
|
`WARNING: ${processes.length} Codex app-server process(es) still running (PID${processes.length === 1 ? "" : "s"}: ${pids}). `
|
|
565
565
|
+ "Disk catalog/cache were updated, but Codex may keep showing the old model list until those processes restart. "
|
|
566
|
-
+ "Re-run with `ocx sync --restart-codex` (or `ocx sync-cache --restart-codex`) to
|
|
567
|
-
+ "
|
|
566
|
+
+ "Re-run with `ocx sync --restart-codex` (or `ocx sync-cache --restart-codex`) to restart those processes and the Codex desktop app. "
|
|
567
|
+
+ "Use `--restart-app-server-only` to leave the desktop app running. "
|
|
568
568
|
+ "Active turns may be interrupted."
|
|
569
569
|
);
|
|
570
570
|
}
|
|
@@ -1176,6 +1176,19 @@ export interface AfterCatalogWriteAppServerOptions {
|
|
|
1176
1176
|
restart: boolean;
|
|
1177
1177
|
log?: Pick<Console, "log" | "error"> | null;
|
|
1178
1178
|
io?: CodexAppServerProcessIo;
|
|
1179
|
+
/**
|
|
1180
|
+
* Pids already covered by a desktop-app restart in this same command.
|
|
1181
|
+
*
|
|
1182
|
+
* The app-server is a CHILD of the Codex desktop app on every platform, so signalling
|
|
1183
|
+
* it and then quitting the app interrupts the operator's in-flight turn twice in one
|
|
1184
|
+
* command. Excluding the desktop tree leaves the quit to do that work once.
|
|
1185
|
+
*
|
|
1186
|
+
* Standalone app-servers - the npm wrapper pair, SSH bootstraps - are not members of
|
|
1187
|
+
* that tree and are still signalled. An empty list means no exclusion, which is what a
|
|
1188
|
+
* failed discovery or probe yields: a missed exclusion costs an extra interruption, a
|
|
1189
|
+
* wrong one leaves a stale app-server serving a roster that no longer exists.
|
|
1190
|
+
*/
|
|
1191
|
+
excludePids?: readonly number[];
|
|
1179
1192
|
}
|
|
1180
1193
|
|
|
1181
1194
|
export interface AfterCatalogWriteAppServerResult {
|
|
@@ -1189,7 +1202,9 @@ export interface AfterCatalogWriteAppServerResult {
|
|
|
1189
1202
|
export function afterCatalogWriteHandleAppServers(
|
|
1190
1203
|
options: AfterCatalogWriteAppServerOptions,
|
|
1191
1204
|
): AfterCatalogWriteAppServerResult {
|
|
1192
|
-
const
|
|
1205
|
+
const excluded = new Set(options.excludePids ?? []);
|
|
1206
|
+
const processes = listCodexAppServerProcesses(options.io)
|
|
1207
|
+
.filter(process => !excluded.has(process.pid));
|
|
1193
1208
|
const hint = STALE_CODEX_APP_SERVER_HINT;
|
|
1194
1209
|
if (processes.length === 0) {
|
|
1195
1210
|
return { processes, warned: false, hint };
|
|
@@ -28,11 +28,26 @@ import {
|
|
|
28
28
|
import type { CodexAppServerProcessIo } from "./app-server-processes";
|
|
29
29
|
import type {
|
|
30
30
|
CodexAppServerStateResponse,
|
|
31
|
+
CodexDesktopRestartSummary,
|
|
31
32
|
CodexRestartResponse,
|
|
32
33
|
} from "../lib/codex-restart-contract";
|
|
33
34
|
import { getServerListenPort } from "../server/lifecycle";
|
|
34
35
|
|
|
36
|
+
async function defaultRestartDesktopApp(): Promise<CodexDesktopRestartSummary> {
|
|
37
|
+
const { restartCodexDesktopApp } = await import("./desktop-app-restart");
|
|
38
|
+
const outcome = restartCodexDesktopApp({ allowHandoff: false });
|
|
39
|
+
return {
|
|
40
|
+
attempted: outcome.attempted,
|
|
41
|
+
stopped: outcome.stopped,
|
|
42
|
+
surviving: outcome.surviving,
|
|
43
|
+
relaunch: outcome.relaunch,
|
|
44
|
+
...(outcome.reason === undefined ? {} : { reason: outcome.reason }),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
export interface CodexRestartServiceIo {
|
|
49
|
+
/** Desktop-restart seam, so a route test cannot terminate the developer's own Codex. */
|
|
50
|
+
restartDesktopApp?: () => Promise<CodexDesktopRestartSummary>;
|
|
36
51
|
/** Process-layer seam, forwarded to every app-server-processes call. */
|
|
37
52
|
processIo?: CodexAppServerProcessIo;
|
|
38
53
|
/** Catalog refresh seam. Resolves to whether a catalog or cache write happened. */
|
|
@@ -114,7 +129,18 @@ async function runCodexRestart(io: CodexRestartServiceIo): Promise<CodexRestartR
|
|
|
114
129
|
(io.resetStateCache ?? resetCodexAppServerCatalogStateCache)();
|
|
115
130
|
const before = (io.collectState ?? collectCodexAppServerCatalogState)(io.processIo ?? {});
|
|
116
131
|
|
|
132
|
+
// Done here, before the early returns, because the model picker lives in the DESKTOP
|
|
133
|
+
// app: "no app-server is running" is not a reason to leave a stale roster on screen,
|
|
134
|
+
// and an operator who pressed restart still wants the app back on the current catalog.
|
|
135
|
+
//
|
|
136
|
+
// allowHandoff is false and that is deliberate. The handoff waits for the CALLING
|
|
137
|
+
// process to exit, and this runs inside a long-lived proxy that does not, so every
|
|
138
|
+
// handoff started here would sit out its window and fail after the operator had
|
|
139
|
+
// already been told it was handed off. An honest refusal beats that.
|
|
140
|
+
const desktop = await (io.restartDesktopApp ?? defaultRestartDesktopApp)();
|
|
141
|
+
|
|
117
142
|
const nothingToDo = (): CodexRestartResponse => ({
|
|
143
|
+
desktopApp: desktop,
|
|
118
144
|
success: true,
|
|
119
145
|
stateBefore: before.state,
|
|
120
146
|
synced,
|
|
@@ -174,6 +200,7 @@ async function runCodexRestart(io: CodexRestartServiceIo): Promise<CodexRestartR
|
|
|
174
200
|
surviving: [],
|
|
175
201
|
failed: [],
|
|
176
202
|
code: "nothing_running",
|
|
203
|
+
desktopApp: desktop,
|
|
177
204
|
};
|
|
178
205
|
}
|
|
179
206
|
|
|
@@ -207,8 +234,10 @@ async function runCodexRestart(io: CodexRestartServiceIo): Promise<CodexRestartR
|
|
|
207
234
|
};
|
|
208
235
|
|
|
209
236
|
const result = (io.restart ?? restartCodexAppServers)(targets, guardedProcessIo);
|
|
237
|
+
|
|
210
238
|
const clean = result.surviving.length === 0 && result.failed.length === 0;
|
|
211
239
|
return {
|
|
240
|
+
desktopApp: desktop,
|
|
212
241
|
success: clean,
|
|
213
242
|
stateBefore: before.state,
|
|
214
243
|
synced,
|
|
@@ -2321,7 +2321,7 @@ async function gatherRoutedModelsWithAuth(
|
|
|
2321
2321
|
return models;
|
|
2322
2322
|
}
|
|
2323
2323
|
|
|
2324
|
-
/** Bound a
|
|
2324
|
+
/** Bound a custom row whose model id has pinned native Codex metadata, without changing stored configuration. */
|
|
2325
2325
|
function boundCustomNativeReasoning(
|
|
2326
2326
|
model: CatalogModel,
|
|
2327
2327
|
allowed: readonly string[],
|
|
@@ -2625,8 +2625,8 @@ async function gatherRoutedModelsUncached(
|
|
|
2625
2625
|
: {}),
|
|
2626
2626
|
// Explicit custom-row ladder wins over the inherited provider row below: the merge only
|
|
2627
2627
|
// gap-fills, so a stored `[]` (explicit "no reasoning") or a declared ladder is kept
|
|
2628
|
-
// instead of being replaced by that row's metadata.
|
|
2629
|
-
// bounded against their own
|
|
2628
|
+
// instead of being replaced by that row's metadata. Capability-backed native model ids
|
|
2629
|
+
// are bounded against their own pinned ladder after the merge, including gateways.
|
|
2630
2630
|
...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}),
|
|
2631
2631
|
...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}),
|
|
2632
2632
|
...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}),
|
|
@@ -2679,8 +2679,16 @@ async function gatherRoutedModelsUncached(
|
|
|
2679
2679
|
...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}),
|
|
2680
2680
|
...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}),
|
|
2681
2681
|
} : base;
|
|
2682
|
-
|
|
2683
|
-
|
|
2682
|
+
// Catalog-advertised efforts are bounded whenever the model id is a pinned native
|
|
2683
|
+
// slug. Desktop validates that id, so a gateway such as YYLJ/gpt-6-astra still cannot
|
|
2684
|
+
// advertise none/minimal. Full native identity stays behind the alias predicate.
|
|
2685
|
+
const nativeEffortSource = hasNativeOpenAiCapabilityMetadata(cm.modelId);
|
|
2686
|
+
const reasoningBounded = nativeEffortSource
|
|
2687
|
+
? boundCustomNativeReasoning(
|
|
2688
|
+
merged,
|
|
2689
|
+
nativeReasoningEfforts(cm.modelId),
|
|
2690
|
+
nativeAliasDefaultEffort ?? nativeDefaultReasoningEffort(cm.modelId),
|
|
2691
|
+
)
|
|
2684
2692
|
: merged;
|
|
2685
2693
|
// Vision-sidecar coverage only: when the enriched provider's shared predicate matches
|
|
2686
2694
|
// noVisionModels or text-without-image modelInputModalities, advertise image input so the
|
|
@@ -49,7 +49,7 @@ import { codexAccountLogLabel, fallbackCodexAccountLogLabel } from "../account-l
|
|
|
49
49
|
|
|
50
50
|
import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing";
|
|
51
51
|
import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
|
|
52
|
-
import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, RETIRED_NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata";
|
|
52
|
+
import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, hasNativeOpenAiCapabilityMetadata, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, RETIRED_NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata";
|
|
53
53
|
import {
|
|
54
54
|
bundledCatalogCacheState,
|
|
55
55
|
loadBundledCodexCatalog,
|
|
@@ -316,6 +316,13 @@ function routedDisplayName(slug: string, model?: CatalogModel, config?: Pick<Ocx
|
|
|
316
316
|
return slug;
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
+
function preservePinnedNativeCustomReasoning(model?: CatalogModel): boolean {
|
|
320
|
+
return model !== undefined
|
|
321
|
+
&& model.catalogKind === CODEX_CUSTOM_MODEL_CATALOG_KIND
|
|
322
|
+
&& hasNativeOpenAiCapabilityMetadata(model.id)
|
|
323
|
+
&& Array.isArray(model.reasoningEfforts);
|
|
324
|
+
}
|
|
325
|
+
|
|
319
326
|
/**
|
|
320
327
|
* Cria uma entrada nativa ou roteada a partir do snapshot upstream, de um clone
|
|
321
328
|
* do template ou de campos mínimos. Aplica os metadados e limites pertinentes
|
|
@@ -380,7 +387,9 @@ export function deriveEntry(
|
|
|
380
387
|
e,
|
|
381
388
|
model?.reasoningEfforts,
|
|
382
389
|
model?.defaultReasoningEffort,
|
|
383
|
-
preserveExactReasoning
|
|
390
|
+
preserveExactReasoning
|
|
391
|
+
|| codexForwardNativeCapabilityAlias !== null
|
|
392
|
+
|| preservePinnedNativeCustomReasoning(model),
|
|
384
393
|
);
|
|
385
394
|
// This exact provider/model pair is the ChatGPT/Codex forward surface. Keep the pinned
|
|
386
395
|
// native tool/search/responses-lite contract while preserving the routed slug and wire id.
|
|
@@ -429,7 +438,7 @@ export function deriveEntry(
|
|
|
429
438
|
};
|
|
430
439
|
if (isRouted) {
|
|
431
440
|
applyRoutedCodexToolMode(entry, model?.codexToolMode);
|
|
432
|
-
applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExactReasoning);
|
|
441
|
+
applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExactReasoning || preservePinnedNativeCustomReasoning(model));
|
|
433
442
|
}
|
|
434
443
|
else {
|
|
435
444
|
applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]);
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS adapter for the Codex desktop-app restart.
|
|
3
|
+
*
|
|
4
|
+
* Measured shape (devlog/_plan/260913_cross_platform_desktop_app_restart/001_platform_topology.md):
|
|
5
|
+
*
|
|
6
|
+
* 15901 1 /Applications/ChatGPT.app/Contents/MacOS/ChatGPT
|
|
7
|
+
* 16733 15901 /Applications/ChatGPT.app/Contents/Resources/codex ... app-server ...
|
|
8
|
+
* 15903 1 .../Contents/Frameworks/Codex Framework.framework/.../browser_crashpad_handler
|
|
9
|
+
*
|
|
10
|
+
* The bundle is named ChatGPT.app but its identifier is com.openai.codex, and the
|
|
11
|
+
* display name is shared with a different OpenAI product. Every identity decision
|
|
12
|
+
* here therefore keys on the identifier, never on the name.
|
|
13
|
+
*/
|
|
14
|
+
import { realpathSync } from "node:fs";
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import {
|
|
18
|
+
isUnderRoot,
|
|
19
|
+
type DesktopAppAdapter,
|
|
20
|
+
type DesktopAppInstall,
|
|
21
|
+
type DesktopExec,
|
|
22
|
+
type DesktopProcess,
|
|
23
|
+
} from "./types";
|
|
24
|
+
|
|
25
|
+
const BUNDLE_ID = "com.openai.codex";
|
|
26
|
+
const CONVENTIONAL_BUNDLE = "/Applications/ChatGPT.app";
|
|
27
|
+
const SHELL_SUFFIX = "/Contents/MacOS/ChatGPT";
|
|
28
|
+
|
|
29
|
+
/** Absolute system locations only. PATH is never consulted for any of these. */
|
|
30
|
+
const PS = "/bin/ps";
|
|
31
|
+
const OSASCRIPT = "/usr/bin/osascript";
|
|
32
|
+
const OPEN = "/usr/bin/open";
|
|
33
|
+
const MDFIND = "/usr/bin/mdfind";
|
|
34
|
+
const PLIST_BUDDY = "/usr/libexec/PlistBuddy";
|
|
35
|
+
|
|
36
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
37
|
+
const MAX_ANCESTRY_HOPS = 16;
|
|
38
|
+
|
|
39
|
+
function shellPath(bundle: string): string {
|
|
40
|
+
return join(bundle, "Contents", "MacOS", "ChatGPT");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readBundleIdentifier(exec: DesktopExec, bundle: string): string | null {
|
|
44
|
+
try {
|
|
45
|
+
return exec(PLIST_BUDDY, [
|
|
46
|
+
"-c", "Print :CFBundleIdentifier",
|
|
47
|
+
join(bundle, "Contents", "Info.plist"),
|
|
48
|
+
], { timeout: PROBE_TIMEOUT_MS }).trim();
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function confirmBundle(exec: DesktopExec, candidate: string): DesktopAppInstall | null {
|
|
55
|
+
if (!candidate) return null;
|
|
56
|
+
let resolved: string;
|
|
57
|
+
try {
|
|
58
|
+
// Resolved ONCE here so membership is a pure comparison against a trusted value.
|
|
59
|
+
// A prefix test against an unresolved path admits sibling directories such as
|
|
60
|
+
// /Applications/ChatGPT.app-evil/..., which the same user can create.
|
|
61
|
+
resolved = realpathSync(candidate);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (readBundleIdentifier(exec, resolved) !== BUNDLE_ID) return null;
|
|
66
|
+
return { id: BUNDLE_ID, root: resolved, relaunch: BUNDLE_ID };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface PsSnapshot {
|
|
70
|
+
pid: number;
|
|
71
|
+
parentPid: number;
|
|
72
|
+
createdAt: string;
|
|
73
|
+
uid: number;
|
|
74
|
+
executable: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Column order matters. lstart is five whitespace-separated tokens, and the executable
|
|
79
|
+
* path itself contains spaces and parentheses on this app (Codex (Service).app), so
|
|
80
|
+
* everything after the uid is taken as the remainder of the line rather than split.
|
|
81
|
+
*
|
|
82
|
+
* comm as the FINAL -o column yields the full, untruncated executable path; this was
|
|
83
|
+
* checked against a 150+ character helper path rather than assumed. The 16-character
|
|
84
|
+
* truncation people expect belongs to ucomm.
|
|
85
|
+
*/
|
|
86
|
+
function parsePsLine(line: string): PsSnapshot | null {
|
|
87
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(\d+)\s+(.+)$/.exec(line);
|
|
88
|
+
if (!match) return null;
|
|
89
|
+
const pid = Number(match[1]);
|
|
90
|
+
const parentPid = Number(match[2]);
|
|
91
|
+
const uid = Number(match[4]);
|
|
92
|
+
if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(parentPid) || !Number.isSafeInteger(uid)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
pid,
|
|
97
|
+
parentPid,
|
|
98
|
+
createdAt: (match[3] ?? "").trim(),
|
|
99
|
+
uid,
|
|
100
|
+
executable: (match[5] ?? "").trim(),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readPsSnapshots(exec: DesktopExec): PsSnapshot[] | null {
|
|
105
|
+
let stdout: string;
|
|
106
|
+
try {
|
|
107
|
+
stdout = exec(PS, ["-Ao", "pid=,ppid=,lstart=,uid=,comm="], { timeout: PROBE_TIMEOUT_MS });
|
|
108
|
+
} catch {
|
|
109
|
+
// A probe that could not RUN is not evidence of absence.
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const out: PsSnapshot[] = [];
|
|
113
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
114
|
+
const parsed = parsePsLine(line);
|
|
115
|
+
if (parsed) out.push(parsed);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Prefer the bundle the RUNNING shell executes out of.
|
|
122
|
+
*
|
|
123
|
+
* Membership is path-scoped while the quit and the relaunch are bundle-id-scoped. If
|
|
124
|
+
* two bundles claim com.openai.codex, discovering by identifier alone could enumerate
|
|
125
|
+
* one installation and quit the other. Starting from the live process makes the thing
|
|
126
|
+
* we quit the same thing we counted.
|
|
127
|
+
*/
|
|
128
|
+
function discoverFromRunningShell(exec: DesktopExec): string | null {
|
|
129
|
+
for (const snapshot of readPsSnapshots(exec) ?? []) {
|
|
130
|
+
if (!snapshot.executable.endsWith(SHELL_SUFFIX)) continue;
|
|
131
|
+
return snapshot.executable.slice(0, -SHELL_SUFFIX.length);
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function currentUid(): number | undefined {
|
|
137
|
+
try {
|
|
138
|
+
return typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
139
|
+
} catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let killProcess: (pid: number, signal: NodeJS.Signals) => void = (pid, signal) => {
|
|
145
|
+
process.kill(pid, signal);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** Test-only seam, so a kill can be observed without ending a developer's own Codex. */
|
|
149
|
+
export function setDarwinKillForTests(
|
|
150
|
+
next: ((pid: number, signal: NodeJS.Signals) => void) | null,
|
|
151
|
+
): void {
|
|
152
|
+
killProcess = next ?? ((pid, signal) => { process.kill(pid, signal); });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const darwinDesktopAppAdapter: DesktopAppAdapter = {
|
|
156
|
+
discover(exec): DesktopAppInstall | null {
|
|
157
|
+
const running = discoverFromRunningShell(exec);
|
|
158
|
+
if (running) {
|
|
159
|
+
const confirmed = confirmBundle(exec, running);
|
|
160
|
+
if (confirmed) return confirmed;
|
|
161
|
+
}
|
|
162
|
+
let spotlight = "";
|
|
163
|
+
try {
|
|
164
|
+
const query = "kMDItemCFBundleIdentifier == '" + BUNDLE_ID + "'";
|
|
165
|
+
spotlight = exec(MDFIND, [query], { timeout: PROBE_TIMEOUT_MS })
|
|
166
|
+
.split(/\r?\n/)
|
|
167
|
+
.map(entry => entry.trim())
|
|
168
|
+
.find(entry => entry.length > 0) ?? "";
|
|
169
|
+
} catch {
|
|
170
|
+
spotlight = "";
|
|
171
|
+
}
|
|
172
|
+
return confirmBundle(exec, spotlight) ?? confirmBundle(exec, CONVENTIONAL_BUNDLE);
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
listProcesses(exec, install): DesktopProcess[] | null {
|
|
176
|
+
const snapshots = readPsSnapshots(exec);
|
|
177
|
+
if (snapshots === null) return null;
|
|
178
|
+
const uid = currentUid();
|
|
179
|
+
// Without a uid there is no way to scope the result to this user, and reporting an
|
|
180
|
+
// empty list would tell the caller the app is not running (#2557's failure mode in a
|
|
181
|
+
// different disguise). This is a probe failure.
|
|
182
|
+
if (uid === undefined) return null;
|
|
183
|
+
const out: DesktopProcess[] = [];
|
|
184
|
+
for (const snapshot of snapshots) {
|
|
185
|
+
if (!isUnderRoot(snapshot.executable, install.root)) continue;
|
|
186
|
+
// Same user only.
|
|
187
|
+
if (snapshot.uid !== uid) continue;
|
|
188
|
+
out.push({
|
|
189
|
+
pid: snapshot.pid,
|
|
190
|
+
parentPid: snapshot.parentPid,
|
|
191
|
+
createdAt: snapshot.createdAt,
|
|
192
|
+
executable: snapshot.executable,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
isShell(entry, install): boolean {
|
|
199
|
+
return entry.executable === shellPath(install.root);
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
ancestryPids(exec): number[] {
|
|
203
|
+
const chain: number[] = [process.pid];
|
|
204
|
+
let current = process.pid;
|
|
205
|
+
for (let hop = 0; hop < MAX_ANCESTRY_HOPS; hop++) {
|
|
206
|
+
let stdout: string;
|
|
207
|
+
try {
|
|
208
|
+
stdout = exec(PS, ["-o", "ppid=", "-p", String(current)], { timeout: PROBE_TIMEOUT_MS });
|
|
209
|
+
} catch (error) {
|
|
210
|
+
// ps -p <pid> exits 1 with empty output when the pid does not exist, and
|
|
211
|
+
// execFileSync turns a non-zero exit into a throw. Without this branch the
|
|
212
|
+
// clean-end handling below is unreachable in production, every dead parent reads
|
|
213
|
+
// as unreadable, and the orphaned handoff helper refuses the one job it exists
|
|
214
|
+
// for. Hop 0 is this process, which always exists, so a failure there is real.
|
|
215
|
+
const status = (error as { status?: unknown } | null)?.status;
|
|
216
|
+
if (hop > 0 && status === 1) return chain;
|
|
217
|
+
// Anything else: could not look, so we cannot conclude we are outside the tree.
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
const trimmed = stdout.trim();
|
|
221
|
+
// Empty output means the pid has no live parent entry: a CLEAN end of chain, not
|
|
222
|
+
// a read failure. The detached handoff helper reaches exactly this state once its
|
|
223
|
+
// caller exits, and reading it as unreadable would make the helper refuse the one
|
|
224
|
+
// job it exists for.
|
|
225
|
+
if (trimmed === "") return chain;
|
|
226
|
+
const parent = Number(trimmed);
|
|
227
|
+
if (!Number.isSafeInteger(parent) || parent <= 0) return chain;
|
|
228
|
+
if (chain.includes(parent)) return chain;
|
|
229
|
+
chain.push(parent);
|
|
230
|
+
if (parent === 1) return chain;
|
|
231
|
+
current = parent;
|
|
232
|
+
}
|
|
233
|
+
// Bound reached without finding the top. A truncated chain silently defeats the
|
|
234
|
+
// self-ancestry intersection, so this reports "could not establish" instead.
|
|
235
|
+
return [];
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
requestQuit(exec, install): void {
|
|
239
|
+
// The Apple event, so the app runs its own termination path. Delivery is
|
|
240
|
+
// synchronous; termination is not, which is why the ladder always waits and
|
|
241
|
+
// re-verifies identity afterwards.
|
|
242
|
+
exec(OSASCRIPT, ["-e", 'quit app id "' + install.id + '"'], { timeout: PROBE_TIMEOUT_MS });
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
forceStop(_exec, root): void {
|
|
246
|
+
killProcess(root.pid, "SIGKILL");
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
captureRelaunchContext(): Record<string, string> {
|
|
250
|
+
// LaunchServices supplies the session, so nothing needs carrying forward.
|
|
251
|
+
return {};
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
relaunch(exec, install): void {
|
|
255
|
+
// Deliberately without -g: the operator asked for a restart and expects the app in
|
|
256
|
+
// front of them. An unknown bundle id exits non-zero with
|
|
257
|
+
// LSCopyApplicationURLsForBundleIdentifier() failed, which the ladder turns into
|
|
258
|
+
// relaunch_failed rather than a silent no-op. Not -n either: a second instance is
|
|
259
|
+
// both unreliable to obtain and unwanted.
|
|
260
|
+
exec(OPEN, ["-b", install.relaunch], { timeout: PROBE_TIMEOUT_MS });
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
export const darwinDefaultExec: DesktopExec = (file, args, options) => execFileSync(file, [...args], {
|
|
265
|
+
encoding: "utf-8",
|
|
266
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
267
|
+
timeout: options?.timeout ?? PROBE_TIMEOUT_MS,
|
|
268
|
+
});
|