nexo-brain 7.25.5 → 7.26.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.25.5",
3
+ "version": "7.26.0",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.25.5` is the current packaged-runtime line. Patch release over v7.25.4 - explicit user-included Local Memory roots can override default system/noisy-tree skips, while Brain LocalIndex operator messages stay in English and Desktop continues to enforce the Core-pinned Python 3.12 runtime.
21
+ Version `7.26.0` is the current packaged-runtime line. Minor release over v7.25.6 - provider runtime parity lets Desktop-managed Brain choose Anthropic Claude or OpenAI Codex, keep provider metadata on sessions/automation/crons, and provision the managed Codex runtime from bundled Desktop resources.
22
+
23
+ Previously in `7.25.6`: patch release over v7.25.5 - existing Local Memory sidecar databases repair legacy root/exclusion columns before source-dependent indexes are created, and core background crons prefer the NEXO-managed Python runtime.
22
24
 
23
25
  Previously in `7.25.4`: patch release over v7.25.3 - Local Memory starts from safe user-content and email roots, adds configurable included/excluded file types, and cleans legacy whole-disk index state with backup or archive-rebuild safety.
24
26
 
package/bin/nexo-brain.js CHANGED
@@ -18,6 +18,7 @@ const { execSync, spawnSync } = require("child_process");
18
18
  const crypto = require("crypto");
19
19
  const fs = require("fs");
20
20
  const { createRequire } = require("module");
21
+ const os = require("os");
21
22
  const path = require("path");
22
23
  const readline = require("readline");
23
24
  // Force relative launcher helpers to resolve from bin/ even under test harnesses.
@@ -1672,6 +1673,7 @@ function getDefaultSchedule(timezone) {
1672
1673
  default_terminal_client: "claude_code",
1673
1674
  automation_enabled: true,
1674
1675
  automation_backend: "claude_code",
1676
+ provider_runtime: defaultProviderRuntime("anthropic", "anthropic"),
1675
1677
  // v6.0.0 — model/reasoning_effort have moved to src/resonance_tiers.json
1676
1678
  // keyed by the operator's preferences.default_resonance. The shape
1677
1679
  // below stays so that downstream readers that iterate the profile
@@ -1813,9 +1815,31 @@ function detectInstalledClients() {
1813
1815
  : [];
1814
1816
  const desktopAppPath = desktopApps.find((candidate) => fs.existsSync(candidate)) || "";
1815
1817
  const managedClaudeBin = resolveManagedClaudeBinary();
1818
+ const managedCodexBin = resolveManagedCodexBinary();
1819
+ const desktopManaged = isDesktopManagedInstall();
1820
+ if (desktopManaged) {
1821
+ const managedCodexReady = managedCodexBin && codexVendorPresent(managedClaudePrefix());
1822
+ return {
1823
+ claude_code: {
1824
+ installed: Boolean(managedClaudeBin),
1825
+ path: managedClaudeBin,
1826
+ detectedBy: managedClaudeBin ? "managed_binary" : "missing",
1827
+ },
1828
+ codex: {
1829
+ installed: Boolean(managedCodexReady),
1830
+ path: managedCodexReady ? managedCodexBin : "",
1831
+ detectedBy: managedCodexReady ? "managed_binary" : "missing",
1832
+ },
1833
+ claude_desktop: {
1834
+ installed: Boolean(desktopAppPath || fs.existsSync(desktopConfig)),
1835
+ path: desktopAppPath || desktopConfig,
1836
+ detectedBy: desktopAppPath ? "app" : (fs.existsSync(desktopConfig) ? "config" : "missing"),
1837
+ },
1838
+ };
1839
+ }
1816
1840
  const persistedClaudeBin = readPersistedClaudeCliPath();
1817
1841
  const claudeBin = managedClaudeBin || persistedClaudeBin || run("which claude", { env: buildManagedCliEnv() }) || run("which claude") || "";
1818
- const codexBin = run("which codex") || "";
1842
+ const codexBin = managedCodexBin || run("which codex", { env: buildManagedCliEnv() }) || run("which codex") || "";
1819
1843
  return {
1820
1844
  claude_code: {
1821
1845
  installed: Boolean(claudeBin),
@@ -1905,6 +1929,14 @@ function resolveManagedClaudeBinary() {
1905
1929
  return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || "";
1906
1930
  }
1907
1931
 
1932
+ function resolveManagedCodexBinary() {
1933
+ const prefix = managedClaudePrefix();
1934
+ const candidates = process.platform === "win32"
1935
+ ? [path.join(prefix, "codex.cmd"), path.join(prefix, "bin", "codex.cmd")]
1936
+ : [path.join(prefix, "bin", "codex"), path.join(prefix, "codex")];
1937
+ return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || "";
1938
+ }
1939
+
1908
1940
  function readPersistedClaudeCliPath() {
1909
1941
  const candidates = [
1910
1942
  path.join(NEXO_HOME, "config", "claude-cli-path"),
@@ -2000,6 +2032,60 @@ function defaultClientRuntimeProfiles() {
2000
2032
  };
2001
2033
  }
2002
2034
 
2035
+ function defaultProviderRuntime(selectedProvider = "anthropic", automationProvider = selectedProvider) {
2036
+ const automationBackend = automationProvider === "openai" ? "codex" : (automationProvider === "anthropic" ? "claude_code" : "none");
2037
+ return {
2038
+ schema_version: 1,
2039
+ selected_chat_provider: selectedProvider,
2040
+ automation_provider: automationProvider,
2041
+ automation_backend: automationBackend,
2042
+ providers: {
2043
+ anthropic: {
2044
+ client: "claude_code",
2045
+ runtime_account_status: {
2046
+ surface: "desktop_login",
2047
+ status: "unknown",
2048
+ plan: null,
2049
+ last_checked_at: null,
2050
+ detail: null,
2051
+ },
2052
+ install_status: {
2053
+ installed: false,
2054
+ managed: true,
2055
+ binary_path: null,
2056
+ version: null,
2057
+ },
2058
+ },
2059
+ openai: {
2060
+ client: "codex",
2061
+ runtime_account_status: {
2062
+ surface: "desktop_login",
2063
+ status: "unknown",
2064
+ plan: null,
2065
+ last_checked_at: null,
2066
+ detail: null,
2067
+ },
2068
+ install_status: {
2069
+ installed: false,
2070
+ managed: true,
2071
+ binary_path: null,
2072
+ version: null,
2073
+ },
2074
+ },
2075
+ },
2076
+ fallback_policy: {
2077
+ chat: "ask",
2078
+ automation: "fail_closed",
2079
+ },
2080
+ last_provider_change: {
2081
+ changed_at: null,
2082
+ from_provider: null,
2083
+ to_provider: null,
2084
+ source: null,
2085
+ },
2086
+ };
2087
+ }
2088
+
2003
2089
  function runtimeClientLabel(client) {
2004
2090
  if (client === "claude_code") return "Claude Code";
2005
2091
  if (client === "codex") return "Codex";
@@ -2039,6 +2125,7 @@ function defaultClientSetup(detected) {
2039
2125
  default_terminal_client: "claude_code",
2040
2126
  automation_enabled: true,
2041
2127
  automation_backend: "claude_code",
2128
+ provider_runtime: defaultProviderRuntime("anthropic", "anthropic"),
2042
2129
  client_runtime_profiles: defaultClientRuntimeProfiles(),
2043
2130
  client_install_preferences: {
2044
2131
  claude_code: "ask",
@@ -2057,6 +2144,17 @@ function applyClientSetupToSchedule(schedule, setup) {
2057
2144
  schedule.default_terminal_client = setup.default_terminal_client;
2058
2145
  schedule.automation_enabled = Boolean(setup.automation_enabled);
2059
2146
  schedule.automation_backend = schedule.automation_enabled ? setup.automation_backend : "none";
2147
+ const selectedProvider = setup.default_terminal_client === "codex" ? "openai" : "anthropic";
2148
+ const automationProvider = schedule.automation_backend === "codex"
2149
+ ? "openai"
2150
+ : (schedule.automation_backend === "claude_code" ? "anthropic" : "none");
2151
+ schedule.provider_runtime = {
2152
+ ...defaultProviderRuntime(selectedProvider, automationProvider),
2153
+ ...(setup.provider_runtime || {}),
2154
+ selected_chat_provider: selectedProvider,
2155
+ automation_provider: automationProvider,
2156
+ automation_backend: schedule.automation_backend,
2157
+ };
2060
2158
  schedule.client_runtime_profiles = {
2061
2159
  ...defaultClientRuntimeProfiles(),
2062
2160
  ...(setup.client_runtime_profiles || {}),
@@ -2090,6 +2188,9 @@ function installClaudeCodeCli(platform) {
2090
2188
  const npmViaDesktop = desktopNode && bundledNpmCli;
2091
2189
  let installEnv = buildManagedCliEnv();
2092
2190
  if (desktopNode) installEnv = withDesktopNodeShim(installEnv, desktopNode);
2191
+ if (desktopManaged && !npmViaDesktop) {
2192
+ return { installed: false, path: "" };
2193
+ }
2093
2194
 
2094
2195
  // OFFLINE-FIRST v0.32.4: install claude-code wrapper + ALL its native packs
2095
2196
  // from bundled tarballs. Path: resources/brain-bundle/claude-code/*.tgz.
@@ -2186,22 +2287,7 @@ function installClaudeCodeCli(platform) {
2186
2287
  }
2187
2288
  }
2188
2289
 
2189
- if (desktopManaged) {
2190
- spawnSync(
2191
- "npm",
2192
- ["install", "-g", "--prefix", managedPrefix, "@anthropic-ai/claude-code"],
2193
- {
2194
- stdio: "inherit",
2195
- env: installEnv,
2196
- },
2197
- );
2198
- claudeInstalled = detectInstalledClients().claude_code.path || "";
2199
- if (claudeInstalled) {
2200
- persistClaudeCliPath(claudeInstalled);
2201
- return { installed: true, path: claudeInstalled };
2202
- }
2203
- return { installed: false, path: "" };
2204
- }
2290
+ if (desktopManaged) return { installed: false, path: "" };
2205
2291
 
2206
2292
  spawnSync("npx", ["-y", "@anthropic-ai/claude-code", "--version"], {
2207
2293
  stdio: "pipe",
@@ -2221,11 +2307,128 @@ function installClaudeCodeCli(platform) {
2221
2307
  return { installed: Boolean(claudeInstalled), path: claudeInstalled || "" };
2222
2308
  }
2223
2309
 
2310
+ function installBundledCodexVendor(bundleDir, managedPrefix) {
2311
+ const packageRoots = [
2312
+ path.join(managedPrefix, "lib", "node_modules", "@openai", "codex"),
2313
+ path.join(managedPrefix, "node_modules", "@openai", "codex"),
2314
+ ];
2315
+ const packageRoot = packageRoots.find((candidate) => fs.existsSync(candidate)) || packageRoots[0];
2316
+ if (!fs.existsSync(packageRoot)) return false;
2317
+ const allTgz = fs.readdirSync(bundleDir).filter((f) => f.endsWith(".tgz"));
2318
+ const platformSlug = `${process.platform}-${process.arch}`;
2319
+ const nativePacks = allTgz.filter((f) => f.includes(`-${platformSlug}.tgz`) || f.includes(`-${platformSlug}-`));
2320
+ if (!nativePacks.length) return false;
2321
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nexo-codex-vendor-"));
2322
+ try {
2323
+ for (const pack of nativePacks) {
2324
+ const tgzPath = path.join(bundleDir, pack);
2325
+ const extract = spawnSync("tar", ["-xzf", tgzPath, "-C", tmpRoot], { stdio: "pipe" });
2326
+ if (extract.status !== 0) continue;
2327
+ const vendorRoot = path.join(tmpRoot, "package", "vendor");
2328
+ if (!fs.existsSync(vendorRoot)) continue;
2329
+ fs.mkdirSync(path.join(packageRoot, "vendor"), { recursive: true });
2330
+ for (const entry of fs.readdirSync(vendorRoot)) {
2331
+ fs.cpSync(path.join(vendorRoot, entry), path.join(packageRoot, "vendor", entry), { recursive: true, force: true });
2332
+ }
2333
+ return true;
2334
+ }
2335
+ } finally {
2336
+ try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch {}
2337
+ }
2338
+ return false;
2339
+ }
2340
+
2341
+ function codexVendorPresent(managedPrefix) {
2342
+ const packageRoots = [
2343
+ path.join(managedPrefix, "lib", "node_modules", "@openai", "codex"),
2344
+ path.join(managedPrefix, "node_modules", "@openai", "codex"),
2345
+ ];
2346
+ for (const packageRoot of packageRoots) {
2347
+ const vendorRoot = path.join(packageRoot, "vendor");
2348
+ if (!fs.existsSync(vendorRoot)) continue;
2349
+ try {
2350
+ const stack = [vendorRoot];
2351
+ while (stack.length) {
2352
+ const current = stack.pop();
2353
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
2354
+ const target = path.join(current, entry.name);
2355
+ if (entry.isDirectory()) {
2356
+ stack.push(target);
2357
+ } else if (entry.isFile() && path.basename(path.dirname(target)) === "bin" && entry.name.startsWith("codex")) {
2358
+ return true;
2359
+ }
2360
+ }
2361
+ }
2362
+ } catch {}
2363
+ }
2364
+ return false;
2365
+ }
2366
+
2224
2367
  function installCodexCli() {
2225
- const before = run("which codex");
2226
- if (before) return { installed: true, path: before };
2227
- spawnSync("npm", ["install", "-g", "@openai/codex"], { stdio: "inherit" });
2228
- const codexInstalled = run("which codex") || "";
2368
+ const desktopNode = String(process.env.NEXO_DESKTOP_NODE || "").trim();
2369
+ const bundledNpmCli = String(process.env.NEXO_DESKTOP_NPM_CLI || "").trim();
2370
+ const managedPrefix = managedClaudePrefix();
2371
+ const desktopManaged = isDesktopManagedInstall();
2372
+ let before = detectInstalledClients().codex.path || "";
2373
+ if (before && (!desktopManaged || codexVendorPresent(managedPrefix))) return { installed: true, path: before };
2374
+ if (before && desktopManaged) {
2375
+ log(" Managed Codex wrapper exists, but native vendor is missing; repairing bundled vendor.");
2376
+ }
2377
+ const npmViaDesktop = desktopNode && bundledNpmCli;
2378
+ let installEnv = buildManagedCliEnv();
2379
+ if (desktopNode) installEnv = withDesktopNodeShim(installEnv, desktopNode);
2380
+ if (desktopManaged && !npmViaDesktop) {
2381
+ return { installed: false, path: "" };
2382
+ }
2383
+
2384
+ const bundledCodexDir = path.join(__dirname, "..", "codex");
2385
+ if (fs.existsSync(bundledCodexDir)) {
2386
+ const allTgz = fs.readdirSync(bundledCodexDir).filter((f) => f.endsWith(".tgz")).sort();
2387
+ const wrapper = allTgz.find((f) => /^openai-codex-\d+\.\d+\.\d+\.tgz$/.test(f));
2388
+ if (wrapper) {
2389
+ const tgzPath = path.join(bundledCodexDir, wrapper);
2390
+ log(" Installing Codex from bundled tarball (offline wrapper + native vendor)...");
2391
+ spawnSync(
2392
+ npmViaDesktop ? desktopNode : "npm",
2393
+ [
2394
+ ...(npmViaDesktop ? [bundledNpmCli] : []),
2395
+ "install",
2396
+ "-g",
2397
+ "--prefix",
2398
+ managedPrefix,
2399
+ "--offline",
2400
+ "--no-audit",
2401
+ "--no-fund",
2402
+ tgzPath,
2403
+ ],
2404
+ { stdio: "inherit", env: installEnv },
2405
+ );
2406
+ const bundledVendorInstalled = installBundledCodexVendor(bundledCodexDir, managedPrefix);
2407
+ before = detectInstalledClients().codex.path || "";
2408
+ if (before && bundledVendorInstalled) return { installed: true, path: before };
2409
+ if (before && !bundledVendorInstalled) {
2410
+ log(" Bundled Codex wrapper installed, but native vendor extraction failed; falling back to online install.");
2411
+ }
2412
+ }
2413
+ }
2414
+
2415
+ if (desktopNode && bundledNpmCli) {
2416
+ spawnSync(
2417
+ desktopNode,
2418
+ [bundledNpmCli, "install", "-g", "--prefix", managedPrefix, "@openai/codex"],
2419
+ {
2420
+ stdio: "inherit",
2421
+ env: { ...installEnv, ELECTRON_RUN_AS_NODE: "1" },
2422
+ },
2423
+ );
2424
+ before = detectInstalledClients().codex.path || "";
2425
+ if (before && (!desktopManaged || codexVendorPresent(managedPrefix))) return { installed: true, path: before };
2426
+ }
2427
+
2428
+ if (desktopManaged) return { installed: false, path: "" };
2429
+
2430
+ spawnSync("npm", ["install", "-g", "--prefix", managedPrefix, "@openai/codex"], { stdio: "inherit", env: installEnv });
2431
+ const codexInstalled = detectInstalledClients().codex.path || "";
2229
2432
  return { installed: Boolean(codexInstalled), path: codexInstalled };
2230
2433
  }
2231
2434
 
@@ -2284,19 +2487,19 @@ async function configureClientSetup({ lang, useDefaults, autoInstall, detected }
2284
2487
  const required = requiredCliClients(setup);
2285
2488
  for (const client of required) {
2286
2489
  if (detected[client] && detected[client].installed) continue;
2287
- if (desktopManaged && client === "claude_code") {
2288
- const bundledClaudeDir = path.join(__dirname, "..", "claude-code");
2490
+ if (desktopManaged && (client === "claude_code" || client === "codex")) {
2491
+ const bundledClientDir = path.join(__dirname, "..", client === "claude_code" ? "claude-code" : "codex");
2289
2492
  let hasBundle = false;
2290
2493
  try {
2291
- if (fs.existsSync(bundledClaudeDir)) {
2292
- hasBundle = fs.readdirSync(bundledClaudeDir).some((f) => f.endsWith(".tgz"));
2494
+ if (fs.existsSync(bundledClientDir)) {
2495
+ hasBundle = fs.readdirSync(bundledClientDir).some((f) => f.endsWith(".tgz"));
2293
2496
  }
2294
2497
  } catch (_) {}
2295
2498
  if (!hasBundle) {
2296
- log("Claude Code install deferred to Desktop final sync.");
2499
+ log(`${runtimeClientLabel(client)} install deferred to Desktop final sync.`);
2297
2500
  continue;
2298
2501
  }
2299
- log("Bundled Claude Code tarball detected — installing offline now.");
2502
+ log(`Bundled ${runtimeClientLabel(client)} tarball detected — installing offline now.`);
2300
2503
  }
2301
2504
  let shouldInstall = useDefaults || autoInstall === "auto";
2302
2505
  if (!shouldInstall && process.stdin.isTTY && process.stdout.isTTY) {
@@ -2326,8 +2529,9 @@ async function configureClientSetup({ lang, useDefaults, autoInstall, detected }
2326
2529
  }
2327
2530
 
2328
2531
  if (setup.automation_enabled && setup.automation_backend !== "none" && !detected[setup.automation_backend]?.installed) {
2329
- if (desktopManaged && setup.automation_backend === "claude_code") {
2330
- log("Claude Code will be provisioned by Desktop after the core runtime is ready.");
2532
+ if (desktopManaged && (setup.automation_backend === "claude_code" || setup.automation_backend === "codex")) {
2533
+ const label = setup.automation_backend === "claude_code" ? "Claude Code" : "Codex";
2534
+ log(`${label} will be provisioned by Desktop after the core runtime is ready.`);
2331
2535
  return { setup, detected };
2332
2536
  }
2333
2537
  const label = setup.automation_backend === "claude_code" ? "Claude Code" : "Codex";
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.25.5",
3
+ "version": "7.26.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -65,6 +65,11 @@
65
65
  "name": "@anthropic-ai/claude-code",
66
66
  "type": "npm-global",
67
67
  "optional": false
68
+ },
69
+ {
70
+ "name": "@openai/codex",
71
+ "type": "npm-global",
72
+ "optional": true
68
73
  }
69
74
  ],
70
75
  "engines": {
@@ -87,6 +92,7 @@
87
92
  "!templates/**/__pycache__",
88
93
  "!templates/**/*.pyc",
89
94
  "!templates/**/*.pyo",
95
+ "codex/openai-codex-0.133.0.tgz",
90
96
  ".claude-plugin/",
91
97
  ".mcp.json",
92
98
  "hooks/hooks.json",
@@ -24,8 +24,12 @@ from client_preferences import (
24
24
  BACKEND_NONE,
25
25
  CLIENT_CLAUDE_CODE,
26
26
  CLIENT_CODEX,
27
+ client_to_provider,
27
28
  TERMINAL_CLIENT_KEYS,
28
29
  load_client_preferences,
30
+ _desktop_product_requested,
31
+ _managed_codex_binary,
32
+ _managed_codex_vendor_present,
29
33
  normalize_client_key,
30
34
  resolve_automation_backend,
31
35
  resolve_client_runtime_profile,
@@ -54,6 +58,10 @@ class TerminalClientUnavailableError(AgentRunnerError):
54
58
  class AutomationBackendUnavailableError(AgentRunnerError):
55
59
  """Raised when the configured automation backend is unavailable."""
56
60
 
61
+
62
+ def _automation_provider_for_backend(backend: str) -> str:
63
+ return client_to_provider(backend) or ""
64
+
57
65
  def _canonical_pricing_model(model: str) -> str:
58
66
  lowered = str(model or "").strip().lower()
59
67
  lowered = lowered.split("[", 1)[0]
@@ -199,6 +207,7 @@ def _record_automation_start(
199
207
  *,
200
208
  caller: str,
201
209
  backend: str,
210
+ provider: str = "",
202
211
  session_type: str,
203
212
  task_profile: str,
204
213
  model: str,
@@ -225,14 +234,15 @@ def _record_automation_start(
225
234
  cur = conn.execute(
226
235
  """
227
236
  INSERT INTO automation_runs (
228
- caller, backend, session_type, task_profile, model,
237
+ caller, backend, provider, session_type, task_profile, model,
229
238
  reasoning_effort, resonance_tier, cwd, output_format,
230
239
  prompt_chars, status, started_at, pid
231
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', datetime('now'), ?)
240
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', datetime('now'), ?)
232
241
  """,
233
242
  (
234
243
  caller or "",
235
244
  backend,
245
+ provider or _automation_provider_for_backend(backend),
236
246
  session_type or "headless",
237
247
  task_profile or "default",
238
248
  model or "",
@@ -339,6 +349,7 @@ def _record_automation_end(
339
349
  def _record_automation_run(
340
350
  *,
341
351
  backend: str,
352
+ provider: str = "",
342
353
  task_profile: str,
343
354
  model: str,
344
355
  reasoning_effort: str,
@@ -359,6 +370,7 @@ def _record_automation_run(
359
370
  row_id, err = _record_automation_start(
360
371
  caller=caller,
361
372
  backend=backend,
373
+ provider=provider or _automation_provider_for_backend(backend),
362
374
  session_type=session_type,
363
375
  task_profile=task_profile,
364
376
  model=model,
@@ -378,14 +390,57 @@ def _record_automation_run(
378
390
  )
379
391
 
380
392
 
393
+ def _record_backend_unavailable(
394
+ *,
395
+ backend: str,
396
+ caller: str,
397
+ cwd: Path,
398
+ prompt: str,
399
+ ) -> None:
400
+ profile = resolve_client_runtime_profile(backend)
401
+ _record_automation_run(
402
+ backend=backend,
403
+ provider=_automation_provider_for_backend(backend),
404
+ task_profile="default",
405
+ model=profile.get("model", ""),
406
+ reasoning_effort=profile.get("reasoning_effort", ""),
407
+ cwd=cwd,
408
+ output_format="text",
409
+ prompt=prompt,
410
+ returncode=2,
411
+ duration_ms=0,
412
+ telemetry={
413
+ "telemetry_source": "backend_unavailable",
414
+ "cost_source": "missing",
415
+ "usage": {},
416
+ "warnings": ["backend_unavailable", "fallback_blocked"],
417
+ "raw": {
418
+ "event": "backend_unavailable",
419
+ "backend": backend,
420
+ "provider": _automation_provider_for_backend(backend),
421
+ "fallback_policy": "fail_closed",
422
+ },
423
+ },
424
+ caller=caller,
425
+ session_type="headless",
426
+ )
427
+
428
+
381
429
  def _resolve_claude_cli() -> str:
382
430
  return _shared_resolve_claude_cli()
383
431
 
384
432
 
385
433
  def _resolve_codex_cli() -> str:
434
+ home = Path.home()
435
+ if _desktop_product_requested(home):
436
+ managed_path = _managed_codex_binary(home)
437
+ return managed_path if managed_path and _managed_codex_vendor_present(home) else ""
386
438
  env_path = os.environ.get("CODEX_BIN", "").strip()
387
439
  if env_path and Path(env_path).exists():
388
440
  return env_path
441
+ managed_path = _managed_codex_binary(home)
442
+ if managed_path and _managed_codex_vendor_present(home):
443
+ return managed_path
389
444
  return shutil.which("codex") or ""
390
445
 
391
446
 
@@ -790,6 +845,7 @@ def run_automation_interactive(
790
845
  row_id, _record_err = _record_automation_start(
791
846
  caller=caller,
792
847
  backend=resolved_client,
848
+ provider=_automation_provider_for_backend(resolved_client),
793
849
  session_type=session_type,
794
850
  task_profile="",
795
851
  model="",
@@ -905,15 +961,6 @@ def _backend_is_available(backend: str) -> bool:
905
961
 
906
962
 
907
963
  def _resolve_available_backend(selected_backend: str, *, preferences: dict | None = None) -> str:
908
- if _backend_is_available(selected_backend):
909
- return selected_backend
910
- prefs = preferences or load_client_preferences()
911
- preferred = resolve_automation_backend(preferences=prefs)
912
- for candidate in (preferred, CLIENT_CLAUDE_CODE, CLIENT_CODEX):
913
- if candidate == selected_backend or candidate == BACKEND_NONE:
914
- continue
915
- if _backend_is_available(candidate):
916
- return candidate
917
964
  return selected_backend
918
965
 
919
966
 
@@ -1011,7 +1058,7 @@ _ANTHROPIC_API_KEY_SEARCH_PATHS = (
1011
1058
 
1012
1059
 
1013
1060
  def _resolve_anthropic_api_key() -> str:
1014
- """Locate an Anthropic API key for bare-mode invocations.
1061
+ """Locate a legacy Anthropic API key for opt-in bare-mode invocations.
1015
1062
 
1016
1063
  ``claude --bare`` skips macOS Keychain auth entirely, so the child
1017
1064
  must find the API key in ``ANTHROPIC_API_KEY`` or via ``apiKeyHelper``.
@@ -1039,15 +1086,16 @@ def _resolve_anthropic_api_key() -> str:
1039
1086
  return ""
1040
1087
 
1041
1088
 
1042
- # Callers for which bare_mode=True is safe. The child's only allowed_tools
1043
- # must be file/grep/shell (no ``mcp__nexo__*``), otherwise --bare's opt-out
1044
- # of plugin sync / MCP bootstrap breaks the run. Extract/synthesize fit
1045
- # this profile: they read transcripts + shared-context and emit JSON, no
1046
- # NEXO tool calls.
1047
- BARE_MODE_SAFE_CALLERS: frozenset[str] = frozenset({
1048
- "deep-sleep/extract",
1049
- "deep-sleep/synthesize",
1050
- })
1089
+ def _allow_anthropic_api_key_bare_mode() -> bool:
1090
+ value = os.environ.get("NEXO_ALLOW_ANTHROPIC_API_BARE", "").strip().lower()
1091
+ return value in {"1", "true", "yes", "on"}
1092
+
1093
+
1094
+ # v7.26 provider-runtime contract: Desktop-managed automation must use the
1095
+ # selected account runtime (Claude Code or Codex), not an Anthropic API key
1096
+ # side path. Bare mode remains a hidden legacy opt-in for controlled local
1097
+ # experiments only via NEXO_ALLOW_ANTHROPIC_API_BARE=1.
1098
+ BARE_MODE_SAFE_CALLERS: frozenset[str] = frozenset()
1051
1099
 
1052
1100
  # Execution contracts keep background agents disciplined without polluting
1053
1101
  # machine-only child calls that must return strict JSON.
@@ -1153,6 +1201,17 @@ def run_automation_prompt(
1153
1201
  if selected_backend == BACKEND_NONE:
1154
1202
  raise AutomationBackendUnavailableError("Automation backend is disabled in config.")
1155
1203
  selected_backend = _resolve_available_backend(selected_backend, preferences=prefs)
1204
+ if not _backend_is_available(selected_backend):
1205
+ cwd_path = Path(cwd).expanduser().resolve() if cwd else Path.cwd()
1206
+ _record_backend_unavailable(
1207
+ backend=selected_backend,
1208
+ caller=caller,
1209
+ cwd=cwd_path,
1210
+ prompt=prompt,
1211
+ )
1212
+ raise AutomationBackendUnavailableError(
1213
+ f"{selected_backend} automation backend selected but launcher is not installed; fallback blocked."
1214
+ )
1156
1215
 
1157
1216
  # Resonance map decides (model, effort) for every call. ``caller`` is
1158
1217
  # MANDATORY — every script that invokes the automation backend must be
@@ -1256,13 +1315,13 @@ def run_automation_prompt(
1256
1315
  # completes in seconds.
1257
1316
  #
1258
1317
  # Selection rules:
1259
- # - bare_mode=True explicit → trust the caller.
1318
+ # - bare_mode=True explicit → allow only when the legacy API-key
1319
+ # escape hatch is enabled.
1260
1320
  # - bare_mode=None (default) + caller in BARE_MODE_SAFE_CALLERS
1261
- # → auto-enable.
1321
+ # → auto-enable. v7.26 keeps this set empty for provider parity.
1262
1322
  # - bare_mode=False → never.
1263
- # - --bare disables keychain auth, so we must provide an
1264
- # ANTHROPIC_API_KEY. If one cannot be located, fall back to
1265
- # normal mode with a warning on stderr rather than failing.
1323
+ # - --bare disables keychain auth, so the legacy path requires
1324
+ # ANTHROPIC_API_KEY. If disabled or missing, use normal account auth.
1266
1325
  resolved_bare = False
1267
1326
  if bare_mode is True:
1268
1327
  resolved_bare = True
@@ -1271,12 +1330,14 @@ def run_automation_prompt(
1271
1330
 
1272
1331
  bare_api_key = ""
1273
1332
  if resolved_bare:
1274
- bare_api_key = _resolve_anthropic_api_key()
1275
- if not bare_api_key:
1276
- # Silent fallback: we would rather take the slower path
1277
- # than force the caller to fail-closed on an env quirk.
1333
+ if not _allow_anthropic_api_key_bare_mode():
1278
1334
  resolved_bare = False
1279
-
1335
+ else:
1336
+ bare_api_key = _resolve_anthropic_api_key()
1337
+ if not bare_api_key:
1338
+ # Silent fallback: we would rather take the slower path
1339
+ # than force the caller to fail-closed on an env quirk.
1340
+ resolved_bare = False
1280
1341
  # Headless claude -p does NOT reliably honour permissions.allow from
1281
1342
  # settings.json for MCP tool calls — it can stall waiting for an
1282
1343
  # approval that will never come in non-interactive mode. All NEXO
@@ -1293,6 +1354,8 @@ def run_automation_prompt(
1293
1354
  run_env = dict(run_env)
1294
1355
  run_env["ANTHROPIC_API_KEY"] = bare_api_key
1295
1356
  else:
1357
+ run_env = dict(run_env)
1358
+ run_env.pop("ANTHROPIC_API_KEY", None)
1296
1359
  cmd.append("--dangerously-skip-permissions")
1297
1360
  if resolved_model:
1298
1361
  cmd.extend(["--model", resolved_model])
@@ -1342,6 +1405,7 @@ def run_automation_prompt(
1342
1405
  telemetry["automation_contract"] = automation_contract
1343
1406
  recorded, record_error = _record_automation_run(
1344
1407
  backend=selected_backend,
1408
+ provider=_automation_provider_for_backend(selected_backend),
1345
1409
  task_profile=task_profile,
1346
1410
  model=resolved_model,
1347
1411
  reasoning_effort=resolved_effort,
@@ -1420,6 +1484,7 @@ def run_automation_prompt(
1420
1484
  telemetry["automation_contract"] = automation_contract
1421
1485
  recorded, record_error = _record_automation_run(
1422
1486
  backend=selected_backend,
1487
+ provider=_automation_provider_for_backend(selected_backend),
1423
1488
  task_profile=task_profile,
1424
1489
  model=resolved_model,
1425
1490
  reasoning_effort=resolved_effort,