super-dsh 0.1.10-e → 0.1.11-a
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/README.md +38 -2
- package/agent-agy/dist/agent.js +19 -1
- package/agent-claude/dist/agent.js +23 -5
- package/agent-codex/dist/adapter.js +3 -2
- package/agent-codex/dist/agent.js +41 -7
- package/agent-codex/dist/app-server-client.js +9 -2
- package/agent-codex/dist/app-server-protocol.js +6 -2
- package/agent-codex/dist/app-server-wire.js +29 -24
- package/agent-codex/dist/codex-launch.js +10 -32
- package/agent-codex/dist/codex-native-config.js +9 -3
- package/agent-codex/dist/codex-oauth.js +153 -0
- package/agent-codex/dist/codex-provider-gateway.js +36 -38
- package/agent-codex/dist/codex-providers.js +20 -7
- package/agent-codex/dist/codex-runtime-fetch.js +293 -0
- package/agent-codex/dist/codex-runtime.js +120 -0
- package/agent-codex/dist/index.js +20 -12
- package/agent-codex/package.json +8 -3
- package/agent-codex-sdk/dist/adapter.js +3 -2
- package/agent-codex-sdk/dist/agent.js +35 -1
- package/agent-codex-sdk/dist/codex-native-config.js +9 -3
- package/agent-codex-sdk/dist/codex-oauth.js +153 -0
- package/agent-codex-sdk/dist/codex-provider-gateway.js +36 -38
- package/agent-codex-sdk/dist/codex-providers.js +20 -7
- package/agent-codex-sdk/dist/codex-sdk-launch.js +5 -38
- package/agent-codex-sdk/dist/index.js +18 -11
- package/agent-codex-sdk/dist/sdk-runtime.js +3 -3
- package/agent-hermes/dist/agent.js +46 -2
- package/agent-hermes/dist/hermes-client.js +49 -1
- package/agent-omp/dist/agent.js +17 -4
- package/agent-omp-sdk/dist/agent.d.ts +11 -0
- package/agent-omp-sdk/dist/agent.js +17 -4
- package/agent-pi/dist/agent.js +33 -1
- package/client/index.js +10 -7
- package/package.json +7 -5
- package/superd-hub/dist/client/agent-settings.d.ts +1 -1
- package/superd-hub/dist/client/agent-settings.js +2 -2
- package/superd-hub/dist/connection-trust.d.ts +8 -0
- package/superd-hub/dist/connection-trust.js +16 -0
- package/superd-hub/dist/web-trust.d.ts +14 -42
- package/superd-hub/dist/web-trust.js +18 -43
- package/superd-hub/dist/world-mount.d.ts +6 -5
- package/superd-hub/dist/world-mount.js +6 -6
- package/superd-hub/lib/client/index.js +10 -7
- package/superd-hub/package.json +5 -1
- package/agent-codex/README.md +0 -179
- package/agent-codex-sdk/README.md +0 -79
- package/agent-hermes/README.md +0 -145
- package/agent-omp/README.md +0 -94
- package/agent-omp-sdk/README.md +0 -103
- package/agent-pi/README.md +0 -67
|
@@ -1,34 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* codex-provider-gateway.ts — the adapter-owned Remote behind the Hub's Codex
|
|
3
|
-
* settings panel (2026-10-03 user ruling; reworked 2026-10-04).
|
|
4
|
-
*
|
|
5
|
-
* Why a Remote and not a hub route: the provider list is ADAPTER config
|
|
6
|
-
* (dev-rules §12), so the adapter owns it and the hub only forwards.
|
|
7
|
-
* `TypertRemoteService` + `@Remote` is the whole mechanism — standard (TC39)
|
|
8
|
-
* method decorators recorded on the class prototype, read back at dispatch time
|
|
9
|
-
* by the gateway's source-claim scan. No codegen, no compiler-injected metadata,
|
|
10
|
-
* which is why a plain `tsc` adapter build can ship one.
|
|
11
|
-
*
|
|
12
|
-
* Wire contract (`superd-hub/src/client/runtime-settings-api.ts` builds each
|
|
13
|
-
* envelope; the hub carrier forwards `envelope.method` verbatim):
|
|
14
|
-
*
|
|
15
|
-
* request POST /hub/api?agent=codex
|
|
16
|
-
* { type:'client-request', rpcId, method:'codexProviders/<op>',
|
|
17
|
-
* payload: { args: { request?: … } } }
|
|
18
|
-
* response { type:'server-response', rpcId, result:{ ok:true, value:<view> } }
|
|
19
|
-
*
|
|
20
|
-
* There is no `select`: which provider a SESSION runs on is DSH's own
|
|
21
|
-
* per-session `model/selection`, chosen from the harness's model selector. This
|
|
22
|
-
* panel is a list editor — add, edit, remove — plus the reconcile with the
|
|
23
|
-
* user's `config.toml` that {@link syncFromNative} performs.
|
|
24
|
-
*
|
|
25
|
-
* The class is a delegation shell on purpose: every operation is a plain
|
|
26
|
-
* function over (worldHome, codexHome), so the entire contract — the key
|
|
27
|
-
* privacy rule, the validation, the sync, the view projection — is pinnable
|
|
28
|
-
* under `node --test` with no Cordis Context and no real home.
|
|
29
|
-
*
|
|
30
|
-
* @module agent-codex/codex-provider-gateway
|
|
31
|
-
*/
|
|
32
1
|
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
33
2
|
var useValue = arguments.length > 2;
|
|
34
3
|
for (var i = 0; i < initializers.length; i++) {
|
|
@@ -63,6 +32,7 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
|
|
|
63
32
|
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
64
33
|
done = true;
|
|
65
34
|
};
|
|
35
|
+
import { OPENAI_OAUTH_ID, hasOpenAiOAuth, openAiOAuthEntry } from "./codex-oauth.js";
|
|
66
36
|
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
67
37
|
import { isProviderId, isWireProtocol, providerStorePath, readProviderStore, removeProvider, syncNativeProviders, upsertProvider, writeProviderStore, } from "./codex-providers.js";
|
|
68
38
|
import { providerCatalogDir, readNativeProviders, readNativeSelection, } from "./codex-native-config.js";
|
|
@@ -134,6 +104,8 @@ function keyAvailable(codexHome, entry) {
|
|
|
134
104
|
return nonEmpty(process.env[entry.auth.name]) !== undefined;
|
|
135
105
|
}
|
|
136
106
|
function project(codexHome, entry) {
|
|
107
|
+
if (entry.kind === "openai-oauth")
|
|
108
|
+
return { id: entry.id, label: entry.label, protocol: "responses", models: openAiOAuthEntry(codexHome).models, authKind: "oauth", hasKey: hasOpenAiOAuth(codexHome) };
|
|
137
109
|
return {
|
|
138
110
|
id: entry.id,
|
|
139
111
|
label: entry.label,
|
|
@@ -172,10 +144,22 @@ export function syncFromNative(worldHome, codexHome) {
|
|
|
172
144
|
const store = readProviderStore(worldHome);
|
|
173
145
|
const imported = readNativeProviders(codexHome).map(toImport);
|
|
174
146
|
const result = syncNativeProviders(store, imported);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
147
|
+
const legacyAlias = result.store.providers.some(entry => entry.id === OPENAI_OAUTH_ID && entry.kind !== "openai-oauth");
|
|
148
|
+
const blockedNativeIds = [...new Set([...(result.store.blockedNativeIds ?? []), ...(legacyAlias ? [OPENAI_OAUTH_ID] : [])])];
|
|
149
|
+
const oauth = hasOpenAiOAuth(codexHome) && !blockedNativeIds.includes(OPENAI_OAUTH_ID);
|
|
150
|
+
const existed = store.providers.some(entry => entry.kind === "openai-oauth");
|
|
151
|
+
// Keep collided identities on disk so ambiguous old routes fail closed. They
|
|
152
|
+
// are quarantined by readEffectiveProviderStore, not silently repurposed.
|
|
153
|
+
const providers = result.store.providers.flatMap(entry => entry.kind === "openai-oauth"
|
|
154
|
+
? legacyAlias || oauth ? [openAiOAuthEntry(codexHome)] : [] : [entry]);
|
|
155
|
+
if (oauth && !existed)
|
|
156
|
+
providers.push(openAiOAuthEntry(codexHome));
|
|
157
|
+
const effective = { ...result.store, providers, ...(blockedNativeIds.length === 0 ? {} : { blockedNativeIds }) };
|
|
158
|
+
const blockedChanged = JSON.stringify(blockedNativeIds) !== JSON.stringify(store.blockedNativeIds ?? []);
|
|
159
|
+
const identityChanged = !legacyAlias && oauth !== existed;
|
|
160
|
+
if (result.added.length > 0 || result.updated.length > 0 || identityChanged || blockedChanged)
|
|
161
|
+
writeProviderStore(worldHome, effective);
|
|
162
|
+
return { ...result, store: effective };
|
|
179
163
|
}
|
|
180
164
|
/** The view for one (already loaded) store. */
|
|
181
165
|
function viewOf(worldHome, codexHome, store, syncedAt, note) {
|
|
@@ -189,13 +173,19 @@ function viewOf(worldHome, codexHome, store, syncedAt, note) {
|
|
|
189
173
|
// Only a SEEDED keyed entry defers to auth.json, so only it can be shadowed
|
|
190
174
|
// by a ChatGPT login. An entry holding its own key bypasses the auth manager
|
|
191
175
|
// entirely through the launch's synthetic `env_key`.
|
|
192
|
-
const deferred = store.providers.some((entry) => entry.auth.kind === "apiKey" && entry.auth.value === "");
|
|
176
|
+
const deferred = store.providers.some((entry) => entry.kind !== "openai-oauth" && entry.auth.kind === "apiKey" && entry.auth.value === "");
|
|
193
177
|
const shadowed = deferred && chatgptAuthMode(codexHome)
|
|
194
178
|
? 'Codex is signed in with a ChatGPT account, which takes precedence over a saved API key — a provider that relies on auth.json will not run until you sign out (`codex logout`) or give this provider its own key.'
|
|
195
179
|
: undefined;
|
|
196
|
-
const
|
|
180
|
+
const nativeAlias = readNativeProviders(codexHome).some(entry => entry.id === OPENAI_OAUTH_ID);
|
|
181
|
+
const blocked = store.blockedNativeIds?.includes(OPENAI_OAUTH_ID) === true;
|
|
182
|
+
const duplicate = store.providers.filter(entry => entry.id === OPENAI_OAUTH_ID).length > 1;
|
|
183
|
+
const collision = blocked || duplicate || (nativeAlias && store.providers.some(entry => entry.kind === "openai-oauth"))
|
|
184
|
+
? 'OpenAI OAuth discovery is blocked by an openai-oauth provider ID conflict. Existing third-party routes and keys are preserved. Rename the conflicting native provider table; historical session routes need explicit repair before this ID can be reused.'
|
|
185
|
+
: undefined;
|
|
186
|
+
const combined = [note, shadowed, collision].filter((part) => part !== undefined).join(" ");
|
|
197
187
|
return {
|
|
198
|
-
providers: store.providers.map((entry) => project(codexHome, entry)),
|
|
188
|
+
providers: store.providers.filter(entry => !(duplicate && entry.id === OPENAI_OAUTH_ID)).map((entry) => project(codexHome, entry)),
|
|
199
189
|
native,
|
|
200
190
|
protocols: PROTOCOL_OPTIONS,
|
|
201
191
|
paths: {
|
|
@@ -243,6 +233,8 @@ export function normalizeSaveRequest(request, existing) {
|
|
|
243
233
|
}
|
|
244
234
|
const raw = request["provider"];
|
|
245
235
|
const id = raw["id"];
|
|
236
|
+
if (id === OPENAI_OAUTH_ID && (existing === undefined || existing.kind === "openai-oauth"))
|
|
237
|
+
throw new TypeError("OpenAI OAuth is a reserved native provider; configure authentication in Codex");
|
|
246
238
|
if (!isProviderId(id)) {
|
|
247
239
|
throw new TypeError(`codexProviders/save: id must match /^[a-z0-9][a-z0-9-]*$/ (got ${JSON.stringify(id)})`);
|
|
248
240
|
}
|
|
@@ -328,6 +320,8 @@ export function normalizeSaveRequest(request, existing) {
|
|
|
328
320
|
export function saveProvider(worldHome, codexHome, request, syncedAt) {
|
|
329
321
|
const store = readProviderStore(worldHome);
|
|
330
322
|
const id = isRecord(request) && isRecord(request["provider"]) ? request["provider"]["id"] : undefined;
|
|
323
|
+
if (store.providers.filter(entry => entry.id === id).length > 1)
|
|
324
|
+
throw new TypeError("codexProviders/save: duplicate provider ID is ambiguous; explicitly repair the conflicting identities before editing");
|
|
331
325
|
const existing = store.providers.find((entry) => entry.id === id);
|
|
332
326
|
const entry = normalizeSaveRequest(request, existing);
|
|
333
327
|
const next = upsertProvider(store, entry);
|
|
@@ -340,6 +334,10 @@ export function removeProviderEntry(worldHome, codexHome, request, syncedAt) {
|
|
|
340
334
|
if (id === undefined)
|
|
341
335
|
throw new TypeError("codexProviders/remove: id must be a non-empty string");
|
|
342
336
|
const store = readProviderStore(worldHome);
|
|
337
|
+
if (id === OPENAI_OAUTH_ID && store.providers.some(entry => entry.id === id && entry.kind === "openai-oauth"))
|
|
338
|
+
throw new TypeError("OpenAI OAuth is a read-only native provider");
|
|
339
|
+
if (store.providers.filter(entry => entry.id === id).length > 1)
|
|
340
|
+
throw new TypeError("codexProviders/remove: duplicate provider ID is ambiguous; explicitly repair the conflicting identities before removing");
|
|
343
341
|
const next = removeProvider(store, id);
|
|
344
342
|
writeProviderStore(worldHome, next);
|
|
345
343
|
return viewOf(worldHome, codexHome, next, syncedAt);
|
|
@@ -99,6 +99,9 @@ export function providerEntry(value) {
|
|
|
99
99
|
if (value === null || typeof value !== "object")
|
|
100
100
|
return undefined;
|
|
101
101
|
const record = value;
|
|
102
|
+
if (record["kind"] === "openai-oauth" && record["id"] === "openai-oauth") {
|
|
103
|
+
return { id: "openai-oauth", label: "OpenAI OAuth", kind: "openai-oauth", origin: "native", baseUrl: "", protocol: "responses", auth: { kind: "apiKey", value: "" }, models: [] };
|
|
104
|
+
}
|
|
102
105
|
const id = record["id"];
|
|
103
106
|
const label = str(record["label"]);
|
|
104
107
|
const baseUrl = str(record["baseUrl"]);
|
|
@@ -144,7 +147,9 @@ export function readProviderStore(worldHome) {
|
|
|
144
147
|
const rawRemoved = record["removed"];
|
|
145
148
|
const removed = (Array.isArray(rawRemoved) ? rawRemoved : [])
|
|
146
149
|
.filter((value) => typeof value === "string" && value.trim() !== "");
|
|
147
|
-
|
|
150
|
+
const blockedNativeIds = Array.isArray(record["blockedNativeIds"])
|
|
151
|
+
? [...new Set(record["blockedNativeIds"].filter((id) => id === "openai-oauth"))] : [];
|
|
152
|
+
return { version: 1, providers, ...(removed.length === 0 ? {} : { removed }), ...(blockedNativeIds.length === 0 ? {} : { blockedNativeIds }) };
|
|
148
153
|
}
|
|
149
154
|
/**
|
|
150
155
|
* Persist the store atomically (tmp + rename) and 0600 — the file holds API
|
|
@@ -155,7 +160,7 @@ export function writeProviderStore(worldHome, store) {
|
|
|
155
160
|
const path = providerStorePath(worldHome);
|
|
156
161
|
mkdirSync(dirname(path), { recursive: true });
|
|
157
162
|
const temp = `${path}.tmp-${process.pid}`;
|
|
158
|
-
writeFileSync(temp, `${JSON.stringify(store, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
163
|
+
writeFileSync(temp, `${JSON.stringify({ ...store, providers: store.providers.map(entry => entry.kind === "openai-oauth" ? { id: entry.id, label: entry.label, kind: entry.kind, origin: "native" } : entry) }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
159
164
|
renameSync(temp, path);
|
|
160
165
|
}
|
|
161
166
|
/**
|
|
@@ -176,7 +181,8 @@ export function upsertProvider(store, entry) {
|
|
|
176
181
|
// carry the tombstone forward, and the next sync would keep skipping the
|
|
177
182
|
// endpoint the user just re-added.
|
|
178
183
|
const { removed: _stale, ...rest } = store;
|
|
179
|
-
|
|
184
|
+
const blockedNativeIds = [...new Set([...(store.blockedNativeIds ?? []), ...(entry.id === "openai-oauth" && entry.kind !== "openai-oauth" ? [entry.id] : [])])];
|
|
185
|
+
return { ...rest, providers, ...(removed.length === 0 ? {} : { removed }), ...(blockedNativeIds.length === 0 ? {} : { blockedNativeIds }) };
|
|
180
186
|
}
|
|
181
187
|
/**
|
|
182
188
|
* Drop one provider, and TOMBSTONE its endpoint so the reconcile cannot bring
|
|
@@ -193,7 +199,8 @@ export function removeProvider(store, id) {
|
|
|
193
199
|
return { ...store, providers };
|
|
194
200
|
const key = endpointKey(entry.baseUrl);
|
|
195
201
|
const removed = store.removed?.includes(key) === true ? store.removed : [...store.removed ?? [], key];
|
|
196
|
-
|
|
202
|
+
const blockedNativeIds = [...new Set([...(store.blockedNativeIds ?? []), ...(entry.id === "openai-oauth" && entry.kind !== "openai-oauth" ? [entry.id] : [])])];
|
|
203
|
+
return { ...store, providers, removed, ...(blockedNativeIds.length === 0 ? {} : { blockedNativeIds }) };
|
|
197
204
|
}
|
|
198
205
|
/**
|
|
199
206
|
* The comparison key for "the same endpoint". Codex itself treats two provider
|
|
@@ -330,7 +337,13 @@ export function syncNativeProviders(store, imported) {
|
|
|
330
337
|
if (store.removed?.includes(key) === true)
|
|
331
338
|
continue;
|
|
332
339
|
const wantedId = incoming.nativeId !== undefined && isProviderId(incoming.nativeId) ? incoming.nativeId : undefined;
|
|
333
|
-
|
|
340
|
+
// A native identity is not an endpoint table. Never refresh it, rename a
|
|
341
|
+
// conflicting native table behind the user's back, or select a duplicate id.
|
|
342
|
+
if (wantedId === "openai-oauth" && (providers.some(entry => entry.id === wantedId && entry.kind === "openai-oauth")
|
|
343
|
+
|| providers.filter(entry => entry.id === wantedId).length > 1))
|
|
344
|
+
continue;
|
|
345
|
+
const at = providers.findIndex((entry) => entry.kind !== "openai-oauth" &&
|
|
346
|
+
((wantedId !== undefined && entry.id === wantedId) || endpointKey(entry.baseUrl) === key));
|
|
334
347
|
if (at === -1) {
|
|
335
348
|
const id = uniqueProviderId(wantedId ?? providerIdFromEndpoint(baseUrl), providers.map((entry) => entry.id));
|
|
336
349
|
providers.push({
|
|
@@ -370,7 +383,7 @@ export function syncNativeProviders(store, imported) {
|
|
|
370
383
|
}
|
|
371
384
|
const removed = store.removed ?? [];
|
|
372
385
|
const next = removed.length === 0
|
|
373
|
-
? { version: 1, providers }
|
|
374
|
-
: { version: 1, providers, removed };
|
|
386
|
+
? { ...store, version: 1, providers }
|
|
387
|
+
: { ...store, version: 1, providers, removed };
|
|
375
388
|
return { store: next, added, updated };
|
|
376
389
|
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex-runtime-fetch.ts — the lazy, durable Codex runtime the app-server line
|
|
3
|
+
* falls back to when the operator has NO codex of their own.
|
|
4
|
+
*
|
|
5
|
+
* 2026-10-06 user ruling (amends the 2026-10-05 core-discovery order): the
|
|
6
|
+
* exact-pin `@openai/codex` package must NEVER ride the installation — it went
|
|
7
|
+
* out as a real `dependencies` entry in 0.1.11, so every registry consumer
|
|
8
|
+
* downloaded the pinned CLI binary package AND its six aliased platform
|
|
9
|
+
* binaries (linux-x64/arm64, darwin-x64/arm64, win32-x64/arm64) at install
|
|
10
|
+
* time. That is the omp-sdk/agy-sdk/codex-sdk rule violated one week after it
|
|
11
|
+
* was written: SDKs and binaries are second-class — anything a line needs, it
|
|
12
|
+
* fetches at RUNTIME into its world home, and installs stay clean (no
|
|
13
|
+
* postinstall, no allow-build, no dependency that ships a binary).
|
|
14
|
+
*
|
|
15
|
+
* So the discovery order in codex-runtime.ts is
|
|
16
|
+
* CODEX_BIN → PATH CLI → desktop core → world-home runtime (this module),
|
|
17
|
+
* and this module is the only supplier of that last tier: on the first real
|
|
18
|
+
* prompt, when override, CLI and desktop all missed, the pinned
|
|
19
|
+
* `@openai/codex@0.160.0` tree is fetched into
|
|
20
|
+
*
|
|
21
|
+
* <worldHome>/codex-runtime/
|
|
22
|
+
* package.json written by us (private, never published)
|
|
23
|
+
* node_modules/ `npm install --exact --ignore-scripts --omit=dev`
|
|
24
|
+
* node_modules/@openai/codex/bin/codex.js the entry discovery returns
|
|
25
|
+
* <worldHome>/codex-runtime.json marker — written LAST, after the smoke run
|
|
26
|
+
* <worldHome>/codex-runtime.lock atomic (mkdir) bootstrap lock
|
|
27
|
+
* <worldHome>/codex-runtime.error.log written ONLY by a failed fetch
|
|
28
|
+
*
|
|
29
|
+
* Why the world home rather than the package root: the package root is the
|
|
30
|
+
* installed tarball — read-only in every real consumer install, and shared by
|
|
31
|
+
* every world. The world home (`<dshHome>/agents/codex`) is per-runtime,
|
|
32
|
+
* writable, and already the home of this adapter's own DSH state.
|
|
33
|
+
*
|
|
34
|
+
* Readiness is the ENTRY FILE (checked on every discovery, zero network) plus
|
|
35
|
+
* the marker's package version: a pin bump in a future adapter must refetch,
|
|
36
|
+
* and the sync resolver alone would happily keep using the old tree.
|
|
37
|
+
*
|
|
38
|
+
* The npm invocation is deliberately `--ignore-scripts`: the codex packages
|
|
39
|
+
* ship their binaries inside the tarballs, so a fetch needs zero lifecycle
|
|
40
|
+
* scripts — exactly the property this ruling exists to protect.
|
|
41
|
+
*/
|
|
42
|
+
import { execFile } from "node:child_process";
|
|
43
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
44
|
+
import { dirname, join, resolve } from "node:path";
|
|
45
|
+
import { fileURLToPath } from "node:url";
|
|
46
|
+
import { promisify } from "node:util";
|
|
47
|
+
import { resolveCodexRuntime } from "./codex-runtime.js";
|
|
48
|
+
/** The pinned Codex CLI package this line fetches (ships the app-server binary). */
|
|
49
|
+
export const CODEX_RUNTIME_PACKAGE = "@openai/codex";
|
|
50
|
+
/** Pin of {@link CODEX_RUNTIME_PACKAGE}; bumping it must refetch the tree. */
|
|
51
|
+
export const CODEX_RUNTIME_VERSION = "0.160.0";
|
|
52
|
+
/** The entry `resolveCodexRuntime` returns, relative to the runtime dir. */
|
|
53
|
+
const CODEX_ENTRY_RELATIVE = ["node_modules", CODEX_RUNTIME_PACKAGE, "bin", "codex.js"];
|
|
54
|
+
export function codexRuntimeLayout(worldHome) {
|
|
55
|
+
const dir = join(worldHome, "codex-runtime");
|
|
56
|
+
return {
|
|
57
|
+
dir,
|
|
58
|
+
manifest: join(dir, "package.json"),
|
|
59
|
+
entry: join(dir, ...CODEX_ENTRY_RELATIVE),
|
|
60
|
+
marker: join(worldHome, "codex-runtime.json"),
|
|
61
|
+
lock: join(worldHome, "codex-runtime.lock"),
|
|
62
|
+
errorLog: join(worldHome, "codex-runtime.error.log"),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Read the install marker; undefined when absent or unreadable. */
|
|
66
|
+
export function readCodexRuntimeRecord(worldHome) {
|
|
67
|
+
const { marker } = codexRuntimeLayout(worldHome);
|
|
68
|
+
if (!existsSync(marker))
|
|
69
|
+
return undefined;
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(readFileSync(marker, "utf8"));
|
|
72
|
+
return typeof parsed === "object" && parsed !== null ? parsed : undefined;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Fast path: a fetched runtime exists, matches `identity`, and its entry file
|
|
80
|
+
* is still on disk. Everything else must (re)fetch or (re)discover.
|
|
81
|
+
*/
|
|
82
|
+
export function codexRuntimeCurrent(worldHome, identity) {
|
|
83
|
+
const record = readCodexRuntimeRecord(worldHome);
|
|
84
|
+
if (record === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
if (record.pluginVersion !== identity.pluginVersion)
|
|
87
|
+
return undefined;
|
|
88
|
+
if (record.packageVersion !== identity.packageVersion)
|
|
89
|
+
return undefined;
|
|
90
|
+
if (!existsSync(codexRuntimeLayout(worldHome).entry))
|
|
91
|
+
return undefined;
|
|
92
|
+
return record;
|
|
93
|
+
}
|
|
94
|
+
/** The npm argv the runtime tree is installed with (kept pure for the test). */
|
|
95
|
+
export function codexInstallArgv(packageVersion = CODEX_RUNTIME_VERSION) {
|
|
96
|
+
return [
|
|
97
|
+
"install",
|
|
98
|
+
"--save-exact",
|
|
99
|
+
"--ignore-scripts",
|
|
100
|
+
"--omit=dev",
|
|
101
|
+
`${CODEX_RUNTIME_PACKAGE}@${packageVersion}`,
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
/** A held lock older than this is treated as abandoned and reclaimed. */
|
|
105
|
+
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
106
|
+
/** The smoke run's wall-clock cap: `--version` must never wedge a turn. */
|
|
107
|
+
const SMOKE_TIMEOUT_MS = 60_000;
|
|
108
|
+
const execFileAsync = promisify(execFile);
|
|
109
|
+
/**
|
|
110
|
+
* The PRODUCTION {@link CodexRuntimeFetchDeps}: real npm/Node invocations plus
|
|
111
|
+
* the marker identity read from this package's own manifest. Kept IN THE
|
|
112
|
+
* MODULE so the manual pre-warm script and the first-prompt lazy fetch share
|
|
113
|
+
* ONE implementation — a second copy is exactly how the two paths would drift.
|
|
114
|
+
*/
|
|
115
|
+
export function createCodexRuntimeDeps(options = {}) {
|
|
116
|
+
const pluginDir = options.pluginDir ?? dirname(dirname(fileURLToPath(import.meta.url)));
|
|
117
|
+
return {
|
|
118
|
+
run: async (cmd, args, opts) => {
|
|
119
|
+
await execFileAsync(cmd, args, { cwd: opts?.cwd, env: options.env, maxBuffer: 64 * 1024 * 1024 });
|
|
120
|
+
},
|
|
121
|
+
smoke: async (cmd, args, opts) => {
|
|
122
|
+
const { stdout } = await execFileAsync(cmd, args, {
|
|
123
|
+
cwd: opts?.cwd,
|
|
124
|
+
env: options.env,
|
|
125
|
+
timeout: opts?.timeoutMs,
|
|
126
|
+
maxBuffer: 1024 * 1024,
|
|
127
|
+
});
|
|
128
|
+
return stdout;
|
|
129
|
+
},
|
|
130
|
+
pluginVersion: readPluginVersion(pluginDir),
|
|
131
|
+
log: (line) => process.stderr.write(`[agent-codex] ${line}\n`),
|
|
132
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** The version of the adapter package at `pluginDir` (the marker identity half). */
|
|
136
|
+
export function readPluginVersion(pluginDir) {
|
|
137
|
+
const raw = readFileSync(join(pluginDir, "package.json"), "utf8");
|
|
138
|
+
const parsed = JSON.parse(raw);
|
|
139
|
+
if (typeof parsed.version !== "string" || parsed.version === "") {
|
|
140
|
+
throw new Error(`codex: ${join(pluginDir, "package.json")} carries no version`);
|
|
141
|
+
}
|
|
142
|
+
return parsed.version;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Sync discovery across the whole ladder INCLUDING the fetched runtime; a
|
|
146
|
+
* miss throws (which is the fetch trigger, never surfaced to the user).
|
|
147
|
+
*/
|
|
148
|
+
function discoverWith(worldHome, deps) {
|
|
149
|
+
return resolveCodexRuntime({
|
|
150
|
+
...(deps.discovery ?? {}),
|
|
151
|
+
...(deps.env === undefined ? {} : { env: deps.env }),
|
|
152
|
+
packageEntryCandidates: [codexRuntimeLayout(worldHome).entry],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Return a usable Codex runtime for `<worldHome>`, fetching the pinned
|
|
157
|
+
* package first when the operator's own CODEX_BIN / PATH CLI / desktop core
|
|
158
|
+
* all miss.
|
|
159
|
+
*
|
|
160
|
+
* Idempotent: an operator-provided runtime short-circuits without touching
|
|
161
|
+
* the network, and so does a complete, matching fetched runtime. Concurrent
|
|
162
|
+
* callers: the bootstrap is guarded by a `mkdir` lock — a second caller that
|
|
163
|
+
* finds a FRESH lock throws a clear "fetch in progress" error, while an
|
|
164
|
+
* abandoned lock (older than {@link LOCK_STALE_MS}) is reclaimed so a crashed
|
|
165
|
+
* fetch cannot brick the line.
|
|
166
|
+
*
|
|
167
|
+
* Failures append to `<worldHome>/codex-runtime.error.log` and throw a single
|
|
168
|
+
* actionable error — never a partial runtime behind a success marker.
|
|
169
|
+
*/
|
|
170
|
+
export async function ensureCodexRuntime(worldHome, deps) {
|
|
171
|
+
const usable = (() => {
|
|
172
|
+
try {
|
|
173
|
+
return discoverWith(worldHome, deps);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
})();
|
|
179
|
+
// An operator runtime (override/CLI/desktop) is always taken as-is; a fetched
|
|
180
|
+
// runtime is taken only when its pin still matches — a bumped pin refetches.
|
|
181
|
+
if (usable !== undefined && usable.source !== "package")
|
|
182
|
+
return usable;
|
|
183
|
+
const identity = { pluginVersion: deps.pluginVersion, packageVersion: CODEX_RUNTIME_VERSION };
|
|
184
|
+
if (usable !== undefined && codexRuntimeCurrent(worldHome, identity) !== undefined)
|
|
185
|
+
return usable;
|
|
186
|
+
const layout = codexRuntimeLayout(worldHome);
|
|
187
|
+
mkdirSync(worldHome, { recursive: true });
|
|
188
|
+
acquireLock(layout, deps);
|
|
189
|
+
try {
|
|
190
|
+
// Re-check inside the lock: a peer may have completed the fetch in the
|
|
191
|
+
// window between our check and the lock, in which case we pay nothing.
|
|
192
|
+
const raced = (() => {
|
|
193
|
+
try {
|
|
194
|
+
return discoverWith(worldHome, deps);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
})();
|
|
200
|
+
if (raced !== undefined && (raced.source !== "package" || codexRuntimeCurrent(worldHome, identity) !== undefined))
|
|
201
|
+
return raced;
|
|
202
|
+
try {
|
|
203
|
+
await buildRuntime(layout, deps, identity);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
207
|
+
const stamp = new Date().toISOString();
|
|
208
|
+
try {
|
|
209
|
+
appendFileSync(layout.errorLog, `[${stamp}] codex runtime fetch failed: ${detail}\n`);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
/* the trace is best-effort; the throw below is the contract */
|
|
213
|
+
}
|
|
214
|
+
throw new Error(`codex: could not fetch the runtime (${CODEX_RUNTIME_PACKAGE}@${CODEX_RUNTIME_VERSION}) at ${layout.dir}: ${detail}. ` +
|
|
215
|
+
`Pre-warm it by hand with \`npm run fetch-codex-runtime\`, or install a Codex CLI and set CODEX_BIN. Trace: ${layout.errorLog}`);
|
|
216
|
+
}
|
|
217
|
+
const resolved = discoverWith(worldHome, deps);
|
|
218
|
+
deps.log(`codex: runtime ready at ${layout.dir} (${CODEX_RUNTIME_PACKAGE}@${CODEX_RUNTIME_VERSION})`);
|
|
219
|
+
return resolved;
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
rmSync(layout.lock, { recursive: true, force: true });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** The idempotent build steps; the marker is written last, after the smoke run. */
|
|
226
|
+
async function buildRuntime(layout, deps, identity) {
|
|
227
|
+
mkdirSync(layout.dir, { recursive: true });
|
|
228
|
+
// npm needs a manifest to own the tree; `private` + a throwaway version keep
|
|
229
|
+
// it unambiguously not-a-publishable-package, and an existing one is left
|
|
230
|
+
// alone so a later install can still be reproduced by hand.
|
|
231
|
+
if (!existsSync(layout.manifest)) {
|
|
232
|
+
writeFileSync(layout.manifest, JSON.stringify({ name: "codex-runtime", private: true, version: "0.0.0" }, null, 2) + "\n");
|
|
233
|
+
}
|
|
234
|
+
deps.log(`codex: fetching ${CODEX_RUNTIME_PACKAGE}@${CODEX_RUNTIME_VERSION} into ${layout.dir}`);
|
|
235
|
+
await deps.run("npm", codexInstallArgv(), { cwd: layout.dir });
|
|
236
|
+
if (!existsSync(layout.entry)) {
|
|
237
|
+
throw new Error(`npm install produced no ${layout.entry} (the package layout changed?)`);
|
|
238
|
+
}
|
|
239
|
+
// Smoke step: run the fetched entry's `--version`. This exercises the real
|
|
240
|
+
// platform-binary pick the entry performs at run time — a tree whose binary
|
|
241
|
+
// does not match this host fails HERE, at the fetch, with a trace on disk,
|
|
242
|
+
// instead of at the first prompt inside the agent's cold-start error path.
|
|
243
|
+
// The entry is resolved ABSOLUTE before the cwd switch — the smoke run
|
|
244
|
+
// changes the working directory, and a relative worldHome would otherwise
|
|
245
|
+
// re-resolve against it (same trap as the `-c model_catalog_json` lesson).
|
|
246
|
+
const smokeEntry = resolve(layout.entry);
|
|
247
|
+
deps.log(`codex: smoke-running ${CODEX_RUNTIME_PACKAGE}@${CODEX_RUNTIME_VERSION} --version`);
|
|
248
|
+
const stdout = await deps.smoke(process.execPath, [smokeEntry, "--version"], { cwd: layout.dir, timeoutMs: SMOKE_TIMEOUT_MS });
|
|
249
|
+
const smokeVersion = stdout.trim().split("\n")[0]?.trim() ?? "";
|
|
250
|
+
if (smokeVersion === "") {
|
|
251
|
+
throw new Error(`smoke run of ${layout.entry} --version produced no output`);
|
|
252
|
+
}
|
|
253
|
+
// Marker LAST — a crash before this point leaves an incomplete runtime that
|
|
254
|
+
// the next call heals, never a false success.
|
|
255
|
+
const record = {
|
|
256
|
+
...identity,
|
|
257
|
+
entryPath: layout.entry,
|
|
258
|
+
smokeVersion,
|
|
259
|
+
installedAt: new Date().toISOString(),
|
|
260
|
+
};
|
|
261
|
+
writeFileSync(layout.marker, JSON.stringify(record, null, 2) + "\n");
|
|
262
|
+
rmSync(layout.errorLog, { force: true });
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* mkdir-based mutex. Returns owning the lock; throws when a fresh lock is held
|
|
266
|
+
* by someone else; reclaims a lock older than {@link LOCK_STALE_MS}.
|
|
267
|
+
*/
|
|
268
|
+
function acquireLock(layout, deps) {
|
|
269
|
+
for (;;) {
|
|
270
|
+
try {
|
|
271
|
+
mkdirSync(layout.lock);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
if (error.code !== "EEXIST")
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
let ageMs;
|
|
279
|
+
try {
|
|
280
|
+
ageMs = Date.now() - statSync(layout.lock).mtimeMs;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
continue; // released between the mkdir attempt and the stat — retry now
|
|
284
|
+
}
|
|
285
|
+
if (ageMs > LOCK_STALE_MS) {
|
|
286
|
+
deps.log(`codex: reclaiming stale runtime lock (age ${Math.round(ageMs / 1000)}s)`);
|
|
287
|
+
rmSync(layout.lock, { recursive: true, force: true });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
throw new Error(`codex: another runtime fetch is already in progress (lock ${layout.lock}). ` +
|
|
291
|
+
`Retry once it finishes; if no fetch is running, remove that lock directory.`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** Resolve an installed Codex runtime at first use, without modifying its home. */
|
|
2
|
+
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { posix, win32 } from "node:path";
|
|
5
|
+
function readableFile(path) {
|
|
6
|
+
try {
|
|
7
|
+
accessSync(path, constants.R_OK);
|
|
8
|
+
return statSync(path).isFile();
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function executable(path) {
|
|
15
|
+
try {
|
|
16
|
+
accessSync(path, constants.X_OK);
|
|
17
|
+
return statSync(path).isFile();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function desktopPaths(discovery) {
|
|
24
|
+
if (discovery.desktopCandidates !== undefined)
|
|
25
|
+
return discovery.desktopCandidates;
|
|
26
|
+
const explicit = discovery.env.CODEX_DESKTOP_BIN?.trim();
|
|
27
|
+
const paths = explicit ? [explicit] : [];
|
|
28
|
+
if (discovery.platform === "linux") {
|
|
29
|
+
paths.push("/usr/lib/chatgpt/resources/codex", "/usr/lib/codex/resources/codex", "/opt/Codex/resources/codex", "/opt/codex/resources/codex");
|
|
30
|
+
}
|
|
31
|
+
else if (discovery.platform === "darwin") {
|
|
32
|
+
for (const root of ["/Applications", posix.join(discovery.homeDir, "Applications")]) {
|
|
33
|
+
for (const app of ["Codex.app", "ChatGPT.app"])
|
|
34
|
+
paths.push(posix.join(root, app, "Contents/Resources/codex"));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else if (discovery.platform === "win32") {
|
|
38
|
+
const local = discovery.env.LOCALAPPDATA;
|
|
39
|
+
if (local) {
|
|
40
|
+
for (const app of ["Codex", "ChatGPT"])
|
|
41
|
+
paths.push(win32.join(local, "Programs", app, "resources", "codex.exe"));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return paths;
|
|
45
|
+
}
|
|
46
|
+
function isNodeScript(command, discovery) {
|
|
47
|
+
let target = command;
|
|
48
|
+
try {
|
|
49
|
+
target = discovery.realpath(command);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Explicit nonexistent paths are rejected by the file check below.
|
|
53
|
+
}
|
|
54
|
+
return /\.(?:[cm]?js)$/i.test(target);
|
|
55
|
+
}
|
|
56
|
+
export function resolveCodexRuntime(options = {}) {
|
|
57
|
+
const platform = options.platform ?? process.platform;
|
|
58
|
+
const env = options.env ?? process.env;
|
|
59
|
+
const isFile = options.isFile ?? readableFile;
|
|
60
|
+
const isExecutable = options.isExecutable ?? executable;
|
|
61
|
+
const path = platform === "win32" ? win32 : posix;
|
|
62
|
+
const shellWrapper = (command) => platform === "win32" && /\.(?:cmd|bat)$/i.test(command);
|
|
63
|
+
const discovery = {
|
|
64
|
+
platform,
|
|
65
|
+
env,
|
|
66
|
+
homeDir: options.homeDir ?? homedir(),
|
|
67
|
+
...(options.desktopCandidates === undefined ? {} : { desktopCandidates: options.desktopCandidates }),
|
|
68
|
+
...(options.packageEntryCandidates === undefined ? {} : { packageEntryCandidates: options.packageEntryCandidates }),
|
|
69
|
+
isFile,
|
|
70
|
+
isExecutable,
|
|
71
|
+
realpath: options.realpath ?? realpathSync,
|
|
72
|
+
findCommand: options.findCommand ?? ((name) => {
|
|
73
|
+
const suffixes = platform === "win32" ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")] : [""];
|
|
74
|
+
for (const directory of (env.PATH ?? "").split(platform === "win32" ? ";" : ":")) {
|
|
75
|
+
if (directory === "")
|
|
76
|
+
continue;
|
|
77
|
+
for (const suffix of suffixes) {
|
|
78
|
+
const candidate = path.join(directory, `${name}${suffix}`);
|
|
79
|
+
if (shellWrapper(candidate))
|
|
80
|
+
continue;
|
|
81
|
+
if (isFile(candidate) && isExecutable(candidate))
|
|
82
|
+
return candidate;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}),
|
|
87
|
+
};
|
|
88
|
+
const usable = (command) => !shellWrapper(command) && isFile(command) && (isNodeScript(command, discovery) || isExecutable(command));
|
|
89
|
+
const selected = (command, source) => ({ command, nodeScript: isNodeScript(command, discovery), source });
|
|
90
|
+
const override = env.CODEX_BIN?.trim();
|
|
91
|
+
if (override) {
|
|
92
|
+
const command = /[\\/]/.test(override) ? override : discovery.findCommand(override) ?? override;
|
|
93
|
+
if (shellWrapper(command))
|
|
94
|
+
throw new Error(`CODEX_BIN ${override} is a Windows .cmd/.bat shell wrapper. Set CODEX_BIN to the native codex.exe executable or the @openai/codex JavaScript entry instead.`);
|
|
95
|
+
if (!usable(command))
|
|
96
|
+
throw new Error(`CODEX_BIN ${override} is not a readable JavaScript entry or executable Codex runtime. Fix CODEX_BIN or remove the override.`);
|
|
97
|
+
return selected(command, "override");
|
|
98
|
+
}
|
|
99
|
+
const cli = discovery.findCommand("codex");
|
|
100
|
+
if (cli !== undefined && usable(cli))
|
|
101
|
+
return selected(cli, "cli");
|
|
102
|
+
for (const candidate of desktopPaths(discovery)) {
|
|
103
|
+
if (!shellWrapper(candidate) && isFile(candidate) && isExecutable(candidate))
|
|
104
|
+
return selected(candidate, "desktop");
|
|
105
|
+
}
|
|
106
|
+
// Last resort only: the runtime the fetch layer materialized into the world
|
|
107
|
+
// home. It cannot pre-exist on a clean install — the fetch only runs when the
|
|
108
|
+
// override, CLI and desktop all missed, so checking it last is exact.
|
|
109
|
+
for (const entry of discovery.packageEntryCandidates ?? []) {
|
|
110
|
+
if (!shellWrapper(entry) && isFile(entry))
|
|
111
|
+
return { command: entry, nodeScript: true, source: "package" };
|
|
112
|
+
}
|
|
113
|
+
throw new Error("cannot locate a Codex runtime: install a Codex CLI (or the desktop app) on PATH, " +
|
|
114
|
+
"set CODEX_BIN to its executable or JavaScript entry, " +
|
|
115
|
+
"or let the adapter fetch the pinned @openai/codex package into the world home at first prompt.");
|
|
116
|
+
}
|
|
117
|
+
/** Compatibility interface for callers that need only the selected path. */
|
|
118
|
+
export function resolveCodexCommand(options = {}) {
|
|
119
|
+
return resolveCodexRuntime(options).command;
|
|
120
|
+
}
|