super-dsh 0.1.10-e → 0.1.11

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 (38) hide show
  1. package/README.md +18 -2
  2. package/agent-agy/dist/agent.js +19 -1
  3. package/agent-claude/dist/agent.js +23 -5
  4. package/agent-codex/README.md +18 -6
  5. package/agent-codex/dist/adapter.js +3 -2
  6. package/agent-codex/dist/agent.js +41 -7
  7. package/agent-codex/dist/app-server-client.js +7 -1
  8. package/agent-codex/dist/app-server-wire.js +10 -22
  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.js +120 -0
  15. package/agent-codex/dist/index.js +18 -11
  16. package/agent-codex/package.json +1 -1
  17. package/agent-codex-sdk/dist/adapter.js +3 -2
  18. package/agent-codex-sdk/dist/agent.js +35 -1
  19. package/agent-codex-sdk/dist/codex-native-config.js +9 -3
  20. package/agent-codex-sdk/dist/codex-oauth.js +153 -0
  21. package/agent-codex-sdk/dist/codex-provider-gateway.js +36 -38
  22. package/agent-codex-sdk/dist/codex-providers.js +20 -7
  23. package/agent-codex-sdk/dist/codex-sdk-launch.js +5 -38
  24. package/agent-codex-sdk/dist/index.js +18 -11
  25. package/agent-codex-sdk/dist/sdk-runtime.js +2 -2
  26. package/agent-hermes/dist/agent.js +46 -2
  27. package/agent-hermes/dist/hermes-client.js +49 -1
  28. package/agent-omp/dist/agent.js +17 -4
  29. package/agent-omp-sdk/dist/agent.d.ts +11 -0
  30. package/agent-omp-sdk/dist/agent.js +17 -4
  31. package/agent-pi/dist/agent.js +33 -1
  32. package/client/index.js +10 -7
  33. package/package.json +2 -2
  34. package/superd-hub/dist/client/agent-settings.d.ts +1 -1
  35. package/superd-hub/dist/client/agent-settings.js +2 -2
  36. package/superd-hub/dist/world-mount.d.ts +6 -5
  37. package/superd-hub/dist/world-mount.js +6 -6
  38. package/superd-hub/lib/client/index.js +10 -7
package/README.md CHANGED
@@ -14,7 +14,7 @@ the foreign store.
14
14
  ## Install
15
15
 
16
16
  ```bash
17
- dsh plugin --profile <name> add super-dsh@0.1.0
17
+ dsh plugin --profile <name> add super-dsh@0.1.11
18
18
  ```
19
19
 
