@bitkyc08/opencodex 2.67.0 → 2.68.0

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 (257) hide show
  1. package/bin/ocx.mjs +3 -0
  2. package/gui/dist/assets/App-DCBismRi.css +1 -0
  3. package/gui/dist/assets/App-mpsibsFj.js +51 -0
  4. package/gui/dist/assets/Tray-Bn8WhKBH.js +1 -0
  5. package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
  6. package/gui/dist/assets/index-kUE8tuAK.js +86 -0
  7. package/gui/dist/assets/quota-summary-DL-bNuNL.js +1 -0
  8. package/gui/dist/index.html +21 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-image-guard.ts +13 -1
  11. package/src/adapters/anthropic-image-normalize.ts +3 -2
  12. package/src/adapters/anthropic-output-schema.ts +36 -0
  13. package/src/adapters/anthropic.ts +10 -2
  14. package/src/adapters/base.ts +7 -0
  15. package/src/adapters/codebuddy/live-models.ts +180 -0
  16. package/src/adapters/coding-agent/protocol.ts +91 -12
  17. package/src/adapters/coding-agent/turn.ts +32 -27
  18. package/src/adapters/devin/cloud-direct/index.ts +1 -0
  19. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
  20. package/src/adapters/devin.ts +9 -6
  21. package/src/adapters/google-http.ts +4 -4
  22. package/src/adapters/google.ts +93 -3
  23. package/src/adapters/kiro/adapter.ts +6 -2
  24. package/src/adapters/kiro/stream.ts +12 -2
  25. package/src/adapters/kiro/usage.ts +3 -1
  26. package/src/adapters/kiro-errors.ts +11 -1
  27. package/src/adapters/kiro-events.ts +27 -1
  28. package/src/adapters/kiro-refusal.ts +30 -0
  29. package/src/adapters/kiro-retry.ts +71 -31
  30. package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
  31. package/src/adapters/openai-chat/messages.ts +31 -4
  32. package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
  33. package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
  34. package/src/adapters/openai-chat/tool-schema.ts +6 -2
  35. package/src/adapters/openai-chat.ts +2 -2
  36. package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
  37. package/src/adapters/openai-responses/passthrough.ts +9 -3
  38. package/src/adapters/opencode-go-additional-tools.ts +19 -10
  39. package/src/adapters/physical-send.ts +10 -3
  40. package/src/adapters/registry.ts +2 -1
  41. package/src/adapters/responses-tool-schema.ts +31 -3
  42. package/src/adapters/run-turn-queue.ts +63 -23
  43. package/src/adapters/unique-tool-call-ids.ts +63 -0
  44. package/src/adapters/xai-web-search.ts +32 -2
  45. package/src/bridge/sse.ts +14 -2
  46. package/src/chat/inbound.ts +20 -6
  47. package/src/claude/agents-inject.ts +4 -0
  48. package/src/claude/desktop-3p.ts +53 -13
  49. package/src/claude/desktop-profile.ts +41 -4
  50. package/src/claude/inbound-model-options.ts +14 -2
  51. package/src/claude/inbound.ts +1 -1
  52. package/src/claude/intercept/connect-proxy.ts +17 -1
  53. package/src/claude/intercept/local-ca.ts +7 -1
  54. package/src/claude/message-threads.ts +28 -0
  55. package/src/cli/account-api.ts +13 -0
  56. package/src/cli/account-auth.ts +49 -2
  57. package/src/cli/account-extended.ts +4 -2
  58. package/src/cli/account.ts +4 -2
  59. package/src/cli/capabilities.ts +17 -0
  60. package/src/cli/claude.ts +3 -1
  61. package/src/cli/dispatch.ts +62 -5
  62. package/src/cli/index.ts +84 -79
  63. package/src/cli/minimax.ts +4 -2
  64. package/src/cli/opencode.ts +6 -1
  65. package/src/cli/restart-handoff.ts +110 -0
  66. package/src/cli/status.ts +51 -0
  67. package/src/client/connect.ts +34 -15
  68. package/src/client/link-ingress.ts +102 -0
  69. package/src/client/link-join.ts +37 -16
  70. package/src/client/link-relay.ts +228 -45
  71. package/src/client/link-state.ts +54 -0
  72. package/src/client/link-status.ts +39 -0
  73. package/src/client/link-teardown.ts +2 -2
  74. package/src/client/link-tunnel.ts +615 -86
  75. package/src/client/machine-api.ts +10 -4
  76. package/src/client/machine-listener.ts +57 -10
  77. package/src/client/runtime.ts +210 -35
  78. package/src/clients/config-export/constants.ts +1 -12
  79. package/src/clients/config-export/contracts.ts +2 -0
  80. package/src/clients/config-export/model-metadata.ts +9 -3
  81. package/src/clients/config-export/omp.ts +1 -1
  82. package/src/clients/config-export/zcode-store.ts +2 -2
  83. package/src/clients/config-export.ts +36 -3
  84. package/src/codex/app-server-processes.ts +32 -0
  85. package/src/codex/app-server-restart-service.ts +20 -3
  86. package/src/codex/catalog/access-programs.ts +50 -0
  87. package/src/codex/catalog/build-entries.ts +13 -0
  88. package/src/codex/catalog/derive-entry.ts +1 -0
  89. package/src/codex/catalog/model-hints.ts +11 -0
  90. package/src/codex/catalog/parsing.ts +6 -2
  91. package/src/codex/catalog/provider-models.ts +81 -0
  92. package/src/codex/catalog/retained-sync.ts +3 -0
  93. package/src/codex/codex-write-lock.ts +2 -2
  94. package/src/codex/convergence.ts +2 -0
  95. package/src/codex/desired-state.ts +14 -4
  96. package/src/codex/home.ts +21 -3
  97. package/src/codex/inject/restore.ts +17 -0
  98. package/src/codex/inject/routing-classify.ts +3 -2
  99. package/src/codex/inject-coordination.ts +4 -1
  100. package/src/codex/inject.ts +8 -1
  101. package/src/codex/journal.ts +6 -2
  102. package/src/codex/management-convergence.ts +9 -0
  103. package/src/codex/model-entitlements.ts +28 -5
  104. package/src/codex/routing/idle-window.ts +58 -0
  105. package/src/codex/routing-drift.ts +134 -0
  106. package/src/codex/routing-healer.ts +419 -0
  107. package/src/codex/routing.ts +10 -0
  108. package/src/codex/runtime.ts +188 -57
  109. package/src/codex/sibling-handoff.ts +71 -0
  110. package/src/codex/sibling-start.ts +139 -0
  111. package/src/codex/sync.ts +5 -3
  112. package/src/combos/failover.ts +32 -5
  113. package/src/combos/request.ts +11 -3
  114. package/src/combos/reset-window.ts +10 -5
  115. package/src/combos/resolve.ts +36 -6
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/load-degrade.ts +2 -0
  118. package/src/config/paths.ts +7 -1
  119. package/src/config/process-state.ts +10 -1
  120. package/src/config/schema/compaction-recovery.ts +15 -0
  121. package/src/config/schema/config-schema.ts +2 -0
  122. package/src/config/schema/leaf-validators.ts +9 -3
  123. package/src/generated/compatibility-version.json +360 -204
  124. package/src/images/loop.ts +47 -32
  125. package/src/integrations/catalog-refresh.ts +4 -0
  126. package/src/integrations/omp-yaml-source.ts +1 -0
  127. package/src/lib/claude-request-projection.ts +101 -0
  128. package/src/lib/local-account-switch-capability.ts +48 -0
  129. package/src/lib/local-upstream.ts +146 -0
  130. package/src/lib/package-tree-integrity.ts +25 -3
  131. package/src/lib/package-tree-retarget.ts +130 -0
  132. package/src/lib/process-control.ts +4 -1
  133. package/src/lib/system-restart-contract.ts +106 -0
  134. package/src/lib/upstream-retry.ts +57 -1
  135. package/src/link/ports.ts +9 -0
  136. package/src/link/ssh-argv.ts +16 -0
  137. package/src/link/ssh-runner.ts +70 -2
  138. package/src/link/tunnel-state.ts +58 -9
  139. package/src/oauth/account-quota-rank.ts +29 -18
  140. package/src/oauth/generic-account-failover.ts +179 -22
  141. package/src/oauth/index.ts +17 -4
  142. package/src/oauth/kiro-account-load.ts +107 -0
  143. package/src/oauth/kiro-device-login.ts +312 -0
  144. package/src/oauth/kiro-terminal-failover.ts +28 -0
  145. package/src/oauth/login-flow-state.ts +7 -1
  146. package/src/oauth/pool-settings-capability.ts +23 -14
  147. package/src/oauth/store.ts +56 -3
  148. package/src/oauth/types.ts +4 -0
  149. package/src/plugins/loader.ts +350 -0
  150. package/src/plugins/upstream-hooks.ts +119 -0
  151. package/src/protocols/encoders/adapter-events.ts +9 -2
  152. package/src/providers/account-quota-disk.ts +42 -4
  153. package/src/providers/codebuddy-models.ts +6 -2
  154. package/src/providers/command-code-efforts.ts +33 -10
  155. package/src/providers/kiro-account-state-disk.ts +44 -0
  156. package/src/providers/kiro-model-catalog.ts +162 -0
  157. package/src/providers/kiro-models.ts +5 -4
  158. package/src/providers/kiro-quota-metrics.ts +35 -0
  159. package/src/providers/kiro-usage.ts +118 -14
  160. package/src/providers/quota/account-cache.ts +43 -7
  161. package/src/providers/quota/antigravity.ts +8 -3
  162. package/src/providers/quota/kiro-account-probe.ts +12 -0
  163. package/src/providers/quota/vendor-probes-key.ts +31 -26
  164. package/src/providers/quota/vendor-probes-oauth.ts +16 -7
  165. package/src/providers/quota-types.ts +14 -0
  166. package/src/providers/quota.ts +21 -21
  167. package/src/providers/registry/entries-extended.ts +5 -3
  168. package/src/providers/request-pacing.ts +410 -12
  169. package/src/remote-control/workspace-codex-runtime.ts +4 -3
  170. package/src/responses/citation-markers.ts +132 -65
  171. package/src/responses/hosted-tool-policy.ts +14 -3
  172. package/src/responses/parser-content.ts +3 -2
  173. package/src/responses/parser.ts +4 -1
  174. package/src/responses/schema.ts +4 -0
  175. package/src/responses/visualization-directives.ts +182 -0
  176. package/src/server/chat-native-sse.ts +24 -12
  177. package/src/server/chat-native.ts +137 -68
  178. package/src/server/claude-messages.ts +147 -12
  179. package/src/server/index/link-listener.ts +7 -0
  180. package/src/server/index/optional-listeners.ts +4 -1
  181. package/src/server/index/package-tree-guard.ts +35 -16
  182. package/src/server/index/serve-options.ts +4 -2
  183. package/src/server/index/startup-warnings.ts +6 -0
  184. package/src/server/index.ts +7 -7
  185. package/src/server/inference/client-encoder-delivery.ts +3 -0
  186. package/src/server/inference/context.ts +30 -2
  187. package/src/server/local-account-switch-auth.ts +79 -0
  188. package/src/server/management/agent-settings-routes.ts +11 -3
  189. package/src/server/management/config-routes.ts +28 -11
  190. package/src/server/management/context.ts +2 -0
  191. package/src/server/management/link-routes.ts +123 -39
  192. package/src/server/management/logs-usage-routes.ts +9 -42
  193. package/src/server/management/model-rows.ts +1 -0
  194. package/src/server/management/oauth-account-routes.ts +67 -12
  195. package/src/server/management/route-registry.ts +5 -5
  196. package/src/server/management/sibling-guard.ts +60 -0
  197. package/src/server/management/storage-log-guard-routes.ts +72 -6
  198. package/src/server/management/system-restart.ts +92 -135
  199. package/src/server/management/system-routes.ts +1 -1
  200. package/src/server/management-api.ts +31 -3
  201. package/src/server/management-auth.ts +3 -0
  202. package/src/server/port-reclaim.ts +111 -1
  203. package/src/server/proxy-liveness.ts +16 -0
  204. package/src/server/relay.ts +16 -15
  205. package/src/server/request-log-filter.ts +28 -27
  206. package/src/server/request-log.ts +7 -3
  207. package/src/server/request-metrics.ts +28 -0
  208. package/src/server/responses/adapter-continuation.ts +151 -21
  209. package/src/server/responses/adapter-delivery.ts +21 -7
  210. package/src/server/responses/adapter-dispatch.ts +239 -51
  211. package/src/server/responses/codex-ws-pool.ts +5 -4
  212. package/src/server/responses/codex-ws-request.ts +4 -1
  213. package/src/server/responses/compact.ts +8 -4
  214. package/src/server/responses/compaction-recovery-policy.ts +116 -0
  215. package/src/server/responses/compaction-recovery.ts +330 -0
  216. package/src/server/responses/core-combo-failure.ts +1 -0
  217. package/src/server/responses/core-combo.ts +5 -1
  218. package/src/server/responses/core-lifetime.ts +22 -0
  219. package/src/server/responses/core-options.ts +16 -1
  220. package/src/server/responses/core.ts +14 -4
  221. package/src/server/responses/empty-completion-guard.ts +2 -0
  222. package/src/server/responses/fetch-helpers.ts +82 -31
  223. package/src/server/responses/passthrough-delivery.ts +3 -2
  224. package/src/server/responses/passthrough-dispatch.ts +12 -2
  225. package/src/server/responses/passthrough-execution.ts +2 -2
  226. package/src/server/responses/request-send-budget.ts +21 -5
  227. package/src/server/responses/request-transport.ts +116 -18
  228. package/src/server/responses/reset-replay.ts +32 -0
  229. package/src/server/responses/run-turn-execution.ts +136 -19
  230. package/src/server/responses/sidecar-execution.ts +41 -8
  231. package/src/server/responses/terminal-guard.ts +2 -0
  232. package/src/server/responses/ws-upstream.ts +44 -4
  233. package/src/server/restart-replacement.ts +382 -0
  234. package/src/server/startup-health-cache.ts +23 -4
  235. package/src/server/stop-teardown.ts +35 -1
  236. package/src/server/system-env.ts +3 -0
  237. package/src/service/windows-taskxml.ts +11 -7
  238. package/src/service/windows-wrapper-exit.ts +8 -0
  239. package/src/stall-timeout.ts +35 -10
  240. package/src/storage/policy-job.ts +4 -0
  241. package/src/storage/scanner.ts +177 -29
  242. package/src/storage/storage-mutation-coordinator.ts +17 -0
  243. package/src/tray/windows-tray.ps1 +50 -23
  244. package/src/types/config.ts +4 -0
  245. package/src/types/provider.ts +5 -1
  246. package/src/types/request.ts +13 -2
  247. package/src/update/index.ts +3 -1
  248. package/src/update/job.ts +6 -2
  249. package/src/update/mise-launcher-target.ts +100 -0
  250. package/src/update/worker-launch.ts +46 -0
  251. package/src/usage/log.ts +2 -0
  252. package/src/web-search/loop.ts +5 -2
  253. package/gui/dist/assets/App-BJsT8Icc.css +0 -1
  254. package/gui/dist/assets/App-D3pNGiN4.js +0 -51
  255. package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
  256. package/gui/dist/assets/index-CouvvtMV.js +0 -86
  257. package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Local plugin loader.
