@bitkyc08/opencodex 2.68.0-preview.20260927 → 2.69.0-preview.20260928
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/bin/ocx.mjs +25 -17
- package/gui/dist/assets/App-cDPyCb_V.js +51 -0
- package/gui/dist/assets/{Tray-DFnMiyD0.js → Tray-H1IAB8mN.js} +1 -1
- package/gui/dist/assets/index-BF9QnKO_.css +1 -0
- package/gui/dist/assets/index-CI6YTp8z.js +86 -0
- package/gui/dist/assets/{quota-summary-i84eOU3o.js → quota-summary-CutOlPu2.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/factory-droid.svg +8 -0
- package/package.json +1 -1
- package/src/adapters/anthropic-output-schema.ts +109 -11
- package/src/adapters/coding-agent/protocol.ts +106 -11
- package/src/adapters/coding-agent/turn.ts +23 -18
- package/src/adapters/google-errors.ts +16 -1
- package/src/adapters/google-http.ts +14 -0
- package/src/adapters/openai-chat/passthrough.ts +2 -0
- package/src/adapters/openai-chat/summary-budget.ts +72 -0
- package/src/adapters/openai-chat/tool-call-id-remint.ts +11 -2
- package/src/adapters/openai-chat.ts +6 -9
- package/src/bridge/response-json.ts +3 -1
- package/src/bridge/sse.ts +3 -1
- package/src/claude/desktop-picker.ts +6 -2
- package/src/claude/desktop-system-proxy.ts +155 -0
- package/src/claude/inbound-model-options.ts +10 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/picker-ca-cleanup.ts +31 -0
- package/src/claude/intercept/picker-ca.ts +283 -83
- package/src/claude/intercept/picker-models.ts +4 -1
- package/src/claude/intercept/picker-trust.ts +15 -2
- package/src/claude/intercept/runtime.ts +99 -32
- package/src/cli/account-api.ts +5 -2
- package/src/cli/account-extended.ts +47 -11
- package/src/cli/account-target.ts +12 -7
- package/src/cli/account.ts +47 -3
- package/src/cli/capabilities.ts +33 -7
- package/src/cli/claude-agent-startup-sync.ts +2 -0
- package/src/cli/claude-desktop.ts +37 -13
- package/src/cli/cross-home-owner.ts +138 -0
- package/src/cli/dispatch.ts +1 -1
- package/src/cli/doctor.ts +18 -8
- package/src/cli/ensure-desired-integrations.ts +5 -0
- package/src/cli/export-command.ts +6 -6
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +15 -18
- package/src/cli/integrations.ts +8 -1
- package/src/cli/models-runtime-subcommands.ts +1 -0
- package/src/cli/models-runtime.ts +106 -4
- package/src/cli/models.ts +6 -1
- package/src/cli/opencode.ts +2 -83
- package/src/cli/registry.ts +3 -2
- package/src/cli/status.ts +86 -7
- package/src/client/connect.ts +24 -10
- package/src/client/link-ingress.ts +3 -1
- package/src/client/link-join.ts +76 -9
- package/src/client/link-relay-transport.ts +184 -0
- package/src/client/link-relay.ts +16 -4
- package/src/client/machine-listener.ts +2 -0
- package/src/clients/config-export/constants.ts +8 -0
- package/src/clients/config-export/contracts.ts +13 -1
- package/src/clients/config-export/droid.ts +71 -0
- package/src/clients/config-export/kilo.ts +116 -0
- package/src/clients/config-export.ts +33 -2
- package/src/codex/auth-api/routes.ts +1 -1
- package/src/codex/catalog/access-programs.ts +14 -2
- package/src/codex/catalog/provider-models.ts +1 -1
- package/src/codex/catalog-auto-refresh.ts +122 -0
- package/src/codex/config-drift-heal.ts +80 -0
- package/src/codex/desktop-switches.ts +7 -5
- package/src/codex/inject/bounded-config-reader.ts +69 -0
- package/src/codex/inject/config-toml.ts +31 -3
- package/src/codex/inject/routing-target.ts +6 -1
- package/src/codex/inject.ts +4 -0
- package/src/codex/model-entitlements.ts +32 -5
- package/src/codex/native-profile-startup.ts +17 -5
- package/src/codex/project-config-warnings.ts +72 -21
- package/src/codex/quota-auto-refresh-state.ts +8 -2
- package/src/codex/quota-auto-refresh.ts +80 -9
- package/src/codex/sync.ts +10 -0
- package/src/combos/failover.ts +4 -1
- package/src/combos/jev.ts +15 -4
- package/src/combos/types.ts +16 -0
- package/src/config/diagnostics.ts +19 -1
- package/src/config/load-degrade.ts +29 -0
- package/src/config/macos-system-proxy.ts +114 -0
- package/src/config/proxy-env.ts +67 -20
- package/src/config/schema/config-schema.ts +15 -0
- package/src/config/schema/leaf-validators.ts +23 -0
- package/src/config.ts +2 -0
- package/src/generated/compatibility-version.json +220 -144
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/config-io.ts +12 -3
- package/src/integrations/droid-settings.ts +65 -0
- package/src/integrations/kilo-candidates.ts +30 -0
- package/src/integrations/mutation-plan.ts +62 -7
- package/src/integrations/native/ownership-preflight.ts +46 -12
- package/src/integrations/registry.ts +136 -7
- package/src/integrations/state.ts +52 -4
- package/src/integrations/target.ts +3 -0
- package/src/integrations/writer.ts +55 -14
- package/src/lib/account-selection-events.ts +14 -0
- package/src/lib/jsonc.ts +110 -0
- package/src/lib/standalone.ts +9 -1
- package/src/lib/windows-system-proxy.ts +101 -1
- package/src/link/relay-auth.ts +29 -0
- package/src/link/ssh-argv.ts +6 -3
- package/src/link/ssh-runner.ts +5 -5
- package/src/oauth/generic-account-failover.ts +52 -8
- package/src/oauth/index.ts +37 -14
- package/src/oauth/store.ts +96 -11
- package/src/oauth/token-guardian.ts +7 -3
- package/src/oauth/types.ts +2 -0
- package/src/plugins/loader.ts +5 -3
- package/src/providers/fastwire.ts +3 -0
- package/src/providers/key-failover.ts +13 -5
- package/src/providers/kiro-model-catalog.ts +4 -3
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-tiers-destination.ts +9 -0
- package/src/providers/quota/account-cache.ts +17 -3
- package/src/providers/quota/vendor-probes-oauth.ts +2 -0
- package/src/providers/quota.ts +2 -2
- package/src/responses/code-mode-helper-compat.ts +14 -1
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/server/auth-cors.ts +1 -0
- package/src/server/images.ts +70 -20
- package/src/server/index/link-listener.ts +20 -3
- package/src/server/index/link-relay-sessions.ts +138 -0
- package/src/server/index/optional-listeners.ts +11 -1
- package/src/server/index/serve-options.ts +12 -0
- package/src/server/inference/client-encoder-delivery.ts +1 -0
- package/src/server/local-management-read-client.ts +21 -2
- package/src/server/management/agent-settings-routes.ts +19 -5
- package/src/server/management/combo-routes.ts +5 -3
- package/src/server/management/config-routes.ts +32 -6
- package/src/server/management/model-routes.ts +232 -7
- package/src/server/management/model-rows.ts +199 -1
- package/src/server/management/oauth-account-routes.ts +63 -3
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/port-reclaim.ts +170 -32
- package/src/server/responses/adapter-delivery.ts +6 -4
- package/src/server/responses/adapter-dispatch.ts +72 -2
- package/src/server/responses/core-combo.ts +6 -1
- package/src/server/responses/core-normalize.ts +19 -4
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/memory-models.ts +169 -0
- package/src/server/responses/passthrough-delivery.ts +4 -0
- package/src/server/responses/passthrough-dispatch.ts +10 -0
- package/src/server/responses/request-prepare.ts +86 -4
- package/src/server/responses/request-transport.ts +4 -0
- package/src/server/responses/run-turn-execution.ts +5 -2
- package/src/server/responses/shadow-target-availability.ts +17 -4
- package/src/server/responses/sidecar-execution.ts +18 -2
- package/src/server/responses/skills-snapshot.ts +239 -0
- package/src/server/responses-undeclared-tool-guard.ts +43 -6
- package/src/server/search.ts +1 -1
- package/src/service/guards.ts +4 -5
- package/src/service/state.ts +39 -2
- package/src/service.ts +2 -2
- package/src/types/config.ts +46 -4
- package/src/types/provider.ts +9 -0
- package/src/types/request.ts +6 -0
- package/src/types/tools.ts +38 -8
- package/src/types.ts +3 -0
- package/src/update/async-check.ts +5 -1
- package/src/update/index.ts +30 -12
- package/src/update/job.ts +3 -1
- package/src/update/pnpm-read-policy.d.mts +4 -0
- package/src/update/pnpm-read-policy.mjs +44 -0
- package/src/update/worker-launch.ts +152 -11
- package/src/usage/cost.ts +6 -3
- package/src/usage/log.ts +4 -0
- package/src/web-search/backends.ts +2 -2
- package/src/web-search/devin-executor.ts +1 -1
- package/src/web-search/sidecar-providers.ts +5 -5
- package/gui/dist/assets/App-B5-cAEd2.js +0 -51
- package/gui/dist/assets/index-BDUBS8PW.css +0 -1
- package/gui/dist/assets/index-BcXblvem.js +0 -86
|
@@ -29,7 +29,12 @@ const MIN_INTERVAL_MS = 15 * 60_000;
|
|
|
29
29
|
* callers fail fast and defer (ConvergeRequest.mode) rather than holding the
|
|
30
30
|
* write lock across a slow tick.
|
|
31
31
|
*/
|
|
32
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
|
|
33
|
+
import type { OcxConfig } from "../types";
|
|
34
|
+
|
|
32
35
|
const TICK_DEADLINE_MS = 1_000;
|
|
36
|
+
const MAX_JOURNAL_BYTES = 1024 * 1024;
|
|
37
|
+
const MAX_CATALOG_BYTES = 64 * 1024 * 1024;
|
|
33
38
|
|
|
34
39
|
let timer: ReturnType<typeof setInterval> | null = null;
|
|
35
40
|
let detachShutdownHook: (() => void) | null = null;
|
|
@@ -43,6 +48,111 @@ let generation = 0;
|
|
|
43
48
|
/** setInterval does not skip a firing while the previous callback is still awaiting. */
|
|
44
49
|
let inFlight = false;
|
|
45
50
|
|
|
51
|
+
/** Read only a regular file, with a byte limit even if it grows after the stat. */
|
|
52
|
+
function readBoundedRegularFile(path: string, maxBytes: number): string | null {
|
|
53
|
+
let fd: number | undefined;
|
|
54
|
+
try {
|
|
55
|
+
const entry = lstatSync(path);
|
|
56
|
+
if (!entry.isFile() || entry.size > maxBytes) return null;
|
|
57
|
+
fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
58
|
+
const opened = fstatSync(fd);
|
|
59
|
+
if (!opened.isFile() || opened.size > maxBytes) return null;
|
|
60
|
+
const bytes = Buffer.alloc(opened.size + 1);
|
|
61
|
+
let length = 0;
|
|
62
|
+
while (length < bytes.length) {
|
|
63
|
+
const read = readSync(fd, bytes, length, bytes.length - length, null);
|
|
64
|
+
if (read === 0) break;
|
|
65
|
+
length += read;
|
|
66
|
+
}
|
|
67
|
+
return length > maxBytes ? null : bytes.subarray(0, length).toString("utf8");
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
} finally {
|
|
71
|
+
if (fd !== undefined) closeSync(fd);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A background observation must not use journaledInjectedCatalogPath(), which cleans invalid journals. */
|
|
76
|
+
function readJournaledCatalogPath(journalPath: string): string | null {
|
|
77
|
+
const bytes = readBoundedRegularFile(journalPath, MAX_JOURNAL_BYTES);
|
|
78
|
+
if (bytes === null) return null;
|
|
79
|
+
try {
|
|
80
|
+
const journal: unknown = JSON.parse(bytes);
|
|
81
|
+
if (!journal || typeof journal !== "object" || Array.isArray(journal)) return null;
|
|
82
|
+
const record = journal as Record<string, unknown>;
|
|
83
|
+
return record.version === 1 && typeof record.injectedCatalogPath === "string" && record.injectedCatalogPath.trim()
|
|
84
|
+
? record.injectedCatalogPath
|
|
85
|
+
: null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function usableCatalogPath(path: string | null): string | null {
|
|
92
|
+
if (!path) return null;
|
|
93
|
+
const bytes = readBoundedRegularFile(path, MAX_CATALOG_BYTES);
|
|
94
|
+
if (bytes === null) return null;
|
|
95
|
+
try {
|
|
96
|
+
const catalog: unknown = JSON.parse(bytes);
|
|
97
|
+
return catalog && typeof catalog === "object" && Array.isArray((catalog as { models?: unknown }).models)
|
|
98
|
+
? path : null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Testable path decision with no journal mutation or catalog write. */
|
|
105
|
+
export function selectDriftHealCatalogPath(
|
|
106
|
+
journalPath: string,
|
|
107
|
+
defaultCatalogPath: string,
|
|
108
|
+
resolvePath: (path: string) => string,
|
|
109
|
+
): string | null {
|
|
110
|
+
const recorded = readJournaledCatalogPath(journalPath);
|
|
111
|
+
return usableCatalogPath(recorded ? resolvePath(recorded) : null)
|
|
112
|
+
?? usableCatalogPath(defaultCatalogPath);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Repair only the missing config roots; catalog convergence remains the later tick step. */
|
|
116
|
+
async function healCodexConfigDrift(config: OcxConfig, entryGeneration: number): Promise<"none" | "healed" | "not-healed"> {
|
|
117
|
+
const [{ codexConfigDrift }, { JOURNAL_PATH, journaledInjectedOpenaiBaseUrl, journaledInjectedRealtimeWsBaseUrl }, { shouldSyncCodexOnStart }, { injectCodexConfig }, { DEFAULT_CATALOG_PATH, resolveCodexConfigPath }, { loadConfig }] =
|
|
118
|
+
await Promise.all([
|
|
119
|
+
import("./config-drift-heal"),
|
|
120
|
+
import("./journal"),
|
|
121
|
+
import("./desired-state"),
|
|
122
|
+
import("./inject"),
|
|
123
|
+
import("./paths"),
|
|
124
|
+
import("../config"),
|
|
125
|
+
]);
|
|
126
|
+
const capturedSettings = JSON.stringify(config);
|
|
127
|
+
const current = () => entryGeneration === generation && JSON.stringify(loadConfig()) === capturedSettings;
|
|
128
|
+
if (!current()) return "none";
|
|
129
|
+
if (!shouldSyncCodexOnStart(config)) return "none";
|
|
130
|
+
const journaled = {
|
|
131
|
+
injectedOpenaiBaseUrl: journaledInjectedOpenaiBaseUrl({ readOnly: true }),
|
|
132
|
+
injectedRealtimeWsBaseUrl: journaledInjectedRealtimeWsBaseUrl({ readOnly: true }),
|
|
133
|
+
};
|
|
134
|
+
const drift = codexConfigDrift(() => journaled);
|
|
135
|
+
if (!drift.drifted) return "none";
|
|
136
|
+
const { readRuntimePort } = await import("../config/process-state");
|
|
137
|
+
if (!current()) return "none";
|
|
138
|
+
const runtime = readRuntimePort(process.pid);
|
|
139
|
+
if (!runtime) return "not-healed";
|
|
140
|
+
const catalogPath = selectDriftHealCatalogPath(JOURNAL_PATH, DEFAULT_CATALOG_PATH, resolveCodexConfigPath);
|
|
141
|
+
if (!current()) return "none";
|
|
142
|
+
// Keep the missing routing roots visible to the next tick. Catalog-only convergence below
|
|
143
|
+
// may recreate the file now, but it does not re-inject config.toml in this tick.
|
|
144
|
+
if (catalogPath === null) return "not-healed";
|
|
145
|
+
await injectCodexConfig(runtime.port, config, {
|
|
146
|
+
catalogPath,
|
|
147
|
+
lockTimeoutMs: TICK_DEADLINE_MS,
|
|
148
|
+
beforeClientWrite: () => {
|
|
149
|
+
if (!current()) throw new Error("Catalog drift heal tick is stale");
|
|
150
|
+
},
|
|
151
|
+
}).catch(() => null);
|
|
152
|
+
if (!current()) return "none";
|
|
153
|
+
return codexConfigDrift(() => journaled).drifted ? "not-healed" : "healed";
|
|
154
|
+
}
|
|
155
|
+
|
|
46
156
|
/** Number of ticks that have run. Test-only observability; carries no catalog data. */
|
|
47
157
|
let tickCount = 0;
|
|
48
158
|
|
|
@@ -89,6 +199,18 @@ async function tick(): Promise<void> {
|
|
|
89
199
|
// callee at all. Only while this tick still owns the timer.
|
|
90
200
|
restartIfCadenceChanged(configured);
|
|
91
201
|
tickCount += 1;
|
|
202
|
+
// Config-surface healing precedes catalog work: a desktop app rewrite that stripped the
|
|
203
|
+
// injected routing keys leaves the catalog file untouched, so the converge below would
|
|
204
|
+
// report "no change" while Codex serves its native model picker. Re-injecting through the
|
|
205
|
+
// standard sync rewrites the keys, re-journals the baseline, and only runs when the
|
|
206
|
+
// integration is on and this install is allowed to manage its local client.
|
|
207
|
+
const heal = await healCodexConfigDrift(config, entryGeneration);
|
|
208
|
+
if (entryGeneration !== generation) return;
|
|
209
|
+
if (heal === "healed") {
|
|
210
|
+
console.info("[catalog-auto-refresh] injected Codex config keys were rewritten externally; re-injected");
|
|
211
|
+
} else if (heal === "not-healed") {
|
|
212
|
+
console.info("[catalog-auto-refresh] injected Codex config keys were rewritten externally; not re-injected this tick");
|
|
213
|
+
}
|
|
92
214
|
const [{ createManagementConvergeCodex }, { createCatalogConvergeRequest }] = await Promise.all([
|
|
93
215
|
import("./management-convergence"),
|
|
94
216
|
import("./catalog-admission"),
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config-surface drift detection for the injected Codex config.
|
|
3
|
+
*
|
|
4
|
+
* The Codex desktop app rewrites `~/.codex/config.toml` on its own schedule — app updates,
|
|
5
|
+
* settings changes, reserializations — and a 26.924-era rewrite strips the injected root keys
|
|
6
|
+
* (`openai_base_url`, `model_catalog_json`, the realtime sideband) that carry proxy routing
|
|
7
|
+
* and the model list. Nothing else notices: the catalog file survives, so the catalog-only
|
|
8
|
+
* converge funnel reports "no change", and the app keeps serving its native model picker.
|
|
9
|
+
* Startup sync re-injects, but the proxy may run for days between restarts.
|
|
10
|
+
*
|
|
11
|
+
* This module answers the one question the journal was built to answer and nobody asked in
|
|
12
|
+
* the background: are the bytes on disk still the bytes opencodex injected? The journal
|
|
13
|
+
* records the values the last injection wrote (`injectedOpenaiBaseUrl`,
|
|
14
|
+
* `injectedRealtimeWsBaseUrl`). A caller polls `codexConfigDrift()`; when a journaled root
|
|
15
|
+
* key is absent while the journal says it should be present, the caller re-runs the standard
|
|
16
|
+
* injection, which rewrites the keys and re-journals the new baseline.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately not a filesystem watcher: launchd/FSWatcher coverage for an arbitrary home
|
|
19
|
+
* adds an agent surface for a file that also changes for legitimate reasons (the user's own
|
|
20
|
+
* edits, `ocx restore`). Polling at the auto-refresh cadence bounds staleness to one interval
|
|
21
|
+
* and reuses an already-armed timer.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
25
|
+
import { CODEX_CONFIG_PATH } from "./paths";
|
|
26
|
+
import {
|
|
27
|
+
REALTIME_WS_BASE_URL_KEY,
|
|
28
|
+
rootTomlString,
|
|
29
|
+
} from "./injected-marker";
|
|
30
|
+
|
|
31
|
+
export interface CodexConfigDrift {
|
|
32
|
+
/**
|
|
33
|
+
* An injected routing key is absent from the on-disk config while the journal says the
|
|
34
|
+
* last injection wrote it: the surface was rewritten underneath opencodex.
|
|
35
|
+
*/
|
|
36
|
+
readonly drifted: boolean;
|
|
37
|
+
/** Which injected keys went missing. Empty when not drifted. */
|
|
38
|
+
readonly missingKeys: readonly string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Compare the on-disk config against what the journal says the last injection wrote.
|
|
43
|
+
*
|
|
44
|
+
* The journal is the only authority: a config that never was injected (no journal, or a
|
|
45
|
+
* journal without recorded URLs) reports no drift, because there is nothing to heal. A
|
|
46
|
+
* PRESENT key always satisfies the check regardless of its value — the injector's own
|
|
47
|
+
* user-ownership rules (a user's `openai_base_url` without the marker is kept, reported as
|
|
48
|
+
* `keptUserBaseUrl`, and re-journaled as null) own that case, so a heal triggered on a
|
|
49
|
+
* missing key runs into them and stops on its own rather than stomping a user's routing.
|
|
50
|
+
*/
|
|
51
|
+
export function codexConfigDrift(
|
|
52
|
+
readJournal: () => { injectedOpenaiBaseUrl?: string | null; injectedRealtimeWsBaseUrl?: string | null } | null,
|
|
53
|
+
configPath: string = CODEX_CONFIG_PATH,
|
|
54
|
+
readFile: (path: string) => string = path => readFileSync(path, "utf-8"),
|
|
55
|
+
fileExists: (path: string) => boolean = existsSync,
|
|
56
|
+
): CodexConfigDrift {
|
|
57
|
+
const journal = readJournal();
|
|
58
|
+
const journaledBaseUrl = journal?.injectedOpenaiBaseUrl;
|
|
59
|
+
const journaledRealtimeWsUrl = journal?.injectedRealtimeWsBaseUrl;
|
|
60
|
+
if (!journaledBaseUrl && !journaledRealtimeWsUrl) {
|
|
61
|
+
return { drifted: false, missingKeys: [] };
|
|
62
|
+
}
|
|
63
|
+
if (!fileExists(configPath)) {
|
|
64
|
+
return { drifted: false, missingKeys: [] };
|
|
65
|
+
}
|
|
66
|
+
let content: string;
|
|
67
|
+
try {
|
|
68
|
+
content = readFile(configPath);
|
|
69
|
+
} catch {
|
|
70
|
+
return { drifted: false, missingKeys: [] };
|
|
71
|
+
}
|
|
72
|
+
const missingKeys: string[] = [];
|
|
73
|
+
if (journaledBaseUrl && rootTomlString(content, "openai_base_url") === null) {
|
|
74
|
+
missingKeys.push("openai_base_url");
|
|
75
|
+
}
|
|
76
|
+
if (journaledRealtimeWsUrl && rootTomlString(content, REALTIME_WS_BASE_URL_KEY) === null) {
|
|
77
|
+
missingKeys.push(REALTIME_WS_BASE_URL_KEY);
|
|
78
|
+
}
|
|
79
|
+
return { drifted: missingKeys.length > 0, missingKeys };
|
|
80
|
+
}
|
|
@@ -116,14 +116,16 @@ export function describeCodexDesktopSwitches(
|
|
|
116
116
|
*/
|
|
117
117
|
export async function observedCodexDesktopSwitchApply(): Promise<CodexDesktopSwitchApply> {
|
|
118
118
|
// Same lazy boundary as applyCodexConfigInjection: the ownership predicate lives in the
|
|
119
|
-
// injection graph, which the settings read path must not pull in at module scope.
|
|
120
|
-
|
|
119
|
+
// injection graph, which the settings read path must not pull in at module scope. This
|
|
120
|
+
// path uses the bounded variant — a special or oversized config.toml must answer
|
|
121
|
+
// "undetermined", never stall a settings read the way an unbounded readFileSync would.
|
|
122
|
+
const { observedExternalCodexModelProvider } = await import("./inject/config-toml");
|
|
121
123
|
let provider: string | null;
|
|
122
124
|
try {
|
|
123
|
-
provider =
|
|
125
|
+
provider = observedExternalCodexModelProvider();
|
|
124
126
|
} catch (error) {
|
|
125
|
-
// A present-but-unreadable config.toml (permissions, deletion racing
|
|
126
|
-
// must not take down the whole settings report. The undetermined reason keeps the
|
|
127
|
+
// A present-but-unreadable config.toml (permissions, deletion racing the bounded
|
|
128
|
+
// read) must not take down the whole settings report. The undetermined reason keeps the
|
|
127
129
|
// reporting contract honest: effective values and the sign-in answer stay null instead
|
|
128
130
|
// of presenting local state a foreign provider may still control.
|
|
129
131
|
return {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readSync, statSync, type Stats } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const MAX_CODEX_CONFIG_BYTES = 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read config.toml the way Codex and the injector resolve it — a symlink's target IS the
|
|
7
|
+
* config — without blocking on a special file or buffering without bound.
|
|
8
|
+
*
|
|
9
|
+
* `null` means only "absent at the initial lookup". A path that vanishes or is swapped
|
|
10
|
+
* underneath the read throws the changed-file error instead, because the observation is
|
|
11
|
+
* then undetermined rather than negative: the caller must not report a config the probe
|
|
12
|
+
* watched disappear as simply not there.
|
|
13
|
+
*/
|
|
14
|
+
export function readBoundedCodexConfig(path: string): string | null {
|
|
15
|
+
let fd: number | undefined;
|
|
16
|
+
try {
|
|
17
|
+
let namedBefore: Stats;
|
|
18
|
+
try {
|
|
19
|
+
namedBefore = statSync(path);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
22
|
+
if (code === "ENOENT" || code === "ENOTDIR") return null;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
if (!namedBefore.isFile() || namedBefore.size > MAX_CODEX_CONFIG_BYTES) {
|
|
26
|
+
throw new Error("config.toml is not a bounded regular file");
|
|
27
|
+
}
|
|
28
|
+
// Deliberately no O_NOFOLLOW: Codex and the injector read through a symlinked
|
|
29
|
+
// config.toml, so refusing the link here would disagree with the writes this probe
|
|
30
|
+
// stands in front of. O_NONBLOCK is what keeps a FIFO — linked or direct — from
|
|
31
|
+
// stalling the open; the descriptor checks below still reject anything non-regular.
|
|
32
|
+
const guardedFlags = process.platform === "win32"
|
|
33
|
+
? 0
|
|
34
|
+
: (constants.O_NONBLOCK ?? 0);
|
|
35
|
+
fd = openSync(path, constants.O_RDONLY | guardedFlags);
|
|
36
|
+
const before = fstatSync(fd);
|
|
37
|
+
if (before.dev !== namedBefore.dev || before.ino !== namedBefore.ino) {
|
|
38
|
+
throw new Error("config.toml changed while it was read");
|
|
39
|
+
}
|
|
40
|
+
if (!before.isFile() || before.size > MAX_CODEX_CONFIG_BYTES) {
|
|
41
|
+
throw new Error("config.toml is not a bounded regular file");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const buffer = Buffer.allocUnsafe(before.size + 1);
|
|
45
|
+
let bytesRead = 0;
|
|
46
|
+
while (bytesRead < buffer.length) {
|
|
47
|
+
const count = readSync(fd, buffer, bytesRead, buffer.length - bytesRead, null);
|
|
48
|
+
if (count === 0) break;
|
|
49
|
+
bytesRead += count;
|
|
50
|
+
}
|
|
51
|
+
const after = fstatSync(fd);
|
|
52
|
+
const namedAfter = statSync(path);
|
|
53
|
+
if (bytesRead !== before.size || after.size !== before.size
|
|
54
|
+
|| after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs
|
|
55
|
+
|| !namedAfter.isFile()
|
|
56
|
+
|| namedAfter.dev !== before.dev || namedAfter.ino !== before.ino) {
|
|
57
|
+
throw new Error("config.toml changed while it was read");
|
|
58
|
+
}
|
|
59
|
+
return buffer.toString("utf8", 0, bytesRead);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
62
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
63
|
+
throw new Error("config.toml changed while it was read");
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
} finally {
|
|
67
|
+
if (fd !== undefined) closeSync(fd);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
REALTIME_WS_BASE_URL_KEY,
|
|
9
9
|
isRootOpenaiBaseUrlLine,
|
|
10
10
|
isRootRealtimeWsBaseUrlLine,
|
|
11
|
+
providerTableStart,
|
|
12
|
+
providerTableString,
|
|
11
13
|
rootTomlString,
|
|
12
14
|
tomlStringPattern,
|
|
13
15
|
} from "../injected-marker";
|
|
@@ -18,6 +20,7 @@ import {
|
|
|
18
20
|
resolveCodexConfigPath,
|
|
19
21
|
tomlString,
|
|
20
22
|
} from "../paths";
|
|
23
|
+
import { readBoundedCodexConfig } from "./bounded-config-reader";
|
|
21
24
|
import {
|
|
22
25
|
type CodexRoutingTarget,
|
|
23
26
|
providerBaseHost,
|
|
@@ -28,16 +31,41 @@ import {
|
|
|
28
31
|
|
|
29
32
|
export function externalCodexModelProvider(content: string): string | null {
|
|
30
33
|
const provider = resolveEffectiveProjectModelProvider(content).provider;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
if (!provider || provider === "openai" || provider === "opencodex") return null;
|
|
35
|
+
// A provider table counts as an external owner only when it carries a `base_url` — a
|
|
36
|
+
// gateway names somebody else's endpoint. A base-url-less table is the Codex desktop
|
|
37
|
+
// app's own native-routing placeholder (since app 26.924 each app-managed rewrite
|
|
38
|
+
// writes `model_provider = "custom"` plus such a table, stripping the injected root
|
|
39
|
+
// keys alongside), which re-injects cleanly because the injector strips a root
|
|
40
|
+
// `model_provider` line first. A REAL external provider must carry a base_url.
|
|
41
|
+
if (providerTableStart(content.split("\n"), provider) !== -1 && providerTableString(content, provider, "base_url") === null) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return provider;
|
|
34
45
|
}
|
|
35
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The ownership answer for read/write paths — inject, sync, connect, restore, and the
|
|
49
|
+
* shutdown gate. It deliberately reads the whole file like Codex does (links included):
|
|
50
|
+
* a large or link-mediated config is still a valid config, and these callers must
|
|
51
|
+
* classify it exactly rather than degrade to "undetermined".
|
|
52
|
+
*/
|
|
36
53
|
export function currentExternalCodexModelProvider(): string | null {
|
|
37
54
|
if (!existsSync(CODEX_CONFIG_PATH)) return null;
|
|
38
55
|
return externalCodexModelProvider(readFileSync(CODEX_CONFIG_PATH, "utf8"));
|
|
39
56
|
}
|
|
40
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The same ownership answer for read-only observation (the settings GET / poll path),
|
|
60
|
+
* through a bounded read so a special or oversized config.toml cannot stall a request.
|
|
61
|
+
* A present-but-unreadable config throws so the caller reports undetermined ownership
|
|
62
|
+
* instead of "none".
|
|
63
|
+
*/
|
|
64
|
+
export function observedExternalCodexModelProvider(): string | null {
|
|
65
|
+
const content = readBoundedCodexConfig(CODEX_CONFIG_PATH);
|
|
66
|
+
return content === null ? null : externalCodexModelProvider(content);
|
|
67
|
+
}
|
|
68
|
+
|
|
41
69
|
/**
|
|
42
70
|
* Detect the file's dominant line ending. Every transform in this module is LF-pure
|
|
43
71
|
* (split("\n") + hard "\n" joins), so CRLF configs (Windows-edited config.toml) are
|
|
@@ -50,6 +50,12 @@ export function usesProviderTable(target: CodexRoutingTarget): boolean {
|
|
|
50
50
|
|| target.clientCompaction === true;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** Listing is owned by native app-server, not the inference proxy; never repair it by retagging. */
|
|
54
|
+
export function remoteThreadListCompatibilityWarning(target: CodexRoutingTarget): string {
|
|
55
|
+
if (!usesProviderTable(target)) return "";
|
|
56
|
+
return " ⚠️ Codex remote history: some app-server/mobile versions filter thread/list to the default provider and may hide existing openai-tagged threads. This is listing visibility, not deleted history. A compatible list client can request modelProviders: []; otherwise verify native client/app-server compatibility. OpenCodex does not change history tags for this warning.\n";
|
|
57
|
+
}
|
|
58
|
+
|
|
53
59
|
export function standaloneCodexRoutingTarget(
|
|
54
60
|
port: number,
|
|
55
61
|
config?: Pick<
|
|
@@ -122,4 +128,3 @@ export function providerBaseHost(hostname: string | undefined): string {
|
|
|
122
128
|
if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
|
|
123
129
|
return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
|
|
124
130
|
}
|
|
125
|
-
|
package/src/codex/inject.ts
CHANGED
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
import type { OcxConfig } from "../types";
|
|
66
66
|
import {
|
|
67
67
|
configuredManagedSubagentDefaults,
|
|
68
|
+
remoteThreadListCompatibilityWarning,
|
|
68
69
|
standaloneCodexRoutingTarget,
|
|
69
70
|
validateCodexRoutingTarget,
|
|
70
71
|
type CodexRoutingTarget,
|
|
@@ -718,6 +719,7 @@ async function injectCodexConfigImpl(
|
|
|
718
719
|
const catalogMessage = effectivePlan.catalogPath
|
|
719
720
|
? ` Codex model catalog: ${effectivePlan.catalogPath}\n`
|
|
720
721
|
: ` Codex model catalog not injected because no opencodex catalog file exists yet.\n`;
|
|
722
|
+
const remoteHistoryMessage = remoteThreadListCompatibilityWarning(routingTarget);
|
|
721
723
|
const ejected = (history as { ejectedRows?: number }).ejectedRows ?? 0;
|
|
722
724
|
const migratedRows = (history.rows ?? 0) + ejected;
|
|
723
725
|
const historyMessage =
|
|
@@ -753,6 +755,7 @@ async function injectCodexConfigImpl(
|
|
|
753
755
|
` Your root openai_base_url was left exactly as you set it, so opencodex did not add its own.\n` +
|
|
754
756
|
catalogMessage +
|
|
755
757
|
historyMessage +
|
|
758
|
+
remoteHistoryMessage +
|
|
756
759
|
effectivePlan.managedDefaultsMessage +
|
|
757
760
|
` New threads use the injected opencodex provider and route through the proxy.\n` +
|
|
758
761
|
` Threads already tagged openai resolve through Codex's built-in provider, which your root openai_base_url points at.\n` +
|
|
@@ -791,6 +794,7 @@ async function injectCodexConfigImpl(
|
|
|
791
794
|
headline +
|
|
792
795
|
catalogMessage +
|
|
793
796
|
historyMessage +
|
|
797
|
+
remoteHistoryMessage +
|
|
794
798
|
effectivePlan.managedDefaultsMessage +
|
|
795
799
|
` All models now route through opencodex proxy (like OpenRouter).\n` +
|
|
796
800
|
` OpenAI models (gpt-5.5, etc.) are passed through to OpenAI.\n` +
|
|
@@ -408,6 +408,7 @@ interface CachedAccountModels {
|
|
|
408
408
|
readonly expiresAt: number;
|
|
409
409
|
readonly models: ReadonlySet<string>;
|
|
410
410
|
readonly accessProgramsByModel?: ReadonlyMap<string, CodexAvailableAccessPrograms>;
|
|
411
|
+
readonly availabilityNuxByModel?: ReadonlyMap<string, { message: string }>;
|
|
411
412
|
readonly confirmed: boolean;
|
|
412
413
|
readonly provenance?: CodexModelEntitlementProvenance;
|
|
413
414
|
}
|
|
@@ -415,6 +416,7 @@ interface CachedAccountModels {
|
|
|
415
416
|
export interface CodexModelEntitlementSnapshot {
|
|
416
417
|
readonly modelsByAccount: ReadonlyMap<string, ReadonlySet<string>>;
|
|
417
418
|
readonly accessProgramsByAccount?: ReadonlyMap<string, ReadonlyMap<string, CodexAvailableAccessPrograms>>;
|
|
419
|
+
readonly availabilityNuxByAccount?: ReadonlyMap<string, ReadonlyMap<string, { message: string }>>;
|
|
418
420
|
readonly clientVersionByAccount: ReadonlyMap<string, string>;
|
|
419
421
|
readonly confirmedAccountIds: ReadonlySet<string>;
|
|
420
422
|
readonly credentialIdentities: ReadonlyMap<string, string>;
|
|
@@ -637,16 +639,36 @@ async function accountCredentialSnapshot(
|
|
|
637
639
|
}
|
|
638
640
|
}
|
|
639
641
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
+
const AVAILABILITY_MESSAGE_MAX_CODE_UNITS = 2_000;
|
|
643
|
+
// Codex parses the catalog with a strict JSON reader that rejects a lone surrogate escape.
|
|
644
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
645
|
+
|
|
646
|
+
/** Trim and cap a prompt without leaving half of a surrogate pair at the cut or anywhere else. */
|
|
647
|
+
function boundedAvailabilityMessage(message: string): string {
|
|
648
|
+
return message.trim().slice(0, AVAILABILITY_MESSAGE_MAX_CODE_UNITS).replace(LONE_SURROGATE, "").trim();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Keep valid roster slugs while dropping malformed program and availability metadata. */
|
|
652
|
+
function parseAccountModels(text: string): {
|
|
653
|
+
models: ReadonlySet<string>;
|
|
654
|
+
accessProgramsByModel: ReadonlyMap<string, CodexAvailableAccessPrograms>;
|
|
655
|
+
availabilityNuxByModel: ReadonlyMap<string, { message: string }>;
|
|
656
|
+
} | null {
|
|
642
657
|
try {
|
|
643
658
|
const payload = JSON.parse(text) as { models?: unknown };
|
|
644
659
|
if (!Array.isArray(payload.models)) return null;
|
|
645
660
|
const accessProgramsByModel = new Map<string, CodexAvailableAccessPrograms>();
|
|
661
|
+
const availabilityNuxByModel = new Map<string, { message: string }>();
|
|
646
662
|
const models = payload.models.flatMap(entry => {
|
|
647
663
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
|
|
648
|
-
const row = entry as { slug?: unknown; supported_in_api?: unknown; visibility?: unknown; available_access_programs?: unknown };
|
|
664
|
+
const row = entry as { slug?: unknown; supported_in_api?: unknown; visibility?: unknown; available_access_programs?: unknown; availability_nux?: unknown };
|
|
649
665
|
if (typeof row.slug !== "string" || row.supported_in_api !== true || row.visibility === "hide") return [];
|
|
666
|
+
const nux = row.availability_nux;
|
|
667
|
+
if (nux && typeof nux === "object" && !Array.isArray(nux)) {
|
|
668
|
+
const message = (nux as { message?: unknown }).message;
|
|
669
|
+
const bounded = typeof message === "string" ? boundedAvailabilityMessage(message) : "";
|
|
670
|
+
if (bounded) availabilityNuxByModel.set(row.slug, { message: bounded });
|
|
671
|
+
}
|
|
650
672
|
const programs = row.available_access_programs;
|
|
651
673
|
if (programs === null) accessProgramsByModel.set(row.slug, null);
|
|
652
674
|
else if (programs && typeof programs === "object" && !Array.isArray(programs)) {
|
|
@@ -659,7 +681,7 @@ function parseAccountModels(text: string): { models: ReadonlySet<string>; access
|
|
|
659
681
|
}
|
|
660
682
|
return [row.slug];
|
|
661
683
|
});
|
|
662
|
-
return { models: new Set(models), accessProgramsByModel };
|
|
684
|
+
return { models: new Set(models), accessProgramsByModel, availabilityNuxByModel };
|
|
663
685
|
} catch {
|
|
664
686
|
return null;
|
|
665
687
|
}
|
|
@@ -730,7 +752,7 @@ async function fetchAccountModels(
|
|
|
730
752
|
// account asked under too old a client version answers with no gated rows, and treating
|
|
731
753
|
// that as authoritative is exactly how 2.36.0 denied sol/terra/luna to accounts that own
|
|
732
754
|
// them (#3022). No usable rows means unconfirmed, on the 15s failure TTL, asked again.
|
|
733
|
-
const { models, accessProgramsByModel } = parsed;
|
|
755
|
+
const { models, accessProgramsByModel, availabilityNuxByModel } = parsed;
|
|
734
756
|
const usable = models.size > 0;
|
|
735
757
|
if (!usable) {
|
|
736
758
|
return unconfirmedAccountModels(credential, clientVersion, now, { kind: "parsed-empty" });
|
|
@@ -754,6 +776,7 @@ async function fetchAccountModels(
|
|
|
754
776
|
: MODEL_ROSTER_FAILURE_TTL_MS),
|
|
755
777
|
models,
|
|
756
778
|
accessProgramsByModel,
|
|
779
|
+
availabilityNuxByModel,
|
|
757
780
|
confirmed: true,
|
|
758
781
|
};
|
|
759
782
|
} catch (error) {
|
|
@@ -1196,6 +1219,10 @@ export async function resolveCodexModelEntitlements(
|
|
|
1196
1219
|
result.confirmed && result.accessProgramsByModel
|
|
1197
1220
|
? [[credential.accountId, result.accessProgramsByModel] as const] : []
|
|
1198
1221
|
))),
|
|
1222
|
+
availabilityNuxByAccount: new Map(results.flatMap(({ credential, result }) => (
|
|
1223
|
+
result.confirmed && result.availabilityNuxByModel
|
|
1224
|
+
? [[credential.accountId, result.availabilityNuxByModel] as const] : []
|
|
1225
|
+
))),
|
|
1199
1226
|
clientVersionByAccount: new Map(results.map(({ credential, result }) => (
|
|
1200
1227
|
[credential.accountId, result.clientVersion]
|
|
1201
1228
|
))),
|
|
@@ -78,6 +78,8 @@ interface StartupEntry {
|
|
|
78
78
|
recoveryStarted: boolean;
|
|
79
79
|
policyBindingPending: boolean;
|
|
80
80
|
settled: Promise<NativeMainStartupGateSnapshot>;
|
|
81
|
+
/** Publication provenance only; never substitutes for the live convergence drain. */
|
|
82
|
+
snapshotSettled?: Promise<NativeMainStartupGateSnapshot>;
|
|
81
83
|
resolveAcquisition?: (value: NativeMainStartupGateSnapshot) => void;
|
|
82
84
|
deps: NativeMainStartupGateDeps;
|
|
83
85
|
manager: NativeProfileManager;
|
|
@@ -401,6 +403,7 @@ export function startNativeMainStartupLifecycle(
|
|
|
401
403
|
released = true;
|
|
402
404
|
entry!.refs = Math.max(0, entry!.refs - 1);
|
|
403
405
|
if (entry!.refs !== 0) return;
|
|
406
|
+
const releasedEpoch = entry!.epoch;
|
|
404
407
|
entry!.epoch += 1;
|
|
405
408
|
entry!.sweepStopping = true;
|
|
406
409
|
if (entry!.sweepTimer) clearTimeout(entry!.sweepTimer);
|
|
@@ -413,11 +416,17 @@ export function startNativeMainStartupLifecycle(
|
|
|
413
416
|
// the process exited, because a server whose config does not sync Codex installs a no-op
|
|
414
417
|
// lifecycle that never touches the gate.
|
|
415
418
|
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
|
|
419
|
+
// Reset only a snapshot published by this startup generation. A profile transaction can
|
|
420
|
+
// independently replace it with a same-home recovery fence, which must survive this release.
|
|
421
|
+
// The epoch provenance misses one case: that fence advances the global epoch while this
|
|
422
|
+
// generation's convergence is still in flight, and `completeNativeMainRecovery` then rebinds
|
|
423
|
+
// the shared `settled` to this entry's own pending chain without re-stamping either epoch.
|
|
424
|
+
// The pending snapshot is again this generation's own, so `settled` identity proves it too.
|
|
425
|
+
// Do this synchronously and before the first await: a NEW entry created for the same home
|
|
426
|
+
// afterwards re-arms its own gate and cannot be clobbered by this release. The epoch bump
|
|
427
|
+
// retires any in-flight convergence write from the released generation.
|
|
428
|
+
if (snapshot.homeId === homeId && !startupEntries.has(homeId)
|
|
429
|
+
&& (epoch === releasedEpoch || settled === entry!.settled || settled === entry!.snapshotSettled)) {
|
|
421
430
|
epoch += 1;
|
|
422
431
|
snapshot = ready(null);
|
|
423
432
|
settled = Promise.resolve(snapshot);
|
|
@@ -728,6 +737,9 @@ export function completeNativeMainRecovery(homeId: string): boolean {
|
|
|
728
737
|
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
729
738
|
snapshot = ready(homeId);
|
|
730
739
|
settled = Promise.resolve(snapshot);
|
|
740
|
+
// Remember who published this snapshot without replacing an in-flight recovery/sweep
|
|
741
|
+
// promise: last-reference release must still drain that original convergence chain.
|
|
742
|
+
if (entry) entry.snapshotSettled = settled;
|
|
731
743
|
return true;
|
|
732
744
|
}
|
|
733
745
|
|