20
20
  That single package composes the whole line (`@pgmi-builds/superd-hub` plus the
@@ -27,12 +27,28 @@ home under `<DSH_HOME>/agents/<label>/` on first boot.
27
27
  | Label | Runtime | Integration |
28
28
  |---|---|---|
29
29
  | OMP | OMP (`~/.omp`) | host `omp` binary over RPC (`omp --mode rpc`, NDJSON v1), lazy per session |
30
- | Codex | Codex CLI (`~/.codex`) | adapter world plugin |
30
+ | Codex | Codex app-server (`~/.codex`) | desktop core first, CLI and bundled core fallback |
31
31
  | Claude | Claude Code (`~/.claude`) | standalone adapter, joined by the hub |
32
32
  | Pi | pi coding agent (`~/.pi`) | in-process SDK |
33
33
  | Hermes | Hermes (`~/.hermes`) | gateway child |
34
34
  | Antigravity | agy SDK | Python bridge (`bridge/agy_bridge.py`) |
35
35
 
36
+ ### Codex in 0.1.11
37
+
38
+ Codex discovers the local OpenAI OAuth login as a read-only provider in the
39
+ model selector. Selecting it explicitly overrides a third-party provider in the
40
+ native config, while credentials and native configuration stay in the native
41
+ home. Third-party providers remain selectable per session.
42
+
43
+ The adapter prefers the installed desktop core, then the CLI, then the bundled
44
+ 0.160.0 core (`CODEX_BIN` overrides discovery). Desktop host browser and computer
45
+ use services are separate and are not attached by this adapter.
46
+
47
+ **Known limitation:** returning a session with third-party reasoning history to
48
+ OpenAI OAuth can be rejected by the official endpoint. Use a separate official
49
+ session for now. Compaction before switching was proven in an isolated probe;
50
+ automatic compaction is not included in this release.
51
+
36
52
  ### Opt-in SDK halves
37
53
 
38
54
  Three runtimes also ship a preserved in-process SDK transport beside the primary
@@ -129,9 +129,27 @@ export class AgyAgent {
129
129
  this.closeStep();
130
130
  this.commitStepStart();
131
131
  const callId = ToolCallId(ev.id || `agy-${this.dashTurn}-${this.step}`);
132
+ const argumentsJson = ev.args || "{}";
133
+ // Advertisement-before-execution (validator invariant, 2026-10-04 dev3
134
+ // omp/hermes repair family): agy assistant frames carry only
135
+ // reasoning/text — never tool-call blocks — so every tool/call needs
136
+ // a minimal advertisement assistant frame first; arguments are copied
137
+ // verbatim into both frames (validator: lifecycle arguments ≡
138
+ // advertised, strict string equality).
139
+ const advertisement = createAssistantMessage({
140
+ content: [{ type: "tool-call", id: callId, name: ev.name, arguments: argumentsJson }],
141
+ source: {
142
+ provider: this.options.provider ?? "agy",
143
+ model: this.options.model ?? "",
144
+ },
145
+ });
146
+ this.session.append("assistant/message", {
147
+ turn: this.dashTurn, step: this.step,
148
+ message: advertisement, stream: [],
149
+ }, { surfaceOp: "append" });
132
150
  this.session.append("tool/call", {
133
151
  turn: this.dashTurn, step: this.step,
134
- callId, name: ev.name, arguments: ev.args || "{}",
152
+ callId, name: ev.name, arguments: argumentsJson,
135
153
  });
136
154
  this.unpairedCalls.push({ callId, name: ev.name });
137
155
  }
@@ -969,8 +969,12 @@ export class ClaudeAgent {
969
969
  * projection carried — so the `tool_start` flush lands a same-attempt
970
970
  * sample and nothing ever stashes across turns.
971
971
  */
972
- flushAssistant(usage) {
973
- if (this.reasoningBuffer === "" && this.textBuffer === "") {
972
+ flushAssistant(usage, toolCall) {
973
+ // A toolCall rides the SAME frame (advertise+usage on one attempt): the
974
+ // token-meter fold requires every step's assistant frame to carry a usage
975
+ // sample, so a separate no-usage advertisement frame would turn the
976
+ // turn's whole usage disclosure undefined.
977
+ if (toolCall === undefined && this.reasoningBuffer === "" && this.textBuffer === "") {
974
978
  if (usage !== undefined)
975
979
  trace("flush had usage but no buffered content — sample dropped (no message carries it)");
976
980
  return;
@@ -980,6 +984,9 @@ export class ClaudeAgent {
980
984
  blocks.push({ type: "reasoning", text: this.reasoningBuffer });
981
985
  if (this.textBuffer !== "")
982
986
  blocks.push({ type: "text", text: this.textBuffer });
987
+ if (toolCall !== undefined) {
988
+ blocks.push({ type: "tool-call", id: ToolCallId(toolCall.callId), name: toolCall.name, arguments: toolCall.arguments });
989
+ }
983
990
  this.reasoningBuffer = "";
984
991
  this.textBuffer = "";
985
992
  const effectiveUsage = usage ?? this.capturedUsage;
@@ -1006,13 +1013,23 @@ export class ClaudeAgent {
1006
1013
  this.streamBridge = undefined;
1007
1014
  }
1008
1015
  appendToolCall(callId, name, args) {
1009
- this.commitStepStart();
1016
+ // The tool-call block rides the SAME assistant/message as any pending
1017
+ // text (native advertise+usage shape): one attempt per step with the
1018
+ // usage sample AND the advertisement on one frame. A separate no-usage
1019
+ // advertisement frame would satisfy the format validator but break the
1020
+ // token-meter fold (every step's assistant frame needs a usage sample).
1021
+ const argumentsJson = JSON.stringify(args ?? {});
1022
+ this.flushAssistant(undefined, {
1023
+ callId,
1024
+ name,
1025
+ arguments: argumentsJson,
1026
+ });
1010
1027
  this.session.append("tool/call", {
1011
1028
  turn: this.dashTurn,
1012
1029
  step: this.step,
1013
1030
  callId: ToolCallId(callId),
1014
1031
  name,
1015
- arguments: JSON.stringify(args ?? {}),
1032
+ arguments: argumentsJson,
1016
1033
  });
1017
1034
  }
1018
1035
  appendToolResult(callId, content, isError) {
@@ -1193,7 +1210,8 @@ export class ClaudeAgent {
1193
1210
  // RC-6: the mid-turn flush closes the attempt WITH the sample the
1194
1211
  // message itself reported (captured above / on the assistant events).
1195
1212
  this.captureProvenance(event);
1196
- this.flushAssistant();
1213
+ // flushes pending text WITH the tool-call block as one assistant
1214
+ // message (advertise+usage on one attempt — see appendToolCall).
1197
1215
  this.appendToolCall(callId, name, event.arguments);
1198
1216
  break;
1199
1217
  }
@@ -19,10 +19,10 @@ superd 外壳下的 **Codex adapter**(super-dsh 线):把 Codex CLI 经由
19
19
 
20
20
  ## 线别(2026-10-03 迁移)
21
21
 
22
- **app-server 线**取代了原 `@openai/codex-sdk` 线(SDK 依赖已从本包与主 pack 移除)。
22
+ **app-server 线**是主路线(SDK 依赖已从本包与主 pack 移除)。SDK 另存为默认关闭的 `agent-codex-sdk`,拥有独立的按需 runtime 安装。
23
23
  SDK 线在 adapter 里登记的一整排降级被 app-server 协议面直接消掉:
24
24
 
25
- | 能力 | SDK 线(已退役) | app-server 线(现役) |
25
+ | 能力 | SDK 可选线 | app-server 主路线 |
26
26
  |---|---|---|
27
27
  | 打断 | 只有 AbortSignal(掐断本轮 stream) | **`turn/interrupt`** |
28
28
  | steer | 无 → 排队 followUp 近似 | **`turn/steer`**(注入进行中的 turn) |
@@ -82,19 +82,31 @@ collab/subagent 项。
82
82
  | 变量 | 作用 |
83
83
  |---|---|
84
84
  | `CODEX_TRACE=1` | provider/agent/app-server 追踪(stderr) |
85
- | `CODEX_BIN` | 覆盖 codex 入口脚本(测试);默认 = 本包 pin 的 `@openai/codex/bin/codex.js` |
85
+ | `CODEX_BIN` | 显式覆盖 Codex 核心或 JS 入口;默认依序发现 desktop 核心、用户 CLI、包内 exact-pin fallback |
86
86
  | `CODEX_HOME` | Codex 原生 home 重定向(**测试专用**;生产路径零重定向) |
87
87
  | `CODEX_APPROVAL_MODE` | 覆盖审批档(`never`/`on-request`/`on-failure`/`untrusted`;非法值忽略) |
88
88
  | `CODEX_APPROVAL_TIMEOUT_MS` | 审批应答超时;`0` 关闭;默认 300000 |
89
89
  | `CODEX_IDLE_EXIT_MS` | 会话空闲后拆客户端;`0` 关闭;默认 600000 |
90
90
 
91
+ ## Binary 与官方 OAuth
92
+
93
+ 核心发现顺序是显式 `CODEX_BIN` → 已安装 desktop 核心 → 用户 CLI → 包内 `@openai/codex@0.160.0`。
94
+ 发现和启动仅发生在真正 prompt 时。JS 入口经 Node 启动,原生核心直接执行;认证或生成失败不会静默换 provider。
95
+ desktop 优先选择核心版本,不代表复制 desktop 宿主:独立 app-server 使用 Super D 自有 clientInfo,
96
+ 不借用父任务的 desktop 工具 pipe。browser/computer use 仍需要对应宿主服务与工具接线。
97
+
98
+ 本地 `auth.json` 有 ChatGPT OAuth 时,模型选择器增加只读的 **OpenAI OAuth** provider。
99
+ adapter 正本仅存其 native 身份,token 留在 Codex home;选择该 route 时通过子进程配置覆盖 builtin `openai`,
100
+ 不会改写用户的 config/auth。官方模型读取原生 `models_cache.json`,并保留原生配置的官方默认模型;第三方自定义目录不会当作官方目录。
101
+ 没有原生目录冲突时不强制注入静态 catalog,让实际选中的 binary 按版本、身份与有效期维护 cache;有目录冲突时仅注入可消费的官方 cache,缺失时明确报错。原生默认模型的 ID 不代表当前 binary 的访问权限。OAuth logout 后显式选择必须失败,不能落到本地第三方默认配置。
102
+
91
103
  ## 测试
92
104
 
93
105
  ```bash
94
- npm run build # tsc strict
106
+ node super-dsh/scripts/pack.mjs # 仓根执行,唯一构建打包入口
95
107
  DSH_HOME=<repo>/.tests \
96
- SUPERD_DSH_ANCHOR=<repo>/upstream/deepseek-harness/package.json \
97
- node --test test/*.test.mjs
108
+ SUPERD_DSH_ANCHOR=<repo>/upstream/deepseek-harness/apps/cli/package.json \
109
+ node --test super-dsh/agent-codex/test/*.test.mjs
98
110
  ```
99
111
 
100
112
  单测全部是 fake:`test/fixtures/fake-app-server.mjs` 是一个**可编程的 app-server 替身**
@@ -1,3 +1,5 @@
1
+ import { readEffectiveProviderStore } from "./codex-oauth.js";
2
+ import { resolveCodexHome } from "./codex-store.js";
1
3
  /**
2
4
  * Codex-backed `LlmAdapter` registered on `ctx.llm` by the Codex provider.
3
5
  *
@@ -23,7 +25,6 @@
23
25
  * @module agent-codex/adapter
24
26
  */
25
27
  import { LlmAdapter, LlmError, } from "@deepseek-ai/dsh-llm";
26
- import { readProviderStore } from "./codex-providers.js";
27
28
  /**
28
29
  * The single route every Codex model was served under before the multi-provider
29
30
  * rework. No longer registered (a session pinned to it falls back to whatever
@@ -41,7 +42,7 @@ export function routeIds(store) {
41
42
  }
42
43
  /** The provider entry a route id names, or `undefined`. */
43
44
  export function providerFor(worldHome, providerId) {
44
- return readProviderStore(worldHome).providers.find((entry) => entry.id === providerId);
45
+ return readEffectiveProviderStore(worldHome, resolveCodexHome()).providers.find((entry) => entry.id === providerId);
45
46
  }
46
47
  export class CodexLlmAdapter extends LlmAdapter {
47
48
  worldHome;
@@ -289,6 +289,11 @@ export class CodexAgent {
289
289
  */
290
290
  #remoteQueue = [];
291
291
  #bridgedToolResults = new Set();
292
+ /** callIds already advertised by an appended assistant/message frame — the
293
+ * strict validator refuses any `tool/call` whose callId no assistant frame
294
+ * advertised, and requires the execution arguments to equal the advertised
295
+ * string (2026-10-04 dev3 omp/hermes repair: same write-side family). */
296
+ #advertisedToolCalls = new Set();
292
297
  /**
293
298
  * The open turn's FINAL assistant message, buffered from `message_end`
294
299
  * until `turn_end` so the SAME turn's usage can land on it (the DSH
@@ -711,9 +716,14 @@ export class CodexAgent {
711
716
  // An unknown provider id (a session pinned before the multi-provider rework)
712
717
  // maps to "no injection": the user's own config.toml is the honest answer.
713
718
  const launchSpec = launch ?? NATIVE_LAUNCH;
714
- const providerId = launch === undefined ? null : route?.provider ?? null;
719
+ const providerId = launch === undefined ? null : launch.runtimeProvider ?? route?.provider ?? null;
715
720
  const client = this.#client;
716
721
  const key = `${providerId ?? "native"}/${route?.model ?? ""}`;
722
+ // Pin before ensureStarted: the first thread and turn otherwise inherit
723
+ // the native model captured at construction, even with a new provider.
724
+ if (route !== undefined) {
725
+ await client.setModel(route.provider, route.model);
726
+ }
717
727
  if (!client.spawned && client.threadId === null) {
718
728
  client.applyLaunch(launchSpec, providerId);
719
729
  await client.ensureStarted();
@@ -726,11 +736,6 @@ export class CodexAgent {
726
736
  else if (client.appliedProvider !== providerId) {
727
737
  await client.reattach(launchSpec, providerId, route?.model);
728
738
  }
729
- else if (route !== undefined) {
730
- await client.setModel(route.provider, route.model).catch((error) => {
731
- trace(`set_model ${key} failed: ${String(error)}`);
732
- });
733
- }
734
739
  this.#routeKey = key;
735
740
  }
736
741
  /**
@@ -1043,7 +1048,32 @@ export class CodexAgent {
1043
1048
  case "tool_execution_start": {
1044
1049
  const callId = String(event.toolCallId ?? "");
1045
1050
  const name = String(event.toolName ?? "");
1051
+ const argumentsJson = String(event.argumentsJson ?? JSON.stringify(event.args ?? {}));
1046
1052
  this.#commitStepStart();
1053
+ // Advertisement-before-execution (validator invariant, 2026-10-04 dev3
1054
+ // omp/hermes repair family): unless a real assistant frame advertised
1055
+ // this callId, bridge a minimal assistant/message carrying the
1056
+ // tool-call block — arguments copied verbatim into both frames. The
1057
+ // synthesized source mirrors #appendAssistantMessage's open-turn
1058
+ // fallback chain.
1059
+ if (!this.#advertisedToolCalls.has(callId)) {
1060
+ const advertisement = createAssistantMessage({
1061
+ content: [{ type: "tool-call", id: ToolCallId(callId), name, arguments: argumentsJson }],
1062
+ source: {
1063
+ provider: String(this.#turnRoute?.provider ?? this.options.provider ?? ""),
1064
+ model: String(this.#turnRoute?.model ?? this.options.model ?? ""),
1065
+ },
1066
+ });
1067
+ this.session.append("assistant/message", {
1068
+ turn: this.#dashTurn,
1069
+ step: this.#step,
1070
+ message: advertisement,
1071
+ stream: [],
1072
+ }, {
1073
+ surfaceOp: "append",
1074
+ });
1075
+ this.#advertisedToolCalls.add(callId);
1076
+ }
1047
1077
  this.session.append("tool/call", {
1048
1078
  turn: this.#dashTurn,
1049
1079
  step: this.#step,
@@ -1052,7 +1082,7 @@ export class CodexAgent {
1052
1082
  // The DSH payload requires the RAW JSON string the model produced;
1053
1083
  // the wire carries it verbatim for MCP calls and renders it from the
1054
1084
  // discrete fields for the other item shapes.
1055
- arguments: String(event.argumentsJson ?? JSON.stringify(event.args ?? {})),
1085
+ arguments: argumentsJson,
1056
1086
  });
1057
1087
  break;
1058
1088
  }
@@ -1164,6 +1194,10 @@ export class CodexAgent {
1164
1194
  }
1165
1195
  #appendAssistantMessage(message, usage) {
1166
1196
  const content = convertContent(message.content);
1197
+ for (const block of content) {
1198
+ if (block.type === "tool-call")
1199
+ this.#advertisedToolCalls.add(block.id);
1200
+ }
1167
1201
  const assistant = createAssistantMessage({
1168
1202
  content,
1169
1203
  source: {
@@ -440,11 +440,17 @@ export class CodexAppServerClient {
440
440
  // crash still fails the client.
441
441
  const generation = ++this.#transportGeneration;
442
442
  const launch = this.#launch;
443
+ // This is an independent Super D consumer, including when its parent
444
+ // happens to be a desktop task. The desktop host's attribution and
445
+ // tools pipe belong to that host, not to this new app-server.
446
+ const childEnv = { ...process.env, ...launch.env };
447
+ delete childEnv.CODEX_INTERNAL_ORIGINATOR_OVERRIDE;
448
+ delete childEnv.CODEX_APP_TOOLS_PIPE_PATH;
443
449
  transport = transportFactory({
444
450
  ...(this.#options.cwd === undefined ? {} : { cwd: this.#options.cwd }),
445
451
  ...(this.#options.requestTimeoutMs === undefined ? {} : { requestTimeoutMs: this.#options.requestTimeoutMs }),
446
452
  args: ["app-server", ...launch.argv],
447
- env: { ...process.env, ...launch.env },
453
+ env: childEnv,
448
454
  onNotification: (notification) => this.#onNotification(notification),
449
455
  onServerRequest: (request) => this.#onServerRequest(request),
450
456
  onExit: (info) => {
@@ -7,7 +7,7 @@
7
7
  * `{"id":…,"result":…}` / `{"id":…,"error":{…}}`).
8
8
  *
9
9
  * This module owns exactly four things and nothing else:
10
- * 1. executable resolution (pin-faithful: the package's own `@openai/codex`),
10
+ * 1. runtime selection (override → installed desktop → CLI → pinned package),
11
11
  * 2. process spawn + line framing + per-id request/response correlation,
12
12
  * 3. dispatch of server-initiated notifications and server-requests,
13
13
  * 4. teardown (process GROUP kill — the app-server may spawn stdio MCP
@@ -22,7 +22,8 @@
22
22
  * process, and the client calls it on first real work.
23
23
  */
24
24
  import { spawn } from "node:child_process";
25
- import { createRequire } from "node:module";
25
+ import { resolveCodexRuntime } from "./codex-runtime.js";
26
+ export { resolveCodexCommand, resolveCodexRuntime } from "./codex-runtime.js";
26
27
  /** Per-request protocol error surfaced by the server (JSON-RPC `error` object). */
27
28
  export class CodexWireError extends Error {
28
29
  code;
@@ -37,23 +38,6 @@ export class CodexWireError extends Error {
37
38
  /** Server overload: the documented retryable backpressure code. */
38
39
  export const SERVER_OVERLOADED_CODE = -32001;
39
40
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
40
- /** The codex entry script this package pins (`@openai/codex/bin/codex.js`). */
41
- export function resolveCodexCommand() {
42
- const override = process.env.CODEX_BIN;
43
- if (override !== undefined && override.trim() !== "")
44
- return override;
45
- const require = createRequire(import.meta.url);
46
- try {
47
- // Pin-faithful: the SAME `@openai/codex` this package depends on, so the
48
- // wire protocol and the binary version can never drift apart. Set
49
- // CODEX_BIN to override (tests).
50
- return require.resolve("@openai/codex/bin/codex.js");
51
- }
52
- catch (error) {
53
- throw new Error("cannot locate the pinned codex CLI (@openai/codex/bin/codex.js): "
54
- + `${error instanceof Error ? error.message : String(error)}. Install the package or set CODEX_BIN.`);
55
- }
56
- }
57
41
  /** True for a plain JSON object (never arrays/null). */
58
42
  function asRecord(value) {
59
43
  if (value === null || typeof value !== "object" || Array.isArray(value))
@@ -90,17 +74,21 @@ export class CodexAppServerTransport {
90
74
  start() {
91
75
  if (this.#child !== undefined)
92
76
  return;
93
- const command = this.#options.command ?? resolveCodexCommand();
77
+ const env = this.#options.env ?? { ...process.env };
78
+ const runtime = resolveCodexRuntime({
79
+ env: this.#options.command === undefined ? env : { ...env, CODEX_BIN: this.#options.command },
80
+ });
94
81
  const args = [...(this.#options.args ?? ["app-server"])];
95
82
  // `detached` gives the child its own process group so teardown can kill
96
83
  // the whole tree (stdio MCP descendants included), not just the root.
97
- const child = spawn(process.execPath, [command, ...args], {
84
+ const child = spawn(runtime.nodeScript ? process.execPath : runtime.command, runtime.nodeScript ? [runtime.command, ...args] : args, {
98
85
  cwd: this.#options.cwd,
99
- env: this.#options.env ?? { ...process.env },
86
+ env,
100
87
  stdio: ["pipe", "pipe", "pipe"],
101
88
  detached: true,
102
89
  });
103
90
  this.#child = child;
91
+ this.#options.onStderr?.(`codex runtime source=${runtime.source} command=${runtime.command} mode=${runtime.nodeScript ? "node" : "native"}\n`);
104
92
  child.stdout?.setEncoding("utf8");
105
93
  child.stderr?.setEncoding("utf8");
106
94
  child.stdout?.on("data", (chunk) => this.#onStdout(chunk));
@@ -1,35 +1,4 @@
1
- /**
2
- * codex-launch.ts — how a provider reaches a `codex app-server` child
3
- * (2026-10-04 on-the-fly rework).
4
- *
5
- * The retired design put the provider in force by REWRITING the user's
6
- * `~/.codex/config.toml` before the child started. That made the user's native
7
- * home the switchboard: every switch was a disk write, only ever affected the
8
- * next child, and fought every other tool that owns the same file (cc-switch's
9
- * whole-config rewrites, the user's own hand edits).
10
- *
11
- * This module replaces it with process arguments. `codex app-server -c
12
- * <dotted.path>=<value>` overrides any `config.toml` value for THAT child
13
- * alone (the flag's own help text: "Override a configuration value that would
14
- * otherwise be loaded from `~/.codex/config.toml`. Use a dotted path."), and
15
- * `ThreadStartParams`/`ThreadResumeParams` accept the same object as `config`.
16
- * A provider therefore costs nothing on disk and is scoped to one process, which
17
- * is exactly what a per-session provider choice needs.
18
- *
19
- * Measured constraints this module encodes (see
20
- * `docs/test-reports/2026-10-03-codex-provider-model-switch-probe.md`):
21
- *
22
- * - a HYPHENATED id is fine in a dotted path (`model_providers.zhipu-cn.…`),
23
- * so native table names survive verbatim as provider ids;
24
- * - `model_catalog_json` is resolved against the process CWD when it arrives
25
- * as an override, so the path is always absolute;
26
- * - `env_key` is the one auth shape that never consults the auth manager, so a
27
- * stored API key rides the CHILD's environment under a synthetic variable
28
- * name. That is also what makes a keyed provider usable behind a ChatGPT
29
- * login, where `requires_openai_auth` is silently shadowed.
30
- *
31
- * @module agent-codex/codex-launch
32
- */
1
+ import { officialCatalogForLaunch } from "./codex-oauth.js";
33
2
  import { tomlString, writeProviderCatalog } from "./codex-native-config.js";
34
3
  /** The environment variable prefix for a key this adapter injects itself. */
35
4
  const SYNTHETIC_KEY_PREFIX = "SUPERD_CODEX_KEY_";
@@ -48,6 +17,15 @@ export function syntheticKeyName(providerId) {
48
17
  * @returns the argv/env additions and the provider id they pin.
49
18
  */
50
19
  export function providerLaunch(worldHome, codexHome, provider) {
20
+ if (provider.kind === "openai-oauth") {
21
+ const catalog = officialCatalogForLaunch(codexHome);
22
+ // Empty override is filtered to None by Codex, restoring its builtin
23
+ // endpoint without changing the user's native configuration.
24
+ const argv = ["-c", 'model_provider="openai"', "-c", 'openai_base_url=""'];
25
+ if (catalog !== undefined)
26
+ argv.push("-c", `model_catalog_json=${tomlString(catalog)}`);
27
+ return { argv, env: {}, runtimeProvider: "openai" };
28
+ }
51
29
  const table = `model_providers.${provider.id}`;
52
30
  const argv = [
53
31
  "-c", `${table}.name=${tomlString(provider.label)}`,
@@ -95,9 +95,10 @@ function slugOf(entry) {
95
95
  * that we do not already hold, then upserts `models` by slug. Each upserted
96
96
  * entry is a CLONE of `template` — an existing entry for the same slug if there
97
97
  * is one, else the caller's template, else the first entry either side supplied
98
- * — with only the identity fields overridden. The other ~35 keys are Codex's
99
- * and are copied through untouched; see the plan's D5 for why we do not try to
100
- * synthesize them.
98
+ * — with identity and declared context window overridden. The other metadata
99
+ * is copied through except `comp_hash`: this optional opaque compatibility
100
+ * identifier belongs to the template's model, not an unrelated provider model.
101
+ * See the plan's D5 for why we do not synthesize the remaining fields.
101
102
  */
102
103
  export function mergeModelCatalog(existing, absorbed, models, template) {
103
104
  // Both sides are a TEMPLATE POOL only — never output. What gets emitted is
@@ -132,6 +133,11 @@ export function mergeModelCatalog(existing, absorbed, models, template) {
132
133
  slug: model.id,
133
134
  display_name: model.displayName ?? model.id,
134
135
  };
136
+ // A copied OpenAI hash falsely triggers previous-model compaction on
137
+ // provider switches in Codex 0.160. Our provider model declarations do not
138
+ // carry verified hashes; remove stale generated values too. Context-limit
139
+ // compaction and its window/threshold metadata remain unchanged.
140
+ delete entry["comp_hash"];
135
141
  if (model.contextWindow !== undefined) {
136
142
  // Codex reads context_window and caps against max_context_window; a stale
137
143
  // template value must not silently cap a larger real window.
@@ -0,0 +1,153 @@
1
+ /** Native OAuth discovery. Tokens stay in Codex's own home; no network/spawn. */
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { readNativeSelection } from './codex-native-config.js';
5
+ import { readProviderStore } from './codex-providers.js';
6
+ export const OPENAI_OAUTH_ID = 'openai-oauth';
7
+ export function hasOpenAiOAuth(codexHome) {
8
+ try {
9
+ const value = JSON.parse(readFileSync(join(codexHome, 'auth.json'), 'utf8'));
10
+ const tokens = value?.tokens;
11
+ return value?.auth_mode === 'chatgpt' && tokens !== null && typeof tokens === 'object'
12
+ && [tokens.access_token, tokens.refresh_token].some(token => typeof token === 'string' && token.trim() !== '');
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ function isRecord(value) {
19
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
20
+ }
21
+ function nonEmpty(value) {
22
+ return typeof value === 'string' && value.trim() !== '';
23
+ }
24
+ function oneOf(value, choices) {
25
+ return typeof value === 'string' && choices.includes(value);
26
+ }
27
+ /**
28
+ * The non-defaulted ModelInfo fields consumed by Codex, including its required
29
+ * nested metadata. Keep unknown native fields untouched; never repair or
30
+ * synthesize a catalog. Source: codex-rs/protocol/src/openai_models.rs (0.154 / 0.160).
31
+ */
32
+ function usableModel(value) {
33
+ if (!isRecord(value) || !nonEmpty(value['slug']) || typeof value['display_name'] !== 'string')
34
+ return false;
35
+ const reasoning = value['supported_reasoning_levels'];
36
+ const truncation = value['truncation_policy'];
37
+ const messages = value['model_messages'];
38
+ if (typeof value['base_instructions'] !== 'string' && !(isRecord(messages) && typeof messages['instructions_template'] === 'string'))
39
+ return false;
40
+ return Array.isArray(reasoning) && reasoning.every(level => isRecord(level) && nonEmpty(level['effort']) && typeof level['description'] === 'string')
41
+ && oneOf(value['shell_type'], ['unified_exec', 'default', 'local', 'shell_command', 'disabled'])
42
+ && oneOf(value['visibility'], ['list', 'hide', 'none'])
43
+ && typeof value['supported_in_api'] === 'boolean'
44
+ && Number.isSafeInteger(value['priority']) && Number(value['priority']) >= -2147483648 && Number(value['priority']) <= 2147483647
45
+ && typeof value['support_verbosity'] === 'boolean'
46
+ && isRecord(truncation) && oneOf(truncation['mode'], ['tokens', 'bytes']) && Number.isSafeInteger(truncation['limit'])
47
+ && Array.isArray(value['experimental_supported_tools']) && value['experimental_supported_tools'].every(tool => typeof tool === 'string');
48
+ }
49
+ /** One bad entry invalidates the file: Codex deserializes the whole catalog. */
50
+ function officialCatalogModels(codexHome) {
51
+ try {
52
+ const cache = JSON.parse(readFileSync(join(codexHome, 'models_cache.json'), 'utf8'));
53
+ if (!isRecord(cache))
54
+ return undefined;
55
+ const models = cache['models'];
56
+ if (Array.isArray(models) && models.length > 0 && models.every(usableModel))
57
+ return models;
58
+ }
59
+ catch { /* Missing/stale cache must not become a forced catalog override. */ }
60
+ return undefined;
61
+ }
62
+ /**
63
+ * Conservatively detect configuration that would leak into an official child
64
+ * without a cache override. Inspect all tables: a profile can supply a catalog
65
+ * even when the root has none. Accept indentation, quoted and dotted keys. We
66
+ * only need to know whether a conflicting override exists, not parse its value.
67
+ */
68
+ function nativeAssignments(codexHome) {
69
+ let text;
70
+ try {
71
+ text = readFileSync(join(codexHome, 'config.toml'), 'utf8');
72
+ }
73
+ catch {
74
+ return [];
75
+ }
76
+ const assignments = [];
77
+ for (const line of text.split('\n')) {
78
+ const assignment = /^\s*((?:[\w-]+|"[^"\n]+"|'[^'\n]+')(?:\s*\.\s*(?:[\w-]+|"[^"\n]+"|'[^'\n]+'))*)\s*=\s*(.*)$/.exec(line);
79
+ if (assignment === null)
80
+ continue;
81
+ const key = assignment[1].split(/\s*\.\s*/).at(-1)?.replace(/^["']|["']$/g, '');
82
+ assignments.push({ key, value: assignment[2] });
83
+ }
84
+ return assignments;
85
+ }
86
+ function nativeCatalogConflict(codexHome) {
87
+ return nativeAssignments(codexHome).some(({ key, value }) => key === 'model_catalog_json' || key === 'profile'
88
+ || (key === 'model_provider' && !/^(?:"openai"|'openai')\s*(?:#.*)?$/.test(value)));
89
+ }
90
+ /** Official runtime cache only: never consume a third-party custom catalog. */
91
+ export function readOpenAiModels(codexHome) {
92
+ const models = [];
93
+ for (const raw of officialCatalogModels(codexHome) ?? []) {
94
+ if (raw['visibility'] !== 'list')
95
+ continue;
96
+ const slug = raw['slug'];
97
+ if (models.some(model => model.id === slug))
98
+ continue;
99
+ const displayName = raw['display_name'];
100
+ const contextWindow = raw['context_window'];
101
+ models.push({ id: slug,
102
+ ...(nonEmpty(displayName) ? { displayName } : {}),
103
+ ...(Number.isSafeInteger(contextWindow) && Number(contextWindow) > 0 ? { contextWindow: Number(contextWindow) } : {}),
104
+ });
105
+ }
106
+ // A different binary can leave a valid, older catalog in the shared home.
107
+ // Preserve the user's official configured choice as an identity, without
108
+ // inventing metadata or claiming that this cache/binary supports that model.
109
+ if (!nativeCatalogConflict(codexHome)) {
110
+ const native = readNativeSelection(codexHome);
111
+ if ((native.providerId === undefined || native.providerId === 'openai') && nonEmpty(native.model)
112
+ && !models.some(model => model.id === native.model))
113
+ models.push({ id: native.model });
114
+ }
115
+ return models;
116
+ }
117
+ /** Internal catalog shape; disk serialization stores just its native identity. */
118
+ export function openAiOAuthEntry(codexHome) {
119
+ return { id: OPENAI_OAUTH_ID, label: 'OpenAI OAuth', kind: 'openai-oauth', origin: 'native',
120
+ baseUrl: '', protocol: 'responses', auth: { kind: 'apiKey', value: '' }, models: readOpenAiModels(codexHome) };
121
+ }
122
+ export function readEffectiveProviderStore(worldHome, codexHome) {
123
+ const store = readProviderStore(worldHome);
124
+ const aliasEntries = store.providers.filter(entry => entry.id === OPENAI_OAUTH_ID);
125
+ const ambiguous = aliasEntries.length > 1;
126
+ const blocked = store.blockedNativeIds?.includes(OPENAI_OAUTH_ID) === true;
127
+ return { ...store, providers: store.providers.flatMap(entry => entry.id === OPENAI_OAUTH_ID && ambiguous ? [] : entry.kind === 'openai-oauth'
128
+ ? !blocked && hasOpenAiOAuth(codexHome) ? [openAiOAuthEntry(codexHome)] : [] : [entry]) };
129
+ }
130
+ /** A usable runtime-owned catalog path, excluding malformed/empty cache files. */
131
+ export function openAiCatalogPath(codexHome) {
132
+ return officialCatalogModels(codexHome) === undefined ? undefined : join(codexHome, 'models_cache.json');
133
+ }
134
+ /** Do not let an official child inherit a third-party model_catalog_json. */
135
+ export function officialCatalogForLaunch(codexHome) {
136
+ if (!hasOpenAiOAuth(codexHome))
137
+ throw new Error('OpenAI OAuth authentication is unavailable; sign in using Codex');
138
+ if (nativeAssignments(codexHome).some(({ key, value }) => key === 'forced_login_method' && !/^(?:"chatgpt"|'chatgpt')\s*(?:#.*)?$/.test(value))) {
139
+ throw new Error('OpenAI OAuth cannot use a conflicting forced_login_method; choose ChatGPT login in your Codex configuration');
140
+ }
141
+ // A forced catalog is static in Codex and disables version/identity/TTL
142
+ // refresh. Let the selected binary own discovery when native config is safe;
143
+ // only override a conflicting native catalog with the validated official one.
144
+ if (!nativeCatalogConflict(codexHome))
145
+ return undefined;
146
+ const catalog = openAiCatalogPath(codexHome);
147
+ if (catalog !== undefined)
148
+ return catalog;
149
+ const native = readNativeSelection(codexHome);
150
+ if (nativeCatalogConflict(codexHome) || (native.providerId !== undefined && native.providerId !== 'openai'))
151
+ throw new Error('OpenAI OAuth model cache unavailable; open Codex with the official provider to refresh its models');
152
+ return undefined;
153
+ }