3
+ *
4
+ * `ocx start` imports every `*.ts`, `*.js` and `*.mjs` file in `$OPENCODEX_HOME/plugins/`
5
+ * before the server binds, so a plugin's hooks are in place for the first request. A missing
6
+ * directory means no plugins and no work. `OCX_PLUGINS=0` disables loading for one start.
7
+ *
8
+ * A plugin is a module whose default export is `{ name?, setup(ctx) }`. It runs in the proxy
9
+ * process with the operator's credentials, so the loader accepts only files owned by the
10
+ * current user that no other user can write — the same trust boundary as `config.json`.
11
+ * Windows auto-loading stays disabled until this trust check is backed by an ACL check.
12
+ * Plugins cannot import ocx internals (a compiled binary keeps them inside `$bunfs`); they
13
+ * receive everything they may use through `OcxPluginContext`.
14
+ *
15
+ * Failures are contained: a plugin that throws, times out or has the wrong shape is reported
16
+ * and skipped, its context stops accepting registrations, and the remaining plugins and the
17
+ * proxy start normally. The setup deadline bounds setup that yields to the event loop; plugins
18
+ * run in the proxy's own thread, so synchronous work that never yields cannot be interrupted.
19
+ */
20
+
21
+ import { lstatSync, readdirSync, realpathSync } from "node:fs";
22
+ import { basename, dirname, join } from "node:path";
23
+ import { pathToFileURL } from "node:url";
24
+ import { spawnSync } from "node:child_process";
25
+ import { userInfo } from "node:os";
26
+ import { getConfigDir } from "../config/paths";
27
+ import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks";
28
+ import { registerUpstreamRewriter, type UpstreamRewriter } from "./upstream-hooks";
29
+
30
+ export type { UpstreamRewriter, UpstreamTarget, UpstreamTransport } from "./upstream-hooks";
31
+
32
+ export interface OcxPluginContext {
33
+ /** The plugin's own name, as reported in logs. */
34
+ readonly name: string;
35
+ /** `$OPENCODEX_HOME`, for plugins that keep state next to the proxy's. */
36
+ readonly configDir: string;
37
+ /** `$OPENCODEX_HOME/plugins`. */
38
+ readonly pluginDir: string;
39
+ log(message: string): void;
40
+ /** See `src/plugins/upstream-hooks.ts`. Called synchronously on every provider send. */
41
+ registerUpstreamRewriter(rewrite: UpstreamRewriter): void;
42
+ /** Runs once when the proxy shuts down. Must not throw or block. */
43
+ onShutdown(teardown: () => void): void;
44
+ }
45
+
46
+ export interface OcxPlugin {
47
+ name?: string;
48
+ setup(context: OcxPluginContext): void | Promise<void>;
49
+ }
50
+
51
+ export interface PluginLoadResult {
52
+ file: string;
53
+ name: string;
54
+ loaded: boolean;
55
+ error?: "windows_auto_load_disabled" | "directory_read_failed" | "directory_resolution_failed"
56
+ | "directory_untrusted" | "ancestor_untrusted" | "file_untrusted"
57
+ | "import_failed" | "invalid_export" | "setup_failed" | "setup_timeout";
58
+ }
59
+
60
+ const PLUGIN_EXTENSIONS = [".ts", ".js", ".mjs"];
61
+ const SETUP_TIMEOUT_MS = 5_000;
62
+ const ACL_PROBE_TIMEOUT_MS = 2_000;
63
+
64
+ const MAC_ACL_BENIGN_TOKENS = new Set([
65
+ "read", "list", "search", "execute", "readattr", "readextattr", "readsecurity",
66
+ "file_inherit", "directory_inherit", "limit_inherit", "only_inherit",
67
+ ]);
68
+
69
+ /**
70
+ * A deny, trusted-user grant, or read-only ACL does not let another user replace a checked path.
71
+ * `only_inherit` is not effective on this object; inherited grants on descendants are checked
72
+ * when those descendants are visited. Unknown allow rights and malformed entries fail closed.
73
+ */
74
+ export function macAclListingTrustError(listing: string, currentUser = userInfo().username): string | null {
75
+ const [header, ...lines] = listing.split("\n");
76
+ const owner = /^\S+\s+\d+\s+(\S+)\s+/.exec(header ?? "")?.[1];
77
+ let unparseable = !owner;
78
+ for (const line of lines) {
79
+ if (!/^\s*\d+:/.test(line)) continue;
80
+ const entry = /^\s*\d+:\s+(.+?)\s+(?:inherited\s+)?(allow|deny)\s+([a-z_,]+)\s*$/.exec(line);
81
+ if (!entry) { unparseable = true; continue; }
82
+ if (entry[2] === "deny") continue;
83
+ const principal = entry[1]!;
84
+ // The file owner, this process's user, and root already control the path without an ACE.
85
+ // Require the `user:` prefix: a bare or group principal might include other users.
86
+ if (principal.startsWith("user:")
87
+ && [owner, currentUser, "root", "0"].includes(principal.slice(5))) continue;
88
+ const rights = entry[3]!.split(",");
89
+ if (rights.includes("only_inherit")) continue;
90
+ if (rights.some(right => !MAC_ACL_BENIGN_TOKENS.has(right))) return "has an access control list";
91
+ }
92
+ return unparseable ? "access control list inspection failed" : null;
93
+ }
94
+
95
+ interface MacAclProbeResult {
96
+ status: number | null;
97
+ stdout: string;
98
+ error?: Error;
99
+ }
100
+
101
+ /** A failed inspection never grants trust, even if a subsequent probe is attempted. */
102
+ export function macAclProbeTrustError(probe: () => MacAclProbeResult): string | null {
103
+ try {
104
+ const first = probe();
105
+ // A timeout may carry partial stdout. Its unsafe ACEs are authoritative even if a retry
106
+ // would be clean; any other partial listing is incomplete and therefore also refused.
107
+ // Retry only when the first probe produced no ACL evidence at all.
108
+ let result = first;
109
+ if ((first.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT") {
110
+ if (first.stdout.trim()) {
111
+ return macAclListingTrustError(first.stdout) ?? "access control list inspection failed";
112
+ }
113
+ result = probe();
114
+ }
115
+ if (result.error || result.status !== 0) return "access control list inspection failed";
116
+ return macAclListingTrustError(result.stdout);
117
+ } catch {
118
+ return "access control list inspection failed";
119
+ }
120
+ }
121
+
122
+ /** Refuse extended ACLs: mode bits alone cannot prove who can rewrite a plugin path. */
123
+ function aclTrustError(path: string): string | null {
124
+ if (process.platform !== "darwin" && process.platform !== "linux") return null;
125
+ const mac = process.platform === "darwin";
126
+ // CI and operator PATHs may put GNU coreutils ahead of the macOS tool.
127
+ // GNU ls does not support -e, so pin the OS ACL inspector.
128
+ const probe = () => spawnSync(mac ? "/bin/ls" : "getfacl", mac
129
+ ? ["-lebd", "--", path] : ["-cp", "--", path], {
130
+ encoding: "utf8",
131
+ timeout: ACL_PROBE_TIMEOUT_MS,
132
+ maxBuffer: 64 * 1024,
133
+ env: { ...process.env, LC_ALL: "C" },
134
+ });
135
+ if (mac) return macAclProbeTrustError(probe);
136
+ const result = probe();
137
+ // getfacl is optional on Linux. Without it, the POSIX owner/mode checks below remain;
138
+ // the documented Linux ACL limitation must be visible to operators.
139
+ if ((result.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return null;
140
+ if (result.error || result.status !== 0) return "access control list inspection failed";
141
+ return result.stdout.split("\n").some(line => /^(?:user:[^:]+:|group:[^:]+:|mask::|default:)/.test(line))
142
+ ? "has an access control list" : null;
143
+ }
144
+
145
+ export function pluginDirectory(): string {
146
+ return join(getConfigDir(), "plugins");
147
+ }
148
+
149
+ /** A missing directory is "no plugins"; any other read failure propagates to be reported. */
150
+ function listPluginFiles(dir: string): string[] {
151
+ let entries: string[];
152
+ try {
153
+ entries = readdirSync(dir);
154
+ } catch (error) {
155
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
156
+ throw error;
157
+ }
158
+ return entries
159
+ .filter(entry => !entry.startsWith(".") && !entry.startsWith("_") && !entry.endsWith(".d.ts"))
160
+ .filter(entry => PLUGIN_EXTENSIONS.some(extension => entry.endsWith(extension)))
161
+ .sort()
162
+ .map(entry => join(dir, entry));
163
+ }
164
+
165
+ /**
166
+ * Null when `path` is safe to trust, otherwise the reason it is refused. `lstat` is used so a
167
+ * symbolic link is judged as a link — and refused — rather than as the file it points to: a
168
+ * link to a file you own would otherwise pass the owner and mode checks. Windows has no
169
+ * POSIX owner or mode bits; the loader refuses execution on that platform.
170
+ */
171
+ function trustError(path: string, kind: "file" | "directory"): string | null {
172
+ let stats: ReturnType<typeof lstatSync>;
173
+ try {
174
+ stats = lstatSync(path);
175
+ } catch {
176
+ return "filesystem inspection failed";
177
+ }
178
+ if (stats.isSymbolicLink()) return "is a symbolic link";
179
+ if (kind === "file" ? !stats.isFile() : !stats.isDirectory()) return `not a regular ${kind}`;
180
+ if (process.platform === "win32") return null;
181
+ const uid = process.getuid?.();
182
+ if (uid !== undefined && stats.uid !== uid) return "owned by another user";
183
+ // A POSIX ACL write mask may also set group-write mode bits. Inspect the ACL first so
184
+ // the extended grant is explicitly refused even when the mode check would also refuse it.
185
+ const aclError = aclTrustError(path);
186
+ if (aclError) return aclError;
187
+ if ((stats.mode & 0o022) !== 0) return "writable by group or others (chmod go-w)";
188
+ return null;
189
+ }
190
+
191
+ /** Null when the file is safe to execute, otherwise the reason it is refused. */
192
+ export function pluginFileTrustError(file: string): string | null {
193
+ return trustError(file, "file");
194
+ }
195
+
196
+ /**
197
+ * A directory another user can write lets them add or swap plugin files, so its owner and
198
+ * mode are checked like each file's.
199
+ */
200
+ export function pluginDirectoryTrustError(dir: string): string | null {
201
+ return trustError(dir, "directory");
202
+ }
203
+
204
+ /**
205
+ * Every directory above the (resolved) plugin directory, up to `/`, must be owned by the user
206
+ * or root and not writable by group or others unless it is sticky (like `/tmp`), where others
207
+ * cannot rename or replace entries they do not own. With no writable component on the path, no
208
+ * other user can swap what the loader checked for something else before it is imported
209
+ * (OpenSSH StrictModes applies the same rule). POSIX only.
210
+ */
211
+ export function pluginAncestorsTrustError(realDir: string): string | null {
212
+ if (process.platform === "win32") return null;
213
+ const uid = process.getuid?.();
214
+ let current = dirname(realDir);
215
+ for (;;) {
216
+ let stats: ReturnType<typeof lstatSync>;
217
+ try {
218
+ stats = lstatSync(current);
219
+ } catch {
220
+ return "ancestor inspection failed";
221
+ }
222
+ if (uid !== undefined && stats.uid !== uid && stats.uid !== 0) return `${current} is owned by another user`;
223
+ const aclError = aclTrustError(current);
224
+ if (aclError) return aclError;
225
+ if ((stats.mode & 0o022) !== 0 && (stats.mode & 0o1000) === 0) {
226
+ return `${current} is writable by group or others`;
227
+ }
228
+ const parent = dirname(current);
229
+ if (parent === current) return null;
230
+ current = parent;
231
+ }
232
+ }
233
+
234
+ function isPlugin(value: unknown): value is OcxPlugin {
235
+ return typeof value === "object" && value !== null && typeof (value as OcxPlugin).setup === "function";
236
+ }
237
+
238
+ async function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {
239
+ let timer: ReturnType<typeof setTimeout> | undefined;
240
+ const timeout = new Promise<never>((_, reject) => {
241
+ timer = setTimeout(() => reject(new PluginSetupTimeoutError()), ms);
242
+ });
243
+ try {
244
+ return await Promise.race([work, timeout]);
245
+ } finally {
246
+ clearTimeout(timer);
247
+ }
248
+ }
249
+
250
+ class PluginSetupTimeoutError extends Error {}
251
+
252
+ export interface LoadOcxPluginsOptions {
253
+ /** Deadline for a setup that yields; see the module comment. */
254
+ setupTimeoutMs?: number;
255
+ }
256
+
257
+ export async function loadOcxPlugins(
258
+ dir = pluginDirectory(),
259
+ options: LoadOcxPluginsOptions = {},
260
+ ): Promise<PluginLoadResult[]> {
261
+ if (process.env["OCX_PLUGINS"] === "0") return [];
262
+ if (process.platform === "win32") {
263
+ return [{ file: dir, name: "plugins directory", loaded: false, error: "windows_auto_load_disabled" }];
264
+ }
265
+ let files: string[];
266
+ try {
267
+ files = listPluginFiles(dir);
268
+ } catch {
269
+ return [{ file: dir, name: "plugins directory", loaded: false, error: "directory_read_failed" }];
270
+ }
271
+ if (files.length === 0) return [];
272
+ const dirRefused = pluginDirectoryTrustError(dir);
273
+ if (dirRefused) return [{ file: dir, name: "plugins directory", loaded: false, error: "directory_untrusted" }];
274
+ // Check and import through the resolved path, so both refer to the same components.
275
+ let realDir: string;
276
+ try {
277
+ realDir = realpathSync(dir);
278
+ } catch {
279
+ return [{ file: dir, name: "plugins directory", loaded: false, error: "directory_resolution_failed" }];
280
+ }
281
+ const ancestorRefused = pluginAncestorsTrustError(realDir);
282
+ if (ancestorRefused) return [{ file: dir, name: "plugins directory", loaded: false, error: "ancestor_untrusted" }];
283
+ const results: PluginLoadResult[] = [];
284
+ for (const file of files.map(listed => join(realDir, basename(listed)))) {
285
+ const fallbackName = basename(file).replace(/\.(ts|js|mjs)$/, "");
286
+ const refused = pluginFileTrustError(file);
287
+ if (refused) {
288
+ results.push({ file, name: fallbackName, loaded: false, error: "file_untrusted" });
289
+ continue;
290
+ }
291
+ const unregister: Array<() => void> = [];
292
+ // Closed when setup fails or times out: a setup that resumes later must not register.
293
+ let active = true;
294
+ let shutdownCount = 0;
295
+ const whileActive = (name: string, register: () => () => void): void => {
296
+ if (!active) {
297
+ console.error("[opencodex] plugin registration ignored: setup_failed");
298
+ return;
299
+ }
300
+ unregister.push(register());
301
+ };
302
+ let phase: "import" | "export" | "setup" = "import";
303
+ try {
304
+ const module = await import(pathToFileURL(file).href) as { default?: unknown; plugin?: unknown };
305
+ const plugin = module.default ?? module.plugin;
306
+ phase = "export";
307
+ if (!isPlugin(plugin)) throw new Error("default export must be { name?, setup(context) }");
308
+ const name = typeof plugin.name === "string" && plugin.name.trim() ? plugin.name.trim() : fallbackName;
309
+ const context: OcxPluginContext = {
310
+ name,
311
+ configDir: getConfigDir(),
312
+ pluginDir: dir,
313
+ log: message => console.log(`[plugin:${name}] ${message}`),
314
+ registerUpstreamRewriter: rewrite => whileActive(name, () => registerUpstreamRewriter(name, rewrite)),
315
+ // Keyed by file and registration, not name: two plugins may share a display name, and
316
+ // one plugin may register several teardowns.
317
+ onShutdown: teardown => whileActive(name, () => registerOptionalShutdownHook(`plugin:${file}#${++shutdownCount}`, teardown)),
318
+ };
319
+ const timeoutMs = options.setupTimeoutMs ?? SETUP_TIMEOUT_MS;
320
+ phase = "setup";
321
+ await withTimeout(Promise.resolve(plugin.setup(context)), timeoutMs);
322
+ results.push({ file, name, loaded: true });
323
+ } catch (error) {
324
+ // A half-initialised plugin must not leave hooks behind, now or later.
325
+ active = false;
326
+ for (const undo of unregister) undo();
327
+ results.push({
328
+ file,
329
+ name: fallbackName,
330
+ loaded: false,
331
+ error: error instanceof PluginSetupTimeoutError ? "setup_timeout"
332
+ : phase === "import" ? "import_failed"
333
+ : phase === "export" ? "invalid_export" : "setup_failed",
334
+ });
335
+ }
336
+ }
337
+ return results;
338
+ }
339
+
340
+ /** `ocx start` entry: load and print one line per plugin. Never throws. */
341
+ export async function loadAndReportOcxPlugins(): Promise<void> {
342
+ try {
343
+ for (const result of await loadOcxPlugins()) {
344
+ if (result.loaded) console.log(`🔌 Plugin loaded: ${result.name}`);
345
+ else console.error(`⚠️ Plugin skipped: ${result.error}`);
346
+ }
347
+ } catch {
348
+ console.error("⚠️ Plugin loading failed: internal_error");
349
+ }
350
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Upstream rewrite slot for local plugins.
3
+ *
4
+ * A plugin loaded by `src/plugins/loader.ts` may register a rewriter that sees every
5
+ * provider send at the physical boundary, after the transport was chosen: HTTP in
6
+ * `sendWithConnectionPolicy` and the Codex WebSocket dial in `CodexWsSession`. The rewriter
7
+ * may replace the URL and add or change headers — enough to put a local sidecar (a
8
+ * compression proxy, a recorder) in front of the provider without the core knowing it exists.
9
+ *
10
+ * This module imports nothing, so the request path pays one array-length check when no
11
+ * plugin is installed. A rewriter that throws is disabled for the rest of the process and
12
+ * the send continues unmodified: a broken plugin must never take the proxy down with it.
13
+ */
14
+
15
+ export type UpstreamTransport = "http" | "websocket";
16
+
17
+ export interface UpstreamTarget {
18
+ /** Absolute upstream URL. A rewriter may assign a new one. */
19
+ url: string;
20
+ /** Mutable outbound headers. Credentials are present; a rewriter must not log them. */
21
+ headers: Headers;
22
+ readonly transport: UpstreamTransport;
23
+ }
24
+
25
+ export type UpstreamRewriter = (target: UpstreamTarget) => void;
26
+
27
+ interface Registration {
28
+ readonly name: string;
29
+ readonly rewrite: UpstreamRewriter;
30
+ disabled: boolean;
31
+ }
32
+
33
+ const registrations: Registration[] = [];
34
+
35
+ export function registerUpstreamRewriter(name: string, rewrite: UpstreamRewriter): () => void {
36
+ const registration: Registration = { name, rewrite, disabled: false };
37
+ registrations.push(registration);
38
+ return () => {
39
+ const index = registrations.indexOf(registration);
40
+ if (index >= 0) registrations.splice(index, 1);
41
+ };
42
+ }
43
+
44
+ export function hasUpstreamRewriters(): boolean {
45
+ return registrations.length > 0;
46
+ }
47
+
48
+ /**
49
+ * Run every active rewriter over one send. Returns the input untouched (same objects) when
50
+ * no rewriter is registered, so the common path allocates nothing.
51
+ */
52
+ export function rewriteUpstream<H extends HeadersInit | undefined>(
53
+ url: string,
54
+ headers: H,
55
+ transport: UpstreamTransport,
56
+ ): { url: string; headers: H | Headers } {
57
+ if (registrations.length === 0) return { url, headers };
58
+ const target: UpstreamTarget = { url, headers: new Headers(headers), transport };
59
+ for (const registration of registrations) {
60
+ if (registration.disabled) continue;
61
+ // A rewriter that edits the target and then throws must not leave a half-rewritten send
62
+ // for the next rewriter or the network.
63
+ const urlBefore = target.url;
64
+ const headersBefore = new Headers(target.headers);
65
+ try {
66
+ registration.rewrite(target);
67
+ } catch {
68
+ target.url = urlBefore;
69
+ target.headers = headersBefore;
70
+ registration.disabled = true;
71
+ console.error("[opencodex] plugin upstream rewriter disabled: plugin_exception");
72
+ }
73
+ }
74
+ return { url: target.url, headers: target.headers };
75
+ }
76
+
77
+ /** Plain-record variant for callers that hold headers as `Record<string, string>` (WebSocket dial). */
78
+ export function rewriteUpstreamRecord(
79
+ url: string,
80
+ headers: Record<string, string>,
81
+ transport: UpstreamTransport,
82
+ ): { url: string; headers: Record<string, string> } {
83
+ if (registrations.length === 0) return { url, headers };
84
+ const result = rewriteUpstream(url, headers, transport);
85
+ const record: Record<string, string> = {};
86
+ new Headers(result.headers).forEach((value, key) => { record[key] = value; });
87
+ return { url: result.url, headers: record };
88
+ }
89
+
90
+ export function isLoopbackUrl(raw: string): boolean {
91
+ let host: string;
92
+ try {
93
+ host = new URL(raw).hostname.toLowerCase().replace(/^\[|\]$/g, "");
94
+ } catch {
95
+ return false;
96
+ }
97
+ return host === "localhost" || host.endsWith(".localhost") || host === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
98
+ }
99
+
100
+ /**
101
+ * WebSocket dial variant, run for every exchange before a pooled socket is chosen so the pool
102
+ * identity can include the rewritten destination. The caller chose `proxy` for the original
103
+ * destination; a proxy elsewhere on the network cannot reach this machine's loopback, so a
104
+ * rewrite onto a loopback sidecar dials directly. Any other rewrite keeps the caller's proxy.
105
+ */
106
+ export function rewriteWebSocketDial(
107
+ url: string,
108
+ headers: Record<string, string>,
109
+ proxy: string | undefined,
110
+ ): { url: string; headers: Record<string, string>; proxy: string | undefined } {
111
+ if (registrations.length === 0) return { url, headers, proxy };
112
+ const target = rewriteUpstreamRecord(url, headers, "websocket");
113
+ const redirectedToLoopback = target.url !== url && isLoopbackUrl(target.url);
114
+ return { ...target, proxy: redirectedToLoopback ? undefined : proxy };
115
+ }
116
+
117
+ export function resetUpstreamRewritersForTests(): void {
118
+ registrations.length = 0;
119
+ }
@@ -142,6 +142,11 @@ export interface AdapterEventEncodeOptions {
142
142
  freeformToolNames?: ReadonlySet<string>;
143
143
  toolSearchToolNames?: ReadonlySet<string>;
144
144
  stallTimeoutSec?: number;
145
+ /**
146
+ * Operator-trusted local upstream (loopback / private / `.local` / `.lan`): an unset budget
147
+ * resolves to disabled there instead of the 300 s default, matching the bridge (#5876).
148
+ */
149
+ localUpstream?: boolean;
145
150
  /** Wire-silence heartbeat and stall tick; the bridge's 2 s default. */
146
151
  heartbeatMs?: number;
147
152
  hooks?: ClientEncodeHooks;
@@ -165,7 +170,9 @@ export function encodeAdapterEventStream(
165
170
  const heartbeatMs = options.heartbeatMs ?? 2_000;
166
171
  const setTimer = options.timers?.setInterval ?? ((handler: () => void, ms: number) => setInterval(handler, ms));
167
172
  const clearTimer = options.timers?.clearInterval ?? ((id: unknown) => clearInterval(id as ReturnType<typeof setInterval>));
168
- const maxStallTicks = Math.ceil((resolveStallTimeoutSec(options.stallTimeoutSec) * 1000) / heartbeatMs);
173
+ const maxStallTicks = Math.ceil(
174
+ (resolveStallTimeoutSec(options.stallTimeoutSec, { localUpstream: options.localUpstream }) * 1000) / heartbeatMs,
175
+ );
169
176
  const textEncoder = new TextEncoder();
170
177
 
171
178
  let controller!: ReadableStreamDefaultController<Uint8Array>;
@@ -836,7 +843,7 @@ export function encodeAdapterEventStream(
836
843
  if (upstreamActivity) {
837
844
  upstreamActivity = false;
838
845
  stallTicks = 0;
839
- } else if (++stallTicks >= maxStallTicks) {
846
+ } else if (maxStallTicks > 0 && ++stallTicks >= maxStallTicks) {
840
847
  stall();
841
848
  return;
842
849
  }
@@ -14,6 +14,8 @@ import { existsSync, readFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { atomicWriteFile, getConfigDir } from "../config";
16
16
  import type { ProviderQuota } from "./quota-types";
17
+ import type { KiroPersistedQuota, KiroPersistedVerdict } from "./kiro-account-state-disk";
18
+ import { ACCOUNT_QUOTA_TTL_MS } from "./quota-wire";
17
19
 
18
20
  const FILENAME = "provider-account-quota-cache.json";
19
21
 
@@ -31,7 +33,8 @@ const PERSIST_DEBOUNCE_MS = 250;
31
33
  type DiskFile = {
32
34
  version: 1;
33
35
  /** provider\u0000accountId -> quota, the same key the in-memory cache uses. */
34
- rows: Record<string, ProviderQuota>;
36
+ rows: Record<string, ProviderQuota | KiroPersistedQuota>;
37
+ kiroVerdicts?: Record<string, KiroPersistedVerdict>;
35
38
  };
36
39
 
37
40
  let persistTimer: ReturnType<typeof setTimeout> | null = null;
@@ -45,8 +48,12 @@ export function readPersistedAccountQuotas(now = Date.now()): Map<string, Provid
45
48
  const parsed = JSON.parse(readFileSync(path, "utf8")) as DiskFile;
46
49
  if (!parsed || parsed.version !== 1 || !parsed.rows || typeof parsed.rows !== "object") return rows;
47
50
  for (const [key, quota] of Object.entries(parsed.rows)) {
48
- if (!quota || typeof quota !== "object" || typeof quota.updatedAt !== "number") continue;
51
+ if (!quota || typeof quota !== "object" || typeof quota.updatedAt !== "number"
52
+ || !Number.isFinite(quota.updatedAt)) continue;
49
53
  if (now - quota.updatedAt > DISK_MAX_AGE_MS) continue;
54
+ if (key.startsWith("kiro\0") && (quota.updatedAt > now
55
+ || typeof (quota as KiroPersistedQuota).identity !== "string"
56
+ || !/^[a-f0-9]{64}$/.test((quota as KiroPersistedQuota).identity))) continue;
50
57
  rows.set(key, quota);
51
58
  }
52
59
  } catch {
@@ -55,15 +62,46 @@ export function readPersistedAccountQuotas(now = Date.now()): Map<string, Provid
55
62
  return rows;
56
63
  }
57
64
 
65
+ export function readPersistedKiroVerdicts(now = Date.now()): Map<string, KiroPersistedVerdict> {
66
+ const result = new Map<string, KiroPersistedVerdict>();
67
+ try {
68
+ const file: unknown = JSON.parse(readFileSync(join(getConfigDir(), FILENAME), "utf8"));
69
+ if (!file || typeof file !== "object" || Array.isArray(file)) return result;
70
+ const parsed = file as Partial<DiskFile>;
71
+ if (parsed.version !== 1 || !parsed.kiroVerdicts || typeof parsed.kiroVerdicts !== "object"
72
+ || Array.isArray(parsed.kiroVerdicts)) return result;
73
+ for (const [key, value] of Object.entries(parsed.kiroVerdicts)) {
74
+ if (!key.startsWith("kiro\0") || !value || typeof value !== "object") continue;
75
+ const row = value as Partial<KiroPersistedVerdict>;
76
+ if (typeof row.exhausted !== "boolean" || typeof row.identity !== "string"
77
+ || !/^[a-f0-9]{64}$/.test(row.identity) || typeof row.observedAt !== "number"
78
+ || !Number.isFinite(row.observedAt) || row.observedAt > now
79
+ || now - row.observedAt >= ACCOUNT_QUOTA_TTL_MS
80
+ || (row.resetAt !== undefined && (typeof row.resetAt !== "number"
81
+ || !Number.isFinite(row.resetAt) || row.resetAt <= now
82
+ || !Number.isFinite(new Date(row.resetAt).getTime())))) continue;
83
+ result.set(key, row as KiroPersistedVerdict);
84
+ }
85
+ } catch { /* Missing or malformed evidence is unknown. */ }
86
+ return result;
87
+ }
88
+
58
89
  /** Write the snapshot, debounced. Best-effort: a failed write is not an error. */
59
- export function schedulePersistAccountQuotas(rows: () => Iterable<[string, ProviderQuota]>): void {
90
+ export function schedulePersistAccountQuotas(
91
+ rows: () => Iterable<[string, ProviderQuota | KiroPersistedQuota]>,
92
+ verdicts: () => Iterable<[string, KiroPersistedVerdict]> = () => [],
93
+ ): void {
60
94
  if (persistTimer) clearTimeout(persistTimer);
61
95
  persistTimer = setTimeout(() => {
62
96
  persistTimer = null;
63
97
  try {
64
98
  const out: Record<string, ProviderQuota> = {};
65
99
  for (const [key, quota] of rows()) out[key] = quota;
66
- const body: DiskFile = { version: 1, rows: out };
100
+ const kiroVerdicts: Record<string, KiroPersistedVerdict> = {};
101
+ for (const [key, verdict] of verdicts()) {
102
+ if (key.startsWith("kiro\0") && /^[a-f0-9]{64}$/.test(verdict.identity)) kiroVerdicts[key] = verdict;
103
+ }
104
+ const body: DiskFile = { version: 1, rows: out, kiroVerdicts };
67
105
  atomicWriteFile(join(getConfigDir(), FILENAME), `${JSON.stringify(body)}\n`);
68
106
  } catch {
69
107
  // Best-effort persistence only.
@@ -7,8 +7,12 @@
7
7
  * Global and CN are deliberately NOT the same roster (§八). Context windows, output caps, vision
8
8
  * and reasoning ladders are filled ONLY where the official manifest states them; a model with no
9
9
  * published figure is omitted rather than guessed (§二十八/§二十九). CodeBuddy exposes no documented
10
- * third-party live `/v1/models` endpoint, so these providers seed a static catalog
11
- * (`liveModels: false`) exactly like the Kiro and Command Code entries.
10
+ * third-party live `/v1/models` endpoint, so live discovery instead reads the key-authenticated
11
+ * product configuration roster (src/adapters/codebuddy/live-models.ts) and this static catalog is
12
+ * only the degraded seed for keys that fail to authenticate or requests that fail. The
13
+ * server-side roster can list models the manifest does not know — an account's entitlement can
14
+ * be newer than the bundled manifest — so a mismatch between this file and a live roster is
15
+ * expected, not a catalog bug.
12
16
  */
13
17
 
14
18
  /** Global (`public`) session models accepted by `codebuddy --model`. */