@bitkyc08/opencodex 2.10.1 → 2.10.2
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 +18 -9
- package/gui/dist/assets/index-BKVqyYqT.js +70 -0
- package/gui/dist/assets/{index-Cd6_PBKn.css → index-Ca_3269W.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/commandcode-color.svg +1 -0
- package/gui/dist/provider-icons/openai.svg +1 -1
- package/package.json +1 -1
- package/src/adapters/command-code.ts +453 -0
- package/src/adapters/google.ts +3 -0
- package/src/cli/claude.ts +37 -22
- package/src/cli/index.ts +16 -3
- package/src/cli/launcher-context.ts +77 -0
- package/src/codex/admission.ts +1 -1
- package/src/codex/app-server-processes.ts +44 -1
- package/src/codex/catalog/sync.ts +22 -3
- package/src/codex/catalog-write-serialization.ts +1 -1
- package/src/codex/codex-write-lock.ts +1 -1
- package/src/codex/convergence-types.ts +1 -1
- package/src/codex/desired-state.ts +27 -7
- package/src/codex/history-job.ts +1 -1
- package/src/codex/history-lock.ts +1 -1
- package/src/codex/history-worker.ts +1 -1
- package/src/codex/internal/history-writer.ts +1 -1
- package/src/codex/transition-state.ts +1 -1
- package/src/codex/user-identity.ts +1 -1
- package/src/config.ts +6 -1
- package/src/integrations/config-io.ts +1 -1
- package/src/integrations/journal.ts +1 -1
- package/src/integrations/merge.ts +1 -1
- package/src/integrations/ownership.ts +1 -1
- package/src/integrations/registry.ts +1 -1
- package/src/integrations/serialize.ts +1 -1
- package/src/integrations/state.ts +1 -1
- package/src/integrations/store.ts +1 -1
- package/src/integrations/writer.ts +1 -1
- package/src/lib/bounded-body.ts +3 -1
- package/src/lib/bun-runtime.ts +21 -17
- package/src/lib/bun-stream-caps.ts +1 -1
- package/src/lib/local-management-attestation.ts +51 -0
- package/src/lib/shadow-call.ts +4 -4
- package/src/oauth/command-code.ts +239 -0
- package/src/oauth/health.ts +46 -2
- package/src/oauth/index.ts +38 -3
- package/src/providers/command-code-efforts.ts +85 -0
- package/src/providers/google-vertex-location.ts +14 -0
- package/src/providers/registry.ts +138 -26
- package/src/routing/capability.ts +1 -0
- package/src/server/adapter-resolve.ts +3 -0
- package/src/server/auth-cors.ts +6 -1
- package/src/server/index.ts +44 -2
- package/src/server/management/integration-routes.ts +1 -1
- package/src/server/management/native-integration-routes.ts +2 -2
- package/src/server/responses/core.ts +13 -9
- package/src/storage/scanner.ts +1 -1
- package/src/types.ts +6 -0
- package/src/usage/log.ts +1 -1
- package/gui/dist/assets/index-ChZQsmBY.js +0 -70
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trusted facts captured by the plain-Node npm launcher before Bun auto-loads
|
|
3
|
+
* project dotenv files. The random proof travels in argv while the context
|
|
4
|
+
* travels in the environment, so a project `.env` cannot forge the pair during
|
|
5
|
+
* an ordinary `ocx ...` invocation.
|
|
6
|
+
*/
|
|
7
|
+
export const NODE_LAUNCH_CONTEXT_ENV = "OCX_NODE_LAUNCH_CONTEXT";
|
|
8
|
+
export const NODE_LAUNCH_PROOF_PREFIX = "--ocx-internal-launch-proof=";
|
|
9
|
+
|
|
10
|
+
export const ANTHROPIC_PARENT_ENV_SLOTS = [
|
|
11
|
+
"ANTHROPIC_API_KEY",
|
|
12
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
13
|
+
"ANTHROPIC_BASE_URL",
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export type AnthropicParentEnvSlot = typeof ANTHROPIC_PARENT_ENV_SLOTS[number];
|
|
17
|
+
|
|
18
|
+
export type TrustedNodeLaunchContext = {
|
|
19
|
+
anthropicEnvSlots: readonly AnthropicParentEnvSlot[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let trustedContext: TrustedNodeLaunchContext | null = null;
|
|
23
|
+
|
|
24
|
+
function isLaunchProof(value: string): boolean {
|
|
25
|
+
return /^[A-Za-z0-9_-]{43}$/.test(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Consume the internal proof before normal CLI argument parsing. */
|
|
29
|
+
export function initializeNodeLauncherContext(
|
|
30
|
+
argv: string[] = process.argv,
|
|
31
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
32
|
+
): TrustedNodeLaunchContext | null {
|
|
33
|
+
const proofArgs: string[] = [];
|
|
34
|
+
for (let index = argv.length - 1; index >= 2; index -= 1) {
|
|
35
|
+
const value = argv[index];
|
|
36
|
+
if (!value?.startsWith(NODE_LAUNCH_PROOF_PREFIX)) continue;
|
|
37
|
+
proofArgs.push(value.slice(NODE_LAUNCH_PROOF_PREFIX.length));
|
|
38
|
+
argv.splice(index, 1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const raw = env[NODE_LAUNCH_CONTEXT_ENV];
|
|
42
|
+
delete env[NODE_LAUNCH_CONTEXT_ENV];
|
|
43
|
+
// Older launchers used this unauthenticated marker. Never let a project
|
|
44
|
+
// dotenv resurrect it as a trusted provenance channel.
|
|
45
|
+
delete env.OCX_PRE_BUN_ANTHROPIC_ENV;
|
|
46
|
+
trustedContext = null;
|
|
47
|
+
|
|
48
|
+
if (proofArgs.length !== 1 || !raw || raw.length > 2048) return null;
|
|
49
|
+
const proof = proofArgs[0]!;
|
|
50
|
+
if (!isLaunchProof(proof)) return null;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(raw) as {
|
|
54
|
+
version?: unknown;
|
|
55
|
+
proof?: unknown;
|
|
56
|
+
anthropicEnvSlots?: unknown;
|
|
57
|
+
};
|
|
58
|
+
if (parsed.version !== 1 || parsed.proof !== proof || !Array.isArray(parsed.anthropicEnvSlots)) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const allowed = new Set<string>(ANTHROPIC_PARENT_ENV_SLOTS);
|
|
62
|
+
const slots = parsed.anthropicEnvSlots.filter(
|
|
63
|
+
(slot): slot is AnthropicParentEnvSlot => typeof slot === "string" && allowed.has(slot),
|
|
64
|
+
);
|
|
65
|
+
if (slots.length !== parsed.anthropicEnvSlots.length || new Set(slots).size !== slots.length) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
trustedContext = { anthropicEnvSlots: slots };
|
|
69
|
+
return trustedContext;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function trustedNodeLauncherContext(): TrustedNodeLaunchContext | null {
|
|
76
|
+
return trustedContext;
|
|
77
|
+
}
|
package/src/codex/admission.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* READS ONLY. Nothing here creates a directory, a database, or a marker: an
|
|
17
17
|
* admission that manufactures the state it is admitting cannot refuse.
|
|
18
18
|
*
|
|
19
|
-
* Design record: devlog/
|
|
19
|
+
* Design record: devlog/_fin/260804_codex_write_substrate/040_ownership_convergence.md.
|
|
20
20
|
*/
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
22
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -407,7 +407,9 @@ export function listCodexAppServerProcesses(io: CodexAppServerProcessIo = {}): C
|
|
|
407
407
|
return matched;
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
-
export function formatStaleCodexAppServerWarning(
|
|
410
|
+
export function formatStaleCodexAppServerWarning(
|
|
411
|
+
processes: readonly { pid: number }[],
|
|
412
|
+
): string {
|
|
411
413
|
const pids = processes.map(process => process.pid).join(", ");
|
|
412
414
|
return (
|
|
413
415
|
`WARNING: ${processes.length} Codex app-server process(es) still running (PID${processes.length === 1 ? "" : "s"}: ${pids}). `
|
|
@@ -754,3 +756,44 @@ export function afterCatalogWriteHandleAppServers(
|
|
|
754
756
|
}
|
|
755
757
|
return { processes, warned: false, restart, hint };
|
|
756
758
|
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Startup-safe counterpart to {@link afterCatalogWriteHandleAppServers} (#1046).
|
|
762
|
+
*
|
|
763
|
+
* Service startup rewrites the catalog and the models cache, but an app-server
|
|
764
|
+
* that booted earlier keeps an in-memory model list — Codex builds a static
|
|
765
|
+
* manager from the catalog once and never rereads the file — so the picker shows
|
|
766
|
+
* a roster that no longer exists on disk. Every check a user runs reads the file;
|
|
767
|
+
* the picker renders memory.
|
|
768
|
+
*
|
|
769
|
+
* Two things this deliberately does NOT do, both of which the `--restart-codex`
|
|
770
|
+
* path does:
|
|
771
|
+
*
|
|
772
|
+
* - It never signals anything. Killing an app-server on an unattended boot would
|
|
773
|
+
* interrupt whatever turn the user has in flight. A human typing
|
|
774
|
+
* `ocx sync --restart-codex` is consenting to that; a login is not.
|
|
775
|
+
* - It never warns about a merely-running app-server. It asks the mtime
|
|
776
|
+
* classifier whether one is actually stale, so a boot with Codex open and a
|
|
777
|
+
* current catalog stays quiet.
|
|
778
|
+
*
|
|
779
|
+
* Failure is swallowed: startup synchronization is best-effort and must not stop
|
|
780
|
+
* the proxy from coming up.
|
|
781
|
+
*
|
|
782
|
+
* The memoized state is dropped first. {@link collectCodexAppServerCatalogState}
|
|
783
|
+
* caches for 5s when every io field is defaulted, so a `fresh` reading taken
|
|
784
|
+
* before the write would otherwise be replayed after it and this would stay
|
|
785
|
+
* silent about the very staleness it exists to report.
|
|
786
|
+
*/
|
|
787
|
+
export function warnIfStaleCodexAppServersAfterStartupWrite(
|
|
788
|
+
options: { log?: Pick<Console, "error">; io?: CodexAppServerProcessIo } = {},
|
|
789
|
+
): { warned: boolean } {
|
|
790
|
+
try {
|
|
791
|
+
resetCodexAppServerCatalogStateCache();
|
|
792
|
+
const status = collectCodexAppServerCatalogState(options.io ?? {});
|
|
793
|
+
if (status.state !== "stale") return { warned: false };
|
|
794
|
+
options.log?.error(formatStaleCodexAppServerWarning(status.processes));
|
|
795
|
+
return { warned: true };
|
|
796
|
+
} catch {
|
|
797
|
+
return { warned: false };
|
|
798
|
+
}
|
|
799
|
+
}
|
|
@@ -11,7 +11,6 @@ import { modelInList } from "../../types";
|
|
|
11
11
|
import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
|
|
12
12
|
import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "../../generated/jawcode-model-metadata";
|
|
13
13
|
import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
|
|
14
|
-
import { getProviderRegistryEntry } from "../../providers/registry";
|
|
15
14
|
import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
|
|
16
15
|
import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
|
|
17
16
|
import { identifyRoutedModel } from "../../adapters/identity";
|
|
@@ -200,6 +199,26 @@ export function isExactComboCatalogModel(
|
|
|
200
199
|
return model !== undefined && exactComboSlugs.has(catalogModelSlug(model));
|
|
201
200
|
}
|
|
202
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Friendly Codex-picker label for a routed `provider/model` slug. Command Code's two config
|
|
204
|
+
* ids differ by a single dash (`command-code` vs `commandcode`), so relabel them to the
|
|
205
|
+
* lowercase-dash style the opencode presets use: `commandcode-auth/x` and `commandcode-api/x`.
|
|
206
|
+
* The model-id portion also carries a redundant `<vendor>-` prefix (`deepseek-deepseek-v4-flash`)
|
|
207
|
+
* that is dropped for display. All other providers keep the raw slug exactly as before.
|
|
208
|
+
*/
|
|
209
|
+
function routedDisplayName(slug: string): string {
|
|
210
|
+
const slash = slug.indexOf("/");
|
|
211
|
+
if (slash <= 0) return slug;
|
|
212
|
+
const provider = slug.slice(0, slash);
|
|
213
|
+
let model = slug.slice(slash + 1);
|
|
214
|
+
if (provider === "command-code" || provider === "commandcode") {
|
|
215
|
+
const m = model.match(/^([a-z0-9]+)-([a-z0-9]+(?:-[a-z0-9]+)+)$/i);
|
|
216
|
+
if (m && model.startsWith(`${m[1]}-${m[1]}-`)) model = model.slice(m[1]!.length + 1);
|
|
217
|
+
return `${provider === "command-code" ? "commandcode-auth" : "commandcode-api"}/${model}`;
|
|
218
|
+
}
|
|
219
|
+
return slug;
|
|
220
|
+
}
|
|
221
|
+
|
|
203
222
|
export function deriveEntry(
|
|
204
223
|
template: RawEntry | null,
|
|
205
224
|
slug: string,
|
|
@@ -220,7 +239,7 @@ export function deriveEntry(
|
|
|
220
239
|
if (template) {
|
|
221
240
|
const e = JSON.parse(JSON.stringify(template)) as RawEntry;
|
|
222
241
|
e.slug = slug;
|
|
223
|
-
e.display_name = slug;
|
|
242
|
+
e.display_name = routedDisplayName(slug);
|
|
224
243
|
e.description = desc;
|
|
225
244
|
e.priority = priority;
|
|
226
245
|
e.visibility = "list";
|
|
@@ -271,7 +290,7 @@ export function deriveEntry(
|
|
|
271
290
|
}
|
|
272
291
|
// Fallback when no template is available (best-effort; strict parser may need more).
|
|
273
292
|
const entry: RawEntry = {
|
|
274
|
-
slug, display_name: slug, description: desc,
|
|
293
|
+
slug, display_name: routedDisplayName(slug), description: desc,
|
|
275
294
|
shell_type: "shell_command", visibility: "list", supported_in_api: true,
|
|
276
295
|
priority, base_instructions: "You are a helpful coding assistant.",
|
|
277
296
|
...(isRouted ? { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}),
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* advances the native pair. Order is `N -> K -> C`; there is no `C -> K` and no
|
|
23
23
|
* `K -> N` (`005_contract.md:660-762`).
|
|
24
24
|
*
|
|
25
|
-
* Design record: devlog/
|
|
25
|
+
* Design record: devlog/_fin/260804_codex_write_substrate/005_contract.md §3.
|
|
26
26
|
*/
|
|
27
27
|
import { chmodSync, lstatSync, realpathSync } from "node:fs";
|
|
28
28
|
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* Lock order is `N -> C`. C is `withConfigMutationLockSync`, entered while N is
|
|
27
27
|
* held and released before N commits. There is no `C -> N`.
|
|
28
28
|
*
|
|
29
|
-
* Design record: devlog/
|
|
29
|
+
* Design record: devlog/_fin/260804_codex_write_substrate/030_lock_protocol.md.
|
|
30
30
|
*/
|
|
31
31
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
32
32
|
import { createHash } from "node:crypto";
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* of the record schema, the /api/sync contract, and the convergence entry
|
|
7
7
|
* point; the contract is centralized here so a consumer can only import.
|
|
8
8
|
*
|
|
9
|
-
* Design record: devlog/
|
|
9
|
+
* Design record: devlog/_fin/260804_codex_write_substrate/005_contract.md
|
|
10
10
|
* Audit trail: 006, 007, 008, 009, 010 audit syntheses in the same unit.
|
|
11
11
|
*
|
|
12
12
|
* TYPES ONLY. WP8b deliberately rewires nothing: WP9 supplies the first
|
|
@@ -27,7 +27,17 @@ import type { OcxClientIntegrationsConfig, OcxConfig } from "../types";
|
|
|
27
27
|
export type DurableIntentClientId = keyof OcxClientIntegrationsConfig;
|
|
28
28
|
|
|
29
29
|
/** Injectable for tests; production passes the real sync. */
|
|
30
|
-
|
|
30
|
+
/**
|
|
31
|
+
* The startup sync result the caller needs to decide whether anything was
|
|
32
|
+
* actually written (#1046). It used to be `unknown`, so "a write happened" was
|
|
33
|
+
* not observable at the startup boundary and no post-write action could be
|
|
34
|
+
* gated on it.
|
|
35
|
+
*/
|
|
36
|
+
export interface CodexStartupSyncOutcome {
|
|
37
|
+
catalogWritten: boolean;
|
|
38
|
+
cacheSynced: boolean;
|
|
39
|
+
}
|
|
40
|
+
export type CodexStartupSync = (port: number) => Promise<CodexStartupSyncOutcome | undefined>;
|
|
31
41
|
|
|
32
42
|
export type CodexDesiredStateResult =
|
|
33
43
|
| { readonly ok: true; readonly status: "committed" | "unchanged"; readonly enabled: boolean }
|
|
@@ -143,19 +153,29 @@ export function setGrokIntegrationEnabled(enabled: boolean): CodexDesiredStateRe
|
|
|
143
153
|
* Swallowing the user's decision was not.
|
|
144
154
|
*
|
|
145
155
|
* Returns whether the sync ran, so a caller — or a test — can tell "skipped
|
|
146
|
-
* because the user turned it off" from "ran and quietly failed"
|
|
156
|
+
* because the user turned it off" from "ran and quietly failed", plus what it
|
|
157
|
+
* wrote when it did run (#1046 — the caller warns about stale app-servers only
|
|
158
|
+
* after a real write).
|
|
147
159
|
*/
|
|
148
160
|
export async function syncCodexOnStartIfEnabled(
|
|
149
161
|
port: number,
|
|
150
162
|
config: Pick<OcxConfig, "clientIntegrations">,
|
|
151
163
|
sync: CodexStartupSync = defaultStartupSync,
|
|
152
|
-
): Promise<boolean> {
|
|
153
|
-
if (!codexIntegrationEnabled(config))
|
|
154
|
-
|
|
155
|
-
|
|
164
|
+
): Promise<{ ran: boolean; catalogWritten: boolean; cacheSynced: boolean }> {
|
|
165
|
+
if (!codexIntegrationEnabled(config)) {
|
|
166
|
+
return { ran: false, catalogWritten: false, cacheSynced: false };
|
|
167
|
+
}
|
|
168
|
+
// The `.catch` is deliberate and stays: a failure to APPLY must not stop the
|
|
169
|
+
// proxy from coming up. A failed sync simply reports no writes.
|
|
170
|
+
const outcome = await sync(port).catch(() => undefined);
|
|
171
|
+
return {
|
|
172
|
+
ran: true,
|
|
173
|
+
catalogWritten: outcome?.catalogWritten === true,
|
|
174
|
+
cacheSynced: outcome?.cacheSynced === true,
|
|
175
|
+
};
|
|
156
176
|
}
|
|
157
177
|
|
|
158
|
-
async function defaultStartupSync(port: number): Promise<
|
|
178
|
+
async function defaultStartupSync(port: number): Promise<CodexStartupSyncOutcome> {
|
|
159
179
|
const { syncModelsToCodex } = await import("./sync");
|
|
160
180
|
return syncModelsToCodex(port);
|
|
161
181
|
}
|
package/src/codex/history-job.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* exception crossing back into a route that already persisted its mutation — is
|
|
15
15
|
* how a successful change gets reported as a 500.
|
|
16
16
|
*
|
|
17
|
-
* Design record: devlog/
|
|
17
|
+
* Design record: devlog/_fin/260804_codex_write_substrate/020_history_isolation.md.
|
|
18
18
|
*/
|
|
19
19
|
import { randomUUID } from "node:crypto";
|
|
20
20
|
import { join } from "node:path";
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* So every history mutator asks this module at runtime whether the permit it was
|
|
28
28
|
* handed is still live for the state database it is about to write.
|
|
29
29
|
*
|
|
30
|
-
* Design record: devlog/
|
|
30
|
+
* Design record: devlog/_fin/260804_codex_write_substrate/005_contract.md §6.
|
|
31
31
|
*/
|
|
32
32
|
import { chmodSync, lstatSync, realpathSync } from "node:fs";
|
|
33
33
|
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* at import time, so a request that leaned on those constants would silently
|
|
22
22
|
* address the wrong home.
|
|
23
23
|
*
|
|
24
|
-
* Design record: devlog/
|
|
24
|
+
* Design record: devlog/_fin/260804_codex_write_substrate/020_history_isolation.md.
|
|
25
25
|
*/
|
|
26
26
|
import { withHistoryWriteSerialization } from "./history-lock";
|
|
27
27
|
import {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* root. Readers and probes stay in `history-provider.ts`; nothing in the CLI, the
|
|
22
22
|
* server, the guardian, `inject.ts` or `sync.ts` may reach these symbols.
|
|
23
23
|
*
|
|
24
|
-
* Design record: devlog/
|
|
24
|
+
* Design record: devlog/_fin/260804_codex_write_substrate/020_history_isolation.md.
|
|
25
25
|
*/
|
|
26
26
|
import { assertHistoryWritePermit, type HistoryWritePermit } from "../history-lock";
|
|
27
27
|
import {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* UPDATE, and the opaque one-shot capability backed by an already-open
|
|
8
8
|
* `BEGIN IMMEDIATE` transaction.
|
|
9
9
|
*
|
|
10
|
-
* Design record: devlog/
|
|
10
|
+
* Design record: devlog/_fin/260804_codex_write_substrate/005_contract.md §1.
|
|
11
11
|
*/
|
|
12
12
|
import { randomUUID } from "node:crypto";
|
|
13
13
|
import { chmodSync, lstatSync, realpathSync } from "node:fs";
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* could therefore coordinate through different databases. The namespace is
|
|
7
7
|
* keyed only by the effective uid/SID and the canonical CODEX_HOME.
|
|
8
8
|
*
|
|
9
|
-
* Design record: devlog/
|
|
9
|
+
* Design record: devlog/_fin/260804_codex_write_substrate/005_contract.md §7.
|
|
10
10
|
*/
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
12
|
import {
|
package/src/config.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
} from "./lib/windows-secret-acl";
|
|
39
39
|
import { recordOwnedConfigPath } from "./lib/config-ownership";
|
|
40
40
|
import { assertNotRealHomeUnderTest } from "./lib/test-home-guard";
|
|
41
|
+
import { isLocalAttestationSecret } from "./lib/local-management-attestation";
|
|
41
42
|
import { providerDestinationConfigError } from "./lib/destination-policy";
|
|
42
43
|
import { redactSecretString } from "./lib/redact";
|
|
43
44
|
import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
|
|
@@ -2608,18 +2609,22 @@ export type RuntimePortState = {
|
|
|
2608
2609
|
pid: number;
|
|
2609
2610
|
port: number;
|
|
2610
2611
|
hostname?: string;
|
|
2612
|
+
/** Per-process proof key; protected by the config directory and never served. */
|
|
2613
|
+
attestationSecret?: string;
|
|
2611
2614
|
};
|
|
2612
2615
|
|
|
2613
2616
|
function isValidRuntimePortState(value: unknown): value is RuntimePortState {
|
|
2614
2617
|
if (!value || typeof value !== "object") return false;
|
|
2615
2618
|
const state = value as Record<string, unknown>;
|
|
2616
2619
|
const hostnameOk = state.hostname === undefined || typeof state.hostname === "string";
|
|
2620
|
+
const attestationOk = state.attestationSecret === undefined || isLocalAttestationSecret(state.attestationSecret);
|
|
2617
2621
|
return Number.isSafeInteger(state.pid)
|
|
2618
2622
|
&& Number(state.pid) > 0
|
|
2619
2623
|
&& Number.isInteger(state.port)
|
|
2620
2624
|
&& Number(state.port) > 0
|
|
2621
2625
|
&& Number(state.port) <= 65535
|
|
2622
|
-
&& hostnameOk
|
|
2626
|
+
&& hostnameOk
|
|
2627
|
+
&& attestationOk;
|
|
2623
2628
|
}
|
|
2624
2629
|
|
|
2625
2630
|
export function writeRuntimePort(state: RuntimePortState): void {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* needs it too, and a reader that disagreed with the writer about what counts
|
|
6
6
|
* as an absent file is exactly how an unreadable config gets overwritten.
|
|
7
7
|
*
|
|
8
|
-
* Design of record: devlog/
|
|
8
|
+
* Design of record: devlog/_fin/260802_client_toggle_api/021 §5-6.
|
|
9
9
|
*/
|
|
10
10
|
import { mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
11
11
|
import type { ConfigFormat } from "../clients/config-export";
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* verbatim — so they go through `atomicWriteFile`, which applies 0600 plus
|
|
11
11
|
* Windows ACL hardening.
|
|
12
12
|
*
|
|
13
|
-
* Design of record: devlog/
|
|
13
|
+
* Design of record: devlog/_fin/260802_client_toggle_api/021 §4.
|
|
14
14
|
*/
|
|
15
15
|
import { randomUUID } from "node:crypto";
|
|
16
16
|
import { appendFileSync, existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* remove, and inferring ownership from a name is how a config editor destroys
|
|
8
8
|
* work it did not create.
|
|
9
9
|
*
|
|
10
|
-
* Design of record: devlog/
|
|
10
|
+
* Design of record: devlog/_fin/260802_client_toggle_api/031_wp3_writer_impl.md.
|
|
11
11
|
*/
|
|
12
12
|
import type { ManagedContribution } from "../clients/config-export";
|
|
13
13
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* conflating them is what lets a foreign edit read as ordinary drift — which
|
|
8
8
|
* would then be silently overwritten.
|
|
9
9
|
*
|
|
10
|
-
* Design of record: devlog/
|
|
10
|
+
* Design of record: devlog/_fin/260802_client_toggle_api/021 §2.
|
|
11
11
|
*/
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
13
13
|
import { mkdirSync, readFileSync } from "node:fs";
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* client's config. This one says where it lives, how to tell whether the client
|
|
7
7
|
* is installed at all, and whether a remote bind is safe for it.
|
|
8
8
|
*
|
|
9
|
-
* Design of record: devlog/
|
|
9
|
+
* Design of record: devlog/_fin/260802_client_toggle_api/021 §1.
|
|
10
10
|
*/
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* might misread — and the writer turns the failure into a structured refusal
|
|
22
22
|
* rather than an exception.
|
|
23
23
|
*
|
|
24
|
-
* Design of record: devlog/
|
|
24
|
+
* Design of record: devlog/_fin/260802_client_toggle_api/011_wp1_builders.md.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
export type ConfigFormat = "json" | "yaml" | "toml" | "json5";
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* be reported as ordinary drift. Getting that wrong would let `disable` delete
|
|
7
7
|
* a user's own edits.
|
|
8
8
|
*
|
|
9
|
-
* Design of record: devlog/
|
|
9
|
+
* Design of record: devlog/_fin/260802_client_toggle_api/021 §3.
|
|
10
10
|
*/
|
|
11
11
|
import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, type ExportModel, type ManagedContribution } from "../clients/config-export";
|
|
12
12
|
import type { OcxConfig } from "../types";
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* bookkeeping seams to that same store so the two can never point at different
|
|
9
9
|
* roots.
|
|
10
10
|
*
|
|
11
|
-
* Design of record: devlog/
|
|
11
|
+
* Design of record: devlog/_fin/260802_client_toggle_api/006 §Config-dir seam.
|
|
12
12
|
*/
|
|
13
13
|
import {
|
|
14
14
|
appendOperation,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* file is not in a state we can reason about. A disable that deletes work the
|
|
8
8
|
* user did after us would be worse than never shipping the feature.
|
|
9
9
|
*
|
|
10
|
-
* Design of record: devlog/
|
|
10
|
+
* Design of record: devlog/_fin/260802_client_toggle_api/030 and 031.
|
|
11
11
|
*/
|
|
12
12
|
import { dirname } from "node:path";
|
|
13
13
|
import { EXPORT_CLIENTS, type ExportModel } from "../clients/config-export";
|
package/src/lib/bounded-body.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface BoundedBodyOptions {
|
|
|
23
23
|
totalTimeoutMs?: number;
|
|
24
24
|
/** Deadline between non-empty raw chunks. Exposed for focused tests. */
|
|
25
25
|
inactivityTimeoutMs?: number;
|
|
26
|
+
/** Deadline for the first non-empty raw chunk. Defaults to inactivityTimeoutMs. */
|
|
27
|
+
firstByteTimeoutMs?: number;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export interface BoundedBodyResult {
|
|
@@ -129,7 +131,7 @@ export async function readBoundedResponseBody(
|
|
|
129
131
|
let cancelReason: unknown;
|
|
130
132
|
const total = timeoutPromise(options.totalTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS, TOTAL_TIMEOUT);
|
|
131
133
|
let inactivity = timeoutPromise(
|
|
132
|
-
options.inactivityTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS,
|
|
134
|
+
options.firstByteTimeoutMs ?? options.inactivityTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS,
|
|
133
135
|
INACTIVITY_TIMEOUT,
|
|
134
136
|
);
|
|
135
137
|
|
package/src/lib/bun-runtime.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { createRequire } from "node:module";
|
|
14
14
|
import { realpathSync } from "node:fs";
|
|
15
|
-
import { dirname, join
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
16
|
import { isRealBunBinary } from "./bun-binary-validator.mjs";
|
|
17
17
|
|
|
18
18
|
export { isRealBunBinary };
|
|
@@ -108,19 +108,23 @@ export function withProcessRuntimeProvenance(
|
|
|
108
108
|
* exact executable, otherwise what this executable actually is.
|
|
109
109
|
*/
|
|
110
110
|
function currentRuntimeProvenance(env: NodeJS.ProcessEnv): DurableBunRuntime {
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
if (claimed && claimedPath && samePath(claimedPath, process.execPath)) {
|
|
114
|
-
return { path: process.execPath, source: claimed, overrideEnv: BUN_OVERRIDE_ENV };
|
|
115
|
-
}
|
|
111
|
+
const recorded = recordedCurrentRuntime(env);
|
|
112
|
+
if (recorded) return recorded;
|
|
116
113
|
// No marker that describes this binary: report what is running. One resolution
|
|
117
114
|
// supplies both halves so the pair can never disagree.
|
|
118
|
-
const runtime =
|
|
115
|
+
const runtime = unmarkedDurableBunRuntime();
|
|
119
116
|
return samePath(runtime.path, process.execPath)
|
|
120
117
|
? runtime
|
|
121
118
|
: { path: process.execPath, source: "process", overrideEnv: BUN_OVERRIDE_ENV };
|
|
122
119
|
}
|
|
123
120
|
|
|
121
|
+
function recordedCurrentRuntime(env: NodeJS.ProcessEnv): DurableBunRuntime | null {
|
|
122
|
+
const source = reportedBunRuntimeSource(env);
|
|
123
|
+
const path = env[BUN_RUNTIME_PATH_ENV]?.trim();
|
|
124
|
+
if (!source || !path || !samePath(path, process.execPath)) return null;
|
|
125
|
+
return { path, source, overrideEnv: BUN_OVERRIDE_ENV };
|
|
126
|
+
}
|
|
127
|
+
|
|
124
128
|
/**
|
|
125
129
|
* Same file, allowing for the aliases a path can pick up between launch and relaunch:
|
|
126
130
|
* symlinks/junctions, mapped drives, and Windows case differences. Falls back to a
|
|
@@ -154,21 +158,21 @@ export function bundledBunPath(): string | null {
|
|
|
154
158
|
}
|
|
155
159
|
}
|
|
156
160
|
|
|
157
|
-
|
|
158
|
-
const value = process.env[BUN_OVERRIDE_ENV]?.trim();
|
|
159
|
-
if (!value) return null;
|
|
160
|
-
const resolved = resolve(value);
|
|
161
|
-
return isRealBunBinary(resolved) ? resolved : null;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export function durableBunRuntime(): DurableBunRuntime {
|
|
165
|
-
const override = overrideBunPath();
|
|
166
|
-
if (override) return { path: override, source: "override", overrideEnv: BUN_OVERRIDE_ENV };
|
|
161
|
+
function unmarkedDurableBunRuntime(): DurableBunRuntime {
|
|
167
162
|
const bundled = bundledBunPath();
|
|
168
163
|
if (bundled) return { path: bundled, source: "bundled", overrideEnv: BUN_OVERRIDE_ENV };
|
|
169
164
|
return { path: process.execPath, source: "process", overrideEnv: BUN_OVERRIDE_ENV };
|
|
170
165
|
}
|
|
171
166
|
|
|
167
|
+
export function durableBunRuntime(): DurableBunRuntime {
|
|
168
|
+
// A durable artifact must use the runtime selected BEFORE Bun auto-loaded a
|
|
169
|
+
// project dotenv. The Node launcher and owned service/shim launchers stamp the
|
|
170
|
+
// selected source/path pair; it is accepted only when it names this exact
|
|
171
|
+
// running executable. Re-reading OPENCODEX_BUN_PATH here would let a project
|
|
172
|
+
// `.env` persist an arbitrary executable into a shim or service.
|
|
173
|
+
return recordedCurrentRuntime(process.env) ?? unmarkedDurableBunRuntime();
|
|
174
|
+
}
|
|
175
|
+
|
|
172
176
|
/**
|
|
173
177
|
* Bun path to bake into durable artifacts (launchd/systemd/Task Scheduler and
|
|
174
178
|
* the Codex auto-start shim). Prefer the bundled binary — it lives under the
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* safety pin. Darwin no-rewrite traffic stays on tee
|
|
11
11
|
* for `auto` regardless of runtime capability and reaches eager relay only via
|
|
12
12
|
* explicit `streamMode: "eager-relay"` opt-in (see
|
|
13
|
-
* devlog/
|
|
13
|
+
* devlog/_fin/260731_macos_rss_retention/100_darwin_eager_optin.md).
|
|
14
14
|
*
|
|
15
15
|
* Prerelease conservatism: a version carrying a prerelease suffix (e.g.
|
|
16
16
|
* `1.4.0-canary.3`) is NEVER treated as fixed even when its numeric triple
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const LOCAL_ATTESTATION_CHALLENGE_HEADER = "x-opencodex-attestation-challenge";
|
|
4
|
+
export const LOCAL_ATTESTATION_PROOF_HEADER = "x-opencodex-attestation-proof";
|
|
5
|
+
|
|
6
|
+
const BASE64URL_256 = /^[A-Za-z0-9_-]{43}$/;
|
|
7
|
+
|
|
8
|
+
export function isLocalAttestationSecret(value: unknown): value is string {
|
|
9
|
+
return typeof value === "string" && BASE64URL_256.test(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createLocalAttestationSecret(): string {
|
|
13
|
+
return randomBytes(32).toString("base64url");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createLocalAttestationChallenge(): string {
|
|
17
|
+
return randomBytes(32).toString("base64url");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function attestationPayload(challenge: string, pid: number, port: number): string | null {
|
|
21
|
+
if (!BASE64URL_256.test(challenge)) return null;
|
|
22
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
23
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
|
|
24
|
+
return `opencodex-local-management-v1\n${challenge}\n${pid}\n${port}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createLocalAttestationProof(
|
|
28
|
+
secret: string,
|
|
29
|
+
challenge: string,
|
|
30
|
+
pid: number,
|
|
31
|
+
port: number,
|
|
32
|
+
): string | null {
|
|
33
|
+
if (!isLocalAttestationSecret(secret)) return null;
|
|
34
|
+
const payload = attestationPayload(challenge, pid, port);
|
|
35
|
+
if (!payload) return null;
|
|
36
|
+
return createHmac("sha256", secret).update(payload).digest("base64url");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function verifyLocalAttestationProof(
|
|
40
|
+
secret: string,
|
|
41
|
+
challenge: string,
|
|
42
|
+
pid: number,
|
|
43
|
+
port: number,
|
|
44
|
+
proof: string | null,
|
|
45
|
+
): boolean {
|
|
46
|
+
const expected = createLocalAttestationProof(secret, challenge, pid, port);
|
|
47
|
+
if (!expected || !proof || !BASE64URL_256.test(proof)) return false;
|
|
48
|
+
const expectedBytes = Buffer.from(expected);
|
|
49
|
+
const actualBytes = Buffer.from(proof);
|
|
50
|
+
return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes);
|
|
51
|
+
}
|
package/src/lib/shadow-call.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shadow-call intercept source models.
|
|
3
3
|
*
|
|
4
|
-
* Codex
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Codex 0.145.0+ uses `gpt-5.6-luna` for helper calls. Older clients through
|
|
5
|
+
* 0.144.x used `gpt-5.4-mini`; operators supporting them can restore that
|
|
6
|
+
* prefix with the `sourceModels` override. Every surface that names the
|
|
7
7
|
* intercepted model (management API, GUI badges/tooltips, CLI) reads it from
|
|
8
8
|
* here instead of hard-coding a slug that goes stale on the next client bump.
|
|
9
9
|
*/
|
|
10
|
-
export const DEFAULT_SHADOW_SOURCE_MODELS = ["gpt-5.
|
|
10
|
+
export const DEFAULT_SHADOW_SOURCE_MODELS = ["gpt-5.6-luna"] as const;
|
|
11
11
|
|
|
12
12
|
/** Normalize a persisted `sourceModels` override; falls back to the defaults. */
|
|
13
13
|
export function shadowSourceModels(configured?: unknown): string[] {
|