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.
Files changed (50) hide show
  1. package/README.md +38 -2
  2. package/agent-agy/dist/agent.js +19 -1
  3. package/agent-claude/dist/agent.js +23 -5
  4. package/agent-codex/dist/adapter.js +3 -2
  5. package/agent-codex/dist/agent.js +41 -7
  6. package/agent-codex/dist/app-server-client.js +9 -2
  7. package/agent-codex/dist/app-server-protocol.js +6 -2
  8. package/agent-codex/dist/app-server-wire.js +29 -24
  9. package/agent-codex/dist/codex-launch.js +10 -32
  10. package/agent-codex/dist/codex-native-config.js +9 -3
  11. package/agent-codex/dist/codex-oauth.js +153 -0
  12. package/agent-codex/dist/codex-provider-gateway.js +36 -38
  13. package/agent-codex/dist/codex-providers.js +20 -7
  14. package/agent-codex/dist/codex-runtime-fetch.js +293 -0
  15. package/agent-codex/dist/codex-runtime.js +120 -0
  16. package/agent-codex/dist/index.js +20 -12
  17. package/agent-codex/package.json +8 -3
  18. package/agent-codex-sdk/dist/adapter.js +3 -2
  19. package/agent-codex-sdk/dist/agent.js +35 -1
  20. package/agent-codex-sdk/dist/codex-native-config.js +9 -3
  21. package/agent-codex-sdk/dist/codex-oauth.js +153 -0
  22. package/agent-codex-sdk/dist/codex-provider-gateway.js +36 -38
  23. package/agent-codex-sdk/dist/codex-providers.js +20 -7
  24. package/agent-codex-sdk/dist/codex-sdk-launch.js +5 -38
  25. package/agent-codex-sdk/dist/index.js +18 -11
  26. package/agent-codex-sdk/dist/sdk-runtime.js +3 -3
  27. package/agent-hermes/dist/agent.js +46 -2
  28. package/agent-hermes/dist/hermes-client.js +49 -1
  29. package/agent-omp/dist/agent.js +17 -4
  30. package/agent-omp-sdk/dist/agent.d.ts +11 -0
  31. package/agent-omp-sdk/dist/agent.js +17 -4
  32. package/agent-pi/dist/agent.js +33 -1
  33. package/client/index.js +10 -7
  34. package/package.json +7 -5
  35. package/superd-hub/dist/client/agent-settings.d.ts +1 -1
  36. package/superd-hub/dist/client/agent-settings.js +2 -2
  37. package/superd-hub/dist/connection-trust.d.ts +8 -0
  38. package/superd-hub/dist/connection-trust.js +16 -0
  39. package/superd-hub/dist/web-trust.d.ts +14 -42
  40. package/superd-hub/dist/web-trust.js +18 -43
  41. package/superd-hub/dist/world-mount.d.ts +6 -5
  42. package/superd-hub/dist/world-mount.js +6 -6
  43. package/superd-hub/lib/client/index.js +10 -7
  44. package/superd-hub/package.json +5 -1
  45. package/agent-codex/README.md +0 -179
  46. package/agent-codex-sdk/README.md +0 -79
  47. package/agent-hermes/README.md +0 -145
  48. package/agent-omp/README.md +0 -94
  49. package/agent-omp-sdk/README.md +0 -103
  50. 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
- if (result.added.length > 0 || result.updated.length > 0) {
176
- writeProviderStore(worldHome, result.store);
177
- }
178
- return result;
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 combined = [note, shadowed].filter((part) => part !== undefined).join(" ");
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
- return { version: 1, providers, ...(removed.length === 0 ? {} : { removed }) };
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
- return { ...rest, providers, ...(removed.length === 0 ? {} : { removed }) };
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
- return { ...store, providers, removed };
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
- const at = providers.findIndex((entry) => (wantedId !== undefined && entry.id === wantedId) || endpointKey(entry.baseUrl) === key);
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
+ }