@mono-agent/agent-app 0.6.2 → 0.9.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 (313) hide show
  1. package/README.md +259 -19
  2. package/dist/account-home.d.ts +6 -0
  3. package/dist/account-home.d.ts.map +1 -0
  4. package/dist/account-home.js +9 -0
  5. package/dist/account-home.js.map +1 -0
  6. package/dist/adapter-send-proxy.d.ts +18 -0
  7. package/dist/adapter-send-proxy.d.ts.map +1 -0
  8. package/dist/adapter-send-proxy.js +229 -0
  9. package/dist/adapter-send-proxy.js.map +1 -0
  10. package/dist/adapter-send-tools-main.js +62 -4
  11. package/dist/adapter-send-tools-main.js.map +1 -1
  12. package/dist/adapter-send-tools.d.ts +33 -6
  13. package/dist/adapter-send-tools.d.ts.map +1 -1
  14. package/dist/adapter-send-tools.js +485 -87
  15. package/dist/adapter-send-tools.js.map +1 -1
  16. package/dist/app-config.d.ts +3 -1
  17. package/dist/app-config.d.ts.map +1 -1
  18. package/dist/app-config.js +16 -5
  19. package/dist/app-config.js.map +1 -1
  20. package/dist/app.d.ts +27 -0
  21. package/dist/app.d.ts.map +1 -1
  22. package/dist/app.js +761 -147
  23. package/dist/app.js.map +1 -1
  24. package/dist/background-environment.d.ts +16 -0
  25. package/dist/background-environment.d.ts.map +1 -0
  26. package/dist/background-environment.js +68 -0
  27. package/dist/background-environment.js.map +1 -0
  28. package/dist/background-runtime.d.ts +62 -0
  29. package/dist/background-runtime.d.ts.map +1 -0
  30. package/dist/background-runtime.js +1115 -0
  31. package/dist/background-runtime.js.map +1 -0
  32. package/dist/background-snapshot-key.d.ts +13 -0
  33. package/dist/background-snapshot-key.d.ts.map +1 -0
  34. package/dist/background-snapshot-key.js +194 -0
  35. package/dist/background-snapshot-key.js.map +1 -0
  36. package/dist/background-snapshot.d.ts +96 -0
  37. package/dist/background-snapshot.d.ts.map +1 -0
  38. package/dist/background-snapshot.js +450 -0
  39. package/dist/background-snapshot.js.map +1 -0
  40. package/dist/background-worker-lease.d.ts +39 -0
  41. package/dist/background-worker-lease.d.ts.map +1 -0
  42. package/dist/background-worker-lease.js +356 -0
  43. package/dist/background-worker-lease.js.map +1 -0
  44. package/dist/background.d.ts +74 -2
  45. package/dist/background.d.ts.map +1 -1
  46. package/dist/background.js +815 -73
  47. package/dist/background.js.map +1 -1
  48. package/dist/broadcast-recorder.d.ts.map +1 -1
  49. package/dist/broadcast-recorder.js +24 -2
  50. package/dist/broadcast-recorder.js.map +1 -1
  51. package/dist/channel-plugins.d.ts +2 -0
  52. package/dist/channel-plugins.d.ts.map +1 -1
  53. package/dist/channel-plugins.js +8 -0
  54. package/dist/channel-plugins.js.map +1 -1
  55. package/dist/channels.d.ts +10 -0
  56. package/dist/channels.d.ts.map +1 -1
  57. package/dist/channels.js +87 -9
  58. package/dist/channels.js.map +1 -1
  59. package/dist/cli.d.ts +86 -4
  60. package/dist/cli.d.ts.map +1 -1
  61. package/dist/cli.js +2421 -143
  62. package/dist/cli.js.map +1 -1
  63. package/dist/config-reference.d.ts.map +1 -1
  64. package/dist/config-reference.js +448 -6
  65. package/dist/config-reference.js.map +1 -1
  66. package/dist/configuration-proposal-main.d.ts +3 -0
  67. package/dist/configuration-proposal-main.d.ts.map +1 -0
  68. package/dist/configuration-proposal-main.js +13 -0
  69. package/dist/configuration-proposal-main.js.map +1 -0
  70. package/dist/configuration-proposal-tool.d.ts +33 -0
  71. package/dist/configuration-proposal-tool.d.ts.map +1 -0
  72. package/dist/configuration-proposal-tool.js +188 -0
  73. package/dist/configuration-proposal-tool.js.map +1 -0
  74. package/dist/configured-agent.d.ts +16 -0
  75. package/dist/configured-agent.d.ts.map +1 -1
  76. package/dist/configured-agent.js +165 -18
  77. package/dist/configured-agent.js.map +1 -1
  78. package/dist/consumer-contract.d.ts +3 -0
  79. package/dist/consumer-contract.d.ts.map +1 -1
  80. package/dist/consumer-contract.js +44 -1
  81. package/dist/consumer-contract.js.map +1 -1
  82. package/dist/continuation-command.d.ts +15 -0
  83. package/dist/continuation-command.d.ts.map +1 -0
  84. package/dist/continuation-command.js +170 -0
  85. package/dist/continuation-command.js.map +1 -0
  86. package/dist/continuation-config.d.ts +25 -0
  87. package/dist/continuation-config.d.ts.map +1 -0
  88. package/dist/continuation-config.js +236 -0
  89. package/dist/continuation-config.js.map +1 -0
  90. package/dist/continuation-service.d.ts +99 -0
  91. package/dist/continuation-service.d.ts.map +1 -0
  92. package/dist/continuation-service.js +1313 -0
  93. package/dist/continuation-service.js.map +1 -0
  94. package/dist/continuation-store.d.ts +104 -0
  95. package/dist/continuation-store.d.ts.map +1 -0
  96. package/dist/continuation-store.js +688 -0
  97. package/dist/continuation-store.js.map +1 -0
  98. package/dist/continuations.d.ts +167 -0
  99. package/dist/continuations.d.ts.map +1 -0
  100. package/dist/continuations.js +71 -0
  101. package/dist/continuations.js.map +1 -0
  102. package/dist/doctor.d.ts +29 -5
  103. package/dist/doctor.d.ts.map +1 -1
  104. package/dist/doctor.js +1611 -116
  105. package/dist/doctor.js.map +1 -1
  106. package/dist/first-run-managed-memory.d.ts +39 -0
  107. package/dist/first-run-managed-memory.d.ts.map +1 -0
  108. package/dist/first-run-managed-memory.js +584 -0
  109. package/dist/first-run-managed-memory.js.map +1 -0
  110. package/dist/first-run-readiness.d.ts +111 -0
  111. package/dist/first-run-readiness.d.ts.map +1 -0
  112. package/dist/first-run-readiness.js +728 -0
  113. package/dist/first-run-readiness.js.map +1 -0
  114. package/dist/index.d.ts +17 -3
  115. package/dist/index.d.ts.map +1 -1
  116. package/dist/index.js +8 -1
  117. package/dist/index.js.map +1 -1
  118. package/dist/init.d.ts +95 -3
  119. package/dist/init.d.ts.map +1 -1
  120. package/dist/init.js +1218 -24
  121. package/dist/init.js.map +1 -1
  122. package/dist/interaction-bridge.d.ts +25 -1
  123. package/dist/interaction-bridge.d.ts.map +1 -1
  124. package/dist/interaction-bridge.js +281 -19
  125. package/dist/interaction-bridge.js.map +1 -1
  126. package/dist/launchd.d.ts +16 -6
  127. package/dist/launchd.d.ts.map +1 -1
  128. package/dist/launchd.js +30 -15
  129. package/dist/launchd.js.map +1 -1
  130. package/dist/local-configuration.d.ts +99 -0
  131. package/dist/local-configuration.d.ts.map +1 -0
  132. package/dist/local-configuration.js +1299 -0
  133. package/dist/local-configuration.js.map +1 -0
  134. package/dist/managed-runtime-packages.d.ts +9 -0
  135. package/dist/managed-runtime-packages.d.ts.map +1 -0
  136. package/dist/managed-runtime-packages.js +56 -0
  137. package/dist/managed-runtime-packages.js.map +1 -0
  138. package/dist/memory-command.d.ts +4 -0
  139. package/dist/memory-command.d.ts.map +1 -1
  140. package/dist/memory-command.js +751 -27
  141. package/dist/memory-command.js.map +1 -1
  142. package/dist/memory-embedding-service.d.ts +33 -0
  143. package/dist/memory-embedding-service.d.ts.map +1 -0
  144. package/dist/memory-embedding-service.js +207 -0
  145. package/dist/memory-embedding-service.js.map +1 -0
  146. package/dist/memory-recall.d.ts +43 -14
  147. package/dist/memory-recall.d.ts.map +1 -1
  148. package/dist/memory-recall.js +103 -33
  149. package/dist/memory-recall.js.map +1 -1
  150. package/dist/memory-retrieval.d.ts +66 -0
  151. package/dist/memory-retrieval.d.ts.map +1 -0
  152. package/dist/memory-retrieval.js +332 -0
  153. package/dist/memory-retrieval.js.map +1 -0
  154. package/dist/modules/base.d.ts +6 -5
  155. package/dist/modules/base.d.ts.map +1 -1
  156. package/dist/modules/base.js +11 -7
  157. package/dist/modules/base.js.map +1 -1
  158. package/dist/modules/catalog.d.ts.map +1 -1
  159. package/dist/modules/catalog.js +141 -25
  160. package/dist/modules/catalog.js.map +1 -1
  161. package/dist/modules/index.d.ts +2 -2
  162. package/dist/modules/index.d.ts.map +1 -1
  163. package/dist/modules/index.js +1 -1
  164. package/dist/modules/index.js.map +1 -1
  165. package/dist/modules/known-tools.d.ts +3 -0
  166. package/dist/modules/known-tools.d.ts.map +1 -1
  167. package/dist/modules/known-tools.js +11 -1
  168. package/dist/modules/known-tools.js.map +1 -1
  169. package/dist/modules/types.d.ts +20 -6
  170. package/dist/modules/types.d.ts.map +1 -1
  171. package/dist/modules/types.js.map +1 -1
  172. package/dist/notify-runtime.d.ts +26 -0
  173. package/dist/notify-runtime.d.ts.map +1 -0
  174. package/dist/notify-runtime.js +27 -0
  175. package/dist/notify-runtime.js.map +1 -0
  176. package/dist/notify-tool.d.ts +51 -0
  177. package/dist/notify-tool.d.ts.map +1 -0
  178. package/dist/notify-tool.js +182 -0
  179. package/dist/notify-tool.js.map +1 -0
  180. package/dist/package-version.d.ts +3 -0
  181. package/dist/package-version.d.ts.map +1 -0
  182. package/dist/package-version.js +14 -0
  183. package/dist/package-version.js.map +1 -0
  184. package/dist/pi-auth-store-inspection.d.ts +21 -0
  185. package/dist/pi-auth-store-inspection.d.ts.map +1 -0
  186. package/dist/pi-auth-store-inspection.js +120 -0
  187. package/dist/pi-auth-store-inspection.js.map +1 -0
  188. package/dist/pi-oauth-login-main.d.ts +3 -0
  189. package/dist/pi-oauth-login-main.d.ts.map +1 -0
  190. package/dist/pi-oauth-login-main.js +14 -0
  191. package/dist/pi-oauth-login-main.js.map +1 -0
  192. package/dist/pi-oauth-login.d.ts +22 -0
  193. package/dist/pi-oauth-login.d.ts.map +1 -0
  194. package/dist/pi-oauth-login.js +79 -0
  195. package/dist/pi-oauth-login.js.map +1 -0
  196. package/dist/proactive-notify.d.ts +2 -0
  197. package/dist/proactive-notify.d.ts.map +1 -1
  198. package/dist/proactive-notify.js +1 -0
  199. package/dist/proactive-notify.js.map +1 -1
  200. package/dist/process-incarnation.d.ts +23 -0
  201. package/dist/process-incarnation.d.ts.map +1 -0
  202. package/dist/process-incarnation.js +208 -0
  203. package/dist/process-incarnation.js.map +1 -0
  204. package/dist/project-skills.d.ts +40 -0
  205. package/dist/project-skills.d.ts.map +1 -0
  206. package/dist/project-skills.js +573 -0
  207. package/dist/project-skills.js.map +1 -0
  208. package/dist/provider-setup.d.ts +117 -2
  209. package/dist/provider-setup.d.ts.map +1 -1
  210. package/dist/provider-setup.js +1505 -49
  211. package/dist/provider-setup.js.map +1 -1
  212. package/dist/readiness-probe-worker.d.ts +12 -0
  213. package/dist/readiness-probe-worker.d.ts.map +1 -0
  214. package/dist/readiness-probe-worker.js +276 -0
  215. package/dist/readiness-probe-worker.js.map +1 -0
  216. package/dist/readiness-probe.d.ts +100 -0
  217. package/dist/readiness-probe.d.ts.map +1 -0
  218. package/dist/readiness-probe.js +780 -0
  219. package/dist/readiness-probe.js.map +1 -0
  220. package/dist/recipes/base.d.ts +15 -0
  221. package/dist/recipes/base.d.ts.map +1 -0
  222. package/dist/recipes/base.js +51 -0
  223. package/dist/recipes/base.js.map +1 -0
  224. package/dist/recipes/catalog.d.ts +4 -0
  225. package/dist/recipes/catalog.d.ts.map +1 -0
  226. package/dist/recipes/catalog.js +525 -0
  227. package/dist/recipes/catalog.js.map +1 -0
  228. package/dist/recipes/index.d.ts +11 -0
  229. package/dist/recipes/index.d.ts.map +1 -0
  230. package/dist/recipes/index.js +14 -0
  231. package/dist/recipes/index.js.map +1 -0
  232. package/dist/recipes/types.d.ts +70 -0
  233. package/dist/recipes/types.d.ts.map +1 -0
  234. package/dist/recipes/types.js +15 -0
  235. package/dist/recipes/types.js.map +1 -0
  236. package/dist/request-model-override.d.ts +43 -1
  237. package/dist/request-model-override.d.ts.map +1 -1
  238. package/dist/request-model-override.js +178 -19
  239. package/dist/request-model-override.js.map +1 -1
  240. package/dist/run-history.d.ts +32 -0
  241. package/dist/run-history.d.ts.map +1 -0
  242. package/dist/run-history.js +859 -0
  243. package/dist/run-history.js.map +1 -0
  244. package/dist/runs-health.d.ts.map +1 -1
  245. package/dist/runs-health.js +12 -4
  246. package/dist/runs-health.js.map +1 -1
  247. package/dist/runtime-option-extensions.d.ts +14 -0
  248. package/dist/runtime-option-extensions.d.ts.map +1 -0
  249. package/dist/runtime-option-extensions.js +98 -0
  250. package/dist/runtime-option-extensions.js.map +1 -0
  251. package/dist/runtime-routes.d.ts +9 -0
  252. package/dist/runtime-routes.d.ts.map +1 -0
  253. package/dist/runtime-routes.js +13 -0
  254. package/dist/runtime-routes.js.map +1 -0
  255. package/dist/sandbox-manager.d.ts +86 -0
  256. package/dist/sandbox-manager.d.ts.map +1 -0
  257. package/dist/sandbox-manager.js +1402 -0
  258. package/dist/sandbox-manager.js.map +1 -0
  259. package/dist/setup.d.ts +29 -0
  260. package/dist/setup.d.ts.map +1 -0
  261. package/dist/setup.js +97 -0
  262. package/dist/setup.js.map +1 -0
  263. package/dist/supermemory-plugin.d.ts +56 -0
  264. package/dist/supermemory-plugin.d.ts.map +1 -0
  265. package/dist/supermemory-plugin.js +142 -0
  266. package/dist/supermemory-plugin.js.map +1 -0
  267. package/dist/tui-command.d.ts +56 -0
  268. package/dist/tui-command.d.ts.map +1 -1
  269. package/dist/tui-command.js +305 -31
  270. package/dist/tui-command.js.map +1 -1
  271. package/dist/web-command.d.ts +8 -0
  272. package/dist/web-command.d.ts.map +1 -1
  273. package/dist/web-command.js +174 -17
  274. package/dist/web-command.js.map +1 -1
  275. package/dist/wizard/answers.d.ts +31 -4
  276. package/dist/wizard/answers.d.ts.map +1 -1
  277. package/dist/wizard/answers.js +141 -29
  278. package/dist/wizard/answers.js.map +1 -1
  279. package/dist/wizard/from-flags.d.ts +7 -0
  280. package/dist/wizard/from-flags.d.ts.map +1 -1
  281. package/dist/wizard/from-flags.js +36 -2
  282. package/dist/wizard/from-flags.js.map +1 -1
  283. package/dist/wizard/index.d.ts +4 -4
  284. package/dist/wizard/index.d.ts.map +1 -1
  285. package/dist/wizard/index.js +2 -2
  286. package/dist/wizard/index.js.map +1 -1
  287. package/dist/wizard/model-discovery.d.ts +33 -2
  288. package/dist/wizard/model-discovery.d.ts.map +1 -1
  289. package/dist/wizard/model-discovery.js +826 -108
  290. package/dist/wizard/model-discovery.js.map +1 -1
  291. package/dist/wizard/presets.d.ts +1 -1
  292. package/dist/wizard/presets.d.ts.map +1 -1
  293. package/dist/wizard/presets.js +7 -12
  294. package/dist/wizard/presets.js.map +1 -1
  295. package/dist/wizard/prompts.d.ts +56 -9
  296. package/dist/wizard/prompts.d.ts.map +1 -1
  297. package/dist/wizard/prompts.js +172 -20
  298. package/dist/wizard/prompts.js.map +1 -1
  299. package/dist/wizard/run.d.ts +59 -3
  300. package/dist/wizard/run.d.ts.map +1 -1
  301. package/dist/wizard/run.js +1290 -191
  302. package/dist/wizard/run.js.map +1 -1
  303. package/package.json +21 -16
  304. package/resources/srt/package-lock.json +66 -0
  305. package/resources/srt/package.json +9 -0
  306. package/schema/mono-agent.config.schema.json +587 -20
  307. package/skills/mono-agent-composer/SKILL.md +6 -6
  308. package/skills/mono-agent-composer/references/config-blueprint.md +11 -10
  309. package/skills/mono-agent-composer/references/discovery-questions.md +33 -18
  310. package/skills/mono-agent-composer/references/feature-coverage.md +14 -12
  311. package/skills/mono-agent-composer/references/package-map.md +2 -2
  312. package/skills/mono-agent-composer/references/playbooks.md +8 -7
  313. package/skills/mono-agent-composer/references/validation.md +3 -3
package/dist/doctor.js CHANGED
@@ -1,9 +1,15 @@
1
+ import { execFile as execFileCallback } from "node:child_process";
1
2
  import { constants } from "node:fs";
2
- import { access, mkdir, readFile, stat } from "node:fs/promises";
3
- import { dirname, join } from "node:path";
3
+ import { access, lstat, mkdir, readdir, readFile, stat } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { isIP } from "node:net";
6
+ import { join } from "node:path";
7
+ import { promisify } from "node:util";
8
+ import { getBuiltinModels as getPiBuiltinModels, getBuiltinProviders as getPiBuiltinProviders, } from "@earendil-works/pi-ai/providers/all";
4
9
  import { listRecordedRuns } from "@mono-agent/observability";
5
10
  import { serializeTraceSpans } from "@mono-agent/observability/otel";
6
- import { describeMonoRuntimeSupport, modelReferenceKey, parseMonoRuntimeModelReference } from "@mono-agent/runtime-adapter";
11
+ import { classifyContinuationMcpServerTransport, isStdioMcpServerSpec, loadToolPolicyFromJsonFile, } from "@mono-agent/agent-harness";
12
+ import { defaultExecutionModeForModel, describeMonoRuntimeSupport, isValidMcpServerName, modelReferenceKey, networkPolicyAllowsUrl, parseMonoRuntimeModelReference, resolveModelEffortLevels, } from "@mono-agent/runtime-adapter";
7
13
  import { buildMonoAgentConfigView, findJsonSecretConfigWarnings, findRemovedConfigWarnings, readMonoAgentConfigJson, redactMonoAgentConfig, resolveSupermemoryContainer, } from "@mono-agent/config";
8
14
  import { describeSandboxEffectiveState, resolveSandboxEffectiveState, sandboxEffectiveStateWarning, } from "@mono-agent/runtime-adapter";
9
15
  import { describeSensitiveDataExportWarning, isAppCoreConfigError, loadAppCoreConfig, phoenixAppBaseUrl, resolveAppArtifactDir, resolveAppObservabilityExporters, } from "./app-config.js";
@@ -12,8 +18,19 @@ import { canonicalToolName, isAllowAllTools, isKnownToolName, isMcpToolName, sug
12
18
  import { collectChannelConfigViews } from "./channel-config-view.js";
13
19
  import { resolveChannelDrivers } from "./channels.js";
14
20
  import { findUnknownAppConfigWarnings } from "./config-reference.js";
21
+ import { loadContinuationSettings } from "./continuation-config.js";
22
+ import { CONTINUATION_RECORD_STORE_SCHEMA_VERSION, CONTINUATION_STORE_SCHEMA_VERSION, } from "./continuation-store.js";
23
+ import { CONTINUATION_STATES } from "./continuations.js";
24
+ import { formatInteractionBridgeUrl, loadInteractionSettings } from "./interaction-bridge.js";
25
+ import { FIRST_RUN_MEMORY_INITIALIZING_MARKER } from "./first-run-managed-memory.js";
26
+ import { DEFAULT_MEMORY_EMBEDDING_ENDPOINTS, probeMemoryEmbeddingSelection, } from "./memory-embedding-service.js";
15
27
  import { buildRunsHealthDisplay, RUNS_HEALTH_MAX_RUNS } from "./runs-health.js";
16
- import { piAuthWorkingDirectory, piLoginCommandLine } from "./provider-setup.js";
28
+ import { piAuthRecoveryCommand } from "./provider-setup.js";
29
+ import { inspectPiAuthStore } from "./pi-auth-store-inspection.js";
30
+ import { checkManagedProjectSkills, managedProjectSkillsExist } from "./project-skills.js";
31
+ import { configuredRuntimeFallbackModels, configuredRuntimeModels } from "./runtime-routes.js";
32
+ import { loadSupermemoryPlugin } from "./supermemory-plugin.js";
33
+ const execFile = promisify(execFileCallback);
17
34
  /**
18
35
  * Loads every config section the app would use at start and reports it
19
36
  * per-section, so an engineer can see exactly what would run, wait, or fail —
@@ -36,6 +53,7 @@ export async function validateMonoAgentFolder(options) {
36
53
  sections.push({ id: "core", label: "Core config", status: "error", details: [error.message] });
37
54
  }
38
55
  if (coreConfig !== undefined) {
56
+ const staticTriggerCredentialRefs = await collectStaticTriggerCredentialRefs(drivers, options);
39
57
  const jsonResult = await readMonoAgentConfigJson(options.configPath);
40
58
  // Channel secrets (bot tokens, API keys) live outside the core view, so the
41
59
  // placement check spans both: core sections + every channel's config view.
@@ -55,10 +73,11 @@ export async function validateMonoAgentFolder(options) {
55
73
  sections.push({ id: "secret-placement", label: "Config warnings", status: "waiting", details: configWarnings });
56
74
  }
57
75
  sections.push(runtimeSection(coreConfig));
58
- sections.push(await credentialsSection(coreConfig, options.env));
59
- sections.push(await contextSection(coreConfig));
60
- sections.push(await memorySection(coreConfig, liveness, allowFilesystemWrites));
76
+ sections.push(await credentialsSection(coreConfig, options.env, options.cwd, liveness, options.verifiedCredentialModelRefs, options.sdkAuthStatusExecFile, staticTriggerCredentialRefs));
77
+ sections.push(await contextSection(coreConfig, options.cwd));
78
+ sections.push(await memorySection(coreConfig, options.cwd, options.env, liveness, allowFilesystemWrites, options.preferAppPluginInstall === true));
61
79
  sections.push(await toolsSection(coreConfig, options));
80
+ sections.push(await continuationSection(coreConfig, options));
62
81
  sections.push(await sandboxSection(coreConfig, options.sandboxEngine));
63
82
  }
64
83
  sections.push(await exporterSection(options, liveness));
@@ -66,6 +85,9 @@ export async function validateMonoAgentFolder(options) {
66
85
  for (const driver of drivers) {
67
86
  sections.push(await channelSection(driver, options));
68
87
  }
88
+ if (coreConfig !== undefined) {
89
+ await applyRequestModelOverrideCompatibilityChecks(sections, coreConfig, drivers, options);
90
+ }
69
91
  // Cross-check the built `channel:*` statuses against the tool policy and annotate
70
92
  // the tools section (send-tool-allowed-but-channel-disabled, or channel-enabled-
71
93
  // but-no-send-tool). Only meaningful once coreConfig — and thus the tools section
@@ -78,6 +100,236 @@ export async function validateMonoAgentFolder(options) {
78
100
  ok: sections.every((section) => section.status !== "error"),
79
101
  };
80
102
  }
103
+ /** Enabled, statically configured webhook/cron entries that can actually execute. */
104
+ function staticTriggerConfigEntries(driverId, loaded) {
105
+ if (!isUnknownRecord(loaded))
106
+ return [];
107
+ if (driverId === "webhook" && loaded.enabled === false)
108
+ return [];
109
+ const entries = driverId === "webhook" ? loaded.endpoints : loaded.jobs;
110
+ if (!Array.isArray(entries))
111
+ return [];
112
+ return entries.flatMap((entry, index) => {
113
+ if (!isUnknownRecord(entry) || entry.enabled === false)
114
+ return [];
115
+ return [{
116
+ entryPath: `${driverId}.${driverId === "webhook" ? "endpoints" : "jobs"}[${index}]`,
117
+ entry,
118
+ }];
119
+ });
120
+ }
121
+ /**
122
+ * Collect static trigger model overrides for credential readiness. Dynamic
123
+ * webhook request-body overrides are intentionally unavailable at validate time.
124
+ */
125
+ async function collectStaticTriggerCredentialRefs(drivers, input) {
126
+ const refs = [];
127
+ const seen = new Set();
128
+ for (const driver of drivers) {
129
+ if (driver.id !== "webhook" && driver.id !== "cron")
130
+ continue;
131
+ let loaded;
132
+ try {
133
+ loaded = await driver.loadConfig(input);
134
+ }
135
+ catch {
136
+ // The channel section owns malformed/unreadable config diagnostics.
137
+ continue;
138
+ }
139
+ for (const { entryPath, entry } of staticTriggerConfigEntries(driver.id, loaded)) {
140
+ if (typeof entry.model !== "string")
141
+ continue;
142
+ try {
143
+ const ref = parseMonoRuntimeModelReference(entry.model);
144
+ const key = `${entryPath}\u0000${modelReferenceKey(ref)}`;
145
+ if (seen.has(key))
146
+ continue;
147
+ seen.add(key);
148
+ refs.push({ label: entryPath, ref });
149
+ }
150
+ catch {
151
+ // The channel section owns invalid model-reference syntax.
152
+ }
153
+ }
154
+ }
155
+ return refs;
156
+ }
157
+ async function applyRequestModelOverrideCompatibilityChecks(sections, config, drivers, input) {
158
+ const baseIsDirectCodex = config.runtime.model.sdk === "codex";
159
+ const routeSafety = config.runtime.routeSafety ?? "uniform";
160
+ const directBoundaryConflicts = [];
161
+ const sandboxBypasses = [];
162
+ const toolPolicyBypasses = [];
163
+ const effortBypasses = [];
164
+ const turnCapBypasses = [];
165
+ const mcpBypasses = [];
166
+ const skillBypasses = [];
167
+ const piModelResolutionFailures = [];
168
+ const monoSandboxActive = config.sandbox !== undefined && config.sandbox.mode !== "off";
169
+ const restrictiveToolPolicy = !hasExactAllowAllToolPolicy(config.tools);
170
+ let configuredMcpServerNames = [];
171
+ if (config.tools.mcpConfigPath !== undefined) {
172
+ try {
173
+ configuredMcpServerNames = Object.keys((await loadToolPolicyFromJsonFile(config.tools.mcpConfigPath)).mcpServers ?? {});
174
+ }
175
+ catch {
176
+ // The tools section owns missing/malformed MCP-file diagnostics.
177
+ }
178
+ }
179
+ let adapterToolNames = [];
180
+ try {
181
+ const settings = await resolveAdapterSendToolsSettings(input, {
182
+ allowedTools: config.tools.allowedTools,
183
+ disallowedTools: config.tools.disallowedTools,
184
+ });
185
+ if (settings !== undefined)
186
+ adapterToolNames = adapterSendToolNames(settings);
187
+ }
188
+ catch {
189
+ // Channel/tools sections own adapter config diagnostics.
190
+ }
191
+ const effectiveMcpSources = effectiveMcpRuntimeSources(config, configuredMcpServerNames, adapterToolNames);
192
+ for (const driver of drivers) {
193
+ if (driver.id !== "webhook" && driver.id !== "cron")
194
+ continue;
195
+ let loaded;
196
+ try {
197
+ loaded = await driver.loadConfig(input);
198
+ }
199
+ catch {
200
+ // The channel section already reports malformed/unreadable config.
201
+ continue;
202
+ }
203
+ for (const { entryPath, entry } of staticTriggerConfigEntries(driver.id, loaded)) {
204
+ const hasModelOverride = typeof entry.model === "string";
205
+ const hasEffortOverride = typeof entry.effort === "string";
206
+ if (!hasModelOverride && !hasEffortOverride)
207
+ continue;
208
+ try {
209
+ const model = hasModelOverride
210
+ ? parseMonoRuntimeModelReference(entry.model)
211
+ : config.runtime.model;
212
+ const location = hasModelOverride
213
+ ? `${entryPath}.model=${entry.model}`
214
+ : `${entryPath}.effort=${entry.effort}`;
215
+ if (hasModelOverride) {
216
+ const resolutionIssue = piModelResolutionIssue(config, model);
217
+ if (resolutionIssue !== undefined) {
218
+ piModelResolutionFailures.push(`${location}: ${resolutionIssue}.`);
219
+ }
220
+ }
221
+ if (routeSafety === "uniform" && hasModelOverride && (model.sdk === "codex") !== baseIsDirectCodex) {
222
+ directBoundaryConflicts.push(location);
223
+ }
224
+ if (routeSafety === "uniform" && hasModelOverride && monoSandboxActive && (model.sdk === "claude" || model.sdk === "opencode" || model.sdk === "codex")) {
225
+ sandboxBypasses.push(location);
226
+ }
227
+ if (hasModelOverride && restrictiveToolPolicy && model.sdk === "opencode") {
228
+ toolPolicyBypasses.push(location);
229
+ }
230
+ const effectiveEffort = hasEffortOverride ? entry.effort : config.runtime.effort;
231
+ const legacyFallbacks = (config.runtime.fallbacks?.length ?? 0) > 0
232
+ ? []
233
+ : config.runtime.fallbackModels ?? [];
234
+ const directOpenCodeModels = [model, ...legacyFallbacks]
235
+ .filter((candidate) => candidate.sdk === "opencode");
236
+ if (directOpenCodeModels.length > 0 && effectiveEffort !== undefined) {
237
+ effortBypasses.push(`${location} (effective effort=${effectiveEffort}) (direct OpenCode route=${directOpenCodeModels.map(referenceOf).join(", ")})`);
238
+ }
239
+ if (directOpenCodeModels.length > 0 && Number(config.runtime.maxTurns) > 0) {
240
+ turnCapBypasses.push(`${location} (runtime.maxTurns=${config.runtime.maxTurns}; direct OpenCode route=${directOpenCodeModels.map(referenceOf).join(", ")})`);
241
+ }
242
+ if (directOpenCodeModels.length > 0 && effectiveMcpSources.length > 0) {
243
+ mcpBypasses.push(`${location} (direct OpenCode route=${directOpenCodeModels.map(referenceOf).join(", ")}; MCP sources=${effectiveMcpSources.join("; ")})`);
244
+ }
245
+ if (directOpenCodeModels.length > 0
246
+ && config.context.skillDisclosure === "index"
247
+ && config.context.skillsRoot !== undefined) {
248
+ skillBypasses.push(location);
249
+ }
250
+ }
251
+ catch {
252
+ // Adapter configIssues owns syntax diagnostics.
253
+ }
254
+ }
255
+ }
256
+ if (directBoundaryConflicts.length === 0
257
+ && sandboxBypasses.length === 0
258
+ && toolPolicyBypasses.length === 0
259
+ && effortBypasses.length === 0
260
+ && turnCapBypasses.length === 0
261
+ && mcpBypasses.length === 0
262
+ && skillBypasses.length === 0
263
+ && piModelResolutionFailures.length === 0)
264
+ return;
265
+ const index = sections.findIndex((section) => section.id === "runtime");
266
+ if (index < 0)
267
+ return;
268
+ const runtime = sections[index];
269
+ sections[index] = {
270
+ ...runtime,
271
+ status: "error",
272
+ details: [
273
+ ...runtime.details,
274
+ ...(directBoundaryConflicts.length === 0
275
+ ? []
276
+ : [
277
+ "Uniform route safety cannot cross the direct-Codex runtime boundary because tool and sandbox contracts would change mid-agent. Choose per-route-native to opt into explicit route-local contracts.",
278
+ ...directBoundaryConflicts,
279
+ ]),
280
+ ...(sandboxBypasses.length === 0
281
+ ? []
282
+ : [
283
+ "Claude or direct OpenCode model overrides cannot run under uniform route safety while mono-agent SRT is active; direct Codex is also provider-owned. Choose per-route-native only after reviewing the explicit route-local contracts.",
284
+ ...sandboxBypasses,
285
+ ]),
286
+ ...(toolPolicyBypasses.length === 0
287
+ ? []
288
+ : [
289
+ "Per-trigger direct OpenCode model overrides require exact allow-all because OpenCode's provider-owned tool loop does not consume mono-agent allowedTools/disallowedTools.",
290
+ ...toolPolicyBypasses,
291
+ ]),
292
+ ...(effortBypasses.length === 0
293
+ ? []
294
+ : [
295
+ "Per-trigger direct OpenCode routes cannot receive runtime effort because the OpenCode SDK does not expose effort control.",
296
+ ...effortBypasses,
297
+ ]),
298
+ ...(turnCapBypasses.length === 0
299
+ ? []
300
+ : [
301
+ "Per-trigger direct OpenCode routes cannot enforce runtime.maxTurns; omit it, set it to 0, or use a runtime with a hard turn cap.",
302
+ ...turnCapBypasses,
303
+ ]),
304
+ ...(mcpBypasses.length === 0
305
+ ? []
306
+ : [
307
+ "Per-trigger direct OpenCode routes cannot receive configured or auto-provisioned MCP runtime options.",
308
+ ...mcpBypasses,
309
+ ]),
310
+ ...(skillBypasses.length === 0
311
+ ? []
312
+ : [
313
+ "Per-trigger direct OpenCode routes cannot use index skill disclosure because the bridge disables external/runtime skills; use full disclosure or a Pi runtime.",
314
+ ...skillBypasses,
315
+ ]),
316
+ ...(piModelResolutionFailures.length === 0
317
+ ? []
318
+ : [
319
+ "Per-trigger Pi model overrides must resolve through providers.local or Pi's exact built-in catalog before execution.",
320
+ ...piModelResolutionFailures,
321
+ ]),
322
+ ],
323
+ };
324
+ }
325
+ function isUnknownRecord(value) {
326
+ return typeof value === "object" && value !== null && !Array.isArray(value);
327
+ }
328
+ function hasExactAllowAllToolPolicy(tools) {
329
+ return tools.allowedTools.length === 1
330
+ && tools.allowedTools[0] === "*"
331
+ && tools.disallowedTools.length === 0;
332
+ }
81
333
  /** Adapter send tools each channel owns; an allowed entry needs BOTH the tool AND the enabled channel. */
82
334
  const CHANNEL_OWNED_SEND_TOOLS = {
83
335
  slack: ["SlackSendMessage"],
@@ -141,55 +393,136 @@ function applyToolChannelCrossChecks(sections, allowedTools, disallowedTools) {
141
393
  function runtimeSection(config) {
142
394
  const details = [];
143
395
  let status = "ok";
144
- const primary = describeMonoRuntimeSupport(config.runtime.model, config.runtime.executionMode);
145
- if (primary.compatible) {
146
- details.push(`Primary model ${referenceOf(config.runtime.model)} runs on ${primary.backend?.label ?? "unknown backend"} (${config.runtime.executionMode}).`);
396
+ const routes = configuredRuntimeRouteChecks(config);
397
+ const routeSafety = config.runtime.routeSafety ?? "uniform";
398
+ details.push(`Route safety: ${routeSafety}.`);
399
+ if (routes.length > 1) {
400
+ details.push(routeSafety === "per-route-native"
401
+ ? "Mixed runtime families are allowed; every attempt uses its explicit route-native safety contract."
402
+ : "Fallback routes use the uniform compatibility contract; validation fails closed when any route cannot represent a required capability.");
403
+ }
404
+ const directOpenCodeModels = routes.filter((route) => route.model.sdk === "opencode");
405
+ if (Number(config.runtime.maxTurns) > 0 && directOpenCodeModels.length > 0) {
406
+ status = "error";
407
+ details.push(`Direct OpenCode model${directOpenCodeModels.length === 1 ? "" : "s"} ${directOpenCodeModels.map((route) => referenceOf(route.model)).join(", ")} cannot enforce runtime.maxTurns=${config.runtime.maxTurns}; omit it, set it to 0, or use a runtime with a hard turn cap.`);
147
408
  }
148
- else {
409
+ if (directOpenCodeModels.length > 0
410
+ && config.context.skillDisclosure === "index"
411
+ && config.context.skillsRoot !== undefined) {
149
412
  status = "error";
150
- details.push(`Primary model ${referenceOf(config.runtime.model)}: ${primary.incompatibilityReason ?? "unsupported"}.`);
413
+ details.push(`Direct OpenCode model${directOpenCodeModels.length === 1 ? "" : "s"} ${directOpenCodeModels.map((route) => referenceOf(route.model)).join(", ")} cannot use context.skillDisclosure=index because runtime skills are disabled; use full disclosure or a Pi runtime.`);
151
414
  }
152
- for (const fallback of config.runtime.fallbackModels ?? []) {
153
- const support = describeMonoRuntimeSupport(fallback);
154
- if (support.compatible) {
155
- details.push(`Fallback model ${referenceOf(fallback)} runs on ${support.backend?.label ?? "unknown backend"}.`);
415
+ for (const route of routes) {
416
+ try {
417
+ const support = describeMonoRuntimeSupport(route.model, route.executionMode);
418
+ const resolutionIssue = piModelResolutionIssue(config, route.model);
419
+ const effortIssue = runtimeRouteEffortIssue(config, route);
420
+ if (support.compatible && resolutionIssue === undefined) {
421
+ details.push(`${route.label} ${referenceOf(route.model)} runs on ${support.backend?.label ?? "unknown backend"} ` +
422
+ `(effort: ${route.effort ?? "provider default"}).`);
423
+ }
424
+ else {
425
+ status = "error";
426
+ details.push(`${route.label} ${referenceOf(route.model)}: ${resolutionIssue ?? support.incompatibilityReason ?? "unsupported"}.`);
427
+ }
428
+ if (effortIssue !== undefined) {
429
+ status = "error";
430
+ details.push(`${route.label} ${referenceOf(route.model)}: ${effortIssue}`);
431
+ }
156
432
  }
157
- else {
433
+ catch (error) {
158
434
  status = "error";
159
- details.push(`Fallback model ${referenceOf(fallback)}: ${support.incompatibilityReason ?? "unsupported"}.`);
435
+ details.push(`${route.label} ${referenceOf(route.model)}: ${error instanceof Error ? error.message : String(error)}.`);
160
436
  }
161
437
  }
162
438
  return { id: "runtime", label: "Runtime", status, details };
163
439
  }
164
- const PI_API_KEY_ENV_BY_PROVIDER = {
165
- "opencode-go": "OPENCODE_API_KEY",
166
- };
167
- /** Parses the Pi OAuth auth store (provider -> credentials) at `path`, best-effort. */
168
- async function readPiAuthProviders(path) {
169
- try {
170
- const parsed = JSON.parse(await readFile(path, "utf8"));
171
- if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
172
- return parsed;
173
- }
440
+ function configuredRuntimeRouteChecks(config) {
441
+ const primary = {
442
+ label: "Primary model",
443
+ model: config.runtime.model,
444
+ ...(config.runtime.effort === undefined ? {} : { effort: config.runtime.effort }),
445
+ executionMode: config.runtime.executionMode,
446
+ };
447
+ if ((config.runtime.fallbacks?.length ?? 0) > 0) {
448
+ return [
449
+ primary,
450
+ ...(config.runtime.fallbacks ?? []).map((fallback) => ({
451
+ label: "Fallback model",
452
+ model: fallback.model,
453
+ ...(fallback.effort === undefined ? {} : { effort: fallback.effort }),
454
+ })),
455
+ ];
174
456
  }
175
- catch {
176
- // Missing/invalid auth file is reported per-provider below, not thrown here.
457
+ return [
458
+ primary,
459
+ ...(config.runtime.fallbackModels ?? []).map((model) => ({
460
+ label: "Fallback model",
461
+ model,
462
+ ...(config.runtime.effort === undefined ? {} : { effort: config.runtime.effort }),
463
+ })),
464
+ ];
465
+ }
466
+ function runtimeRouteEffortIssue(config, route) {
467
+ if (route.effort === undefined)
468
+ return undefined;
469
+ if (route.model.sdk === "opencode") {
470
+ return `Direct OpenCode model ${referenceOf(route.model)} cannot receive runtime.effort=${route.effort}; the OpenCode SDK exposes no reasoning-effort input. Omit the route effort.`;
471
+ }
472
+ const metadata = resolveModelEffortLevels(route.model, config.providers?.local);
473
+ if (metadata.effortLevels !== undefined && !metadata.effortLevels.includes(route.effort)) {
474
+ return `effort=${route.effort} is unsupported by known model metadata; choose ${metadata.effortLevels.join(", ")}, or omit it for provider default.`;
475
+ }
476
+ if (metadata.reasoning === false && route.effort !== "none") {
477
+ return `effort=${route.effort} is unsupported because known model metadata marks this route as non-reasoning; use none or provider default.`;
478
+ }
479
+ if (route.model.sdk === "claude"
480
+ && (route.executionMode ?? defaultExecutionModeForModel(route.model)) === "sdk"
481
+ && !["low", "medium", "high", "xhigh", "max"].includes(route.effort)) {
482
+ return `effort=${route.effort} is unsupported by the Claude Agent SDK; choose low, medium, high, xhigh, max, or provider default.`;
177
483
  }
178
484
  return undefined;
179
485
  }
180
- /** Provider ids configured via the sibling `models.json` (custom/local providers need no OAuth). */
181
- async function readPiCustomProviders(authPath) {
182
- try {
183
- const parsed = JSON.parse(await readFile(join(dirname(authPath), "models.json"), "utf8"));
184
- const providers = parsed?.providers;
185
- if (providers !== null && typeof providers === "object" && !Array.isArray(providers)) {
186
- return new Set(Object.keys(providers));
486
+ /**
487
+ * Mirrors the Pi runtime's actual model-resolution boundary. Mono-agent only
488
+ * registers a custom Pi provider from `providers.local`; it deliberately does
489
+ * not import Pi CLI's ambient sibling `models.json`. Without a matching local
490
+ * provider, `resolvePiRuntimeModel` performs an exact built-in catalog lookup.
491
+ */
492
+ function piModelResolutionIssue(config, model) {
493
+ if (model.sdk !== "pi" || model.provider === undefined) {
494
+ return undefined;
495
+ }
496
+ const localProvider = config.providers?.local?.find((provider) => provider.id === model.provider);
497
+ if (localProvider !== undefined) {
498
+ if (localProvider.enabled === false) {
499
+ return `provider \`${model.provider}\` is disabled in providers.local`;
187
500
  }
501
+ const localModel = localProvider.models?.find((candidate) => candidate.name === model.model || candidate.alias === model.model);
502
+ if (localModel?.enabled === false) {
503
+ return `model \`${model.model}\` is disabled in providers.local for provider \`${model.provider}\``;
504
+ }
505
+ return undefined;
188
506
  }
189
- catch {
190
- // No models.json is fine; such providers simply won't be marked custom.
507
+ if (isPiBuiltinProvider(model.provider)
508
+ && getPiBuiltinModels(model.provider).some((candidate) => candidate.id === model.model)) {
509
+ return undefined;
191
510
  }
192
- return new Set();
511
+ return (`pi model not found: ${model.provider}:${model.model}; no matching providers.local entry exists and Pi's built-in catalog has no exact model. ` +
512
+ "The sibling Pi CLI models.json is not a mono-agent runtime source; add providers.local for a self-hosted provider or choose a built-in Pi model");
513
+ }
514
+ function isPiBuiltinProvider(provider) {
515
+ return getPiBuiltinProviders().includes(provider);
516
+ }
517
+ const PI_API_KEY_ENV_BY_PROVIDER = {
518
+ "opencode-go": "OPENCODE_API_KEY",
519
+ };
520
+ /** Inspects Pi credentials without following aliases or reading unbounded input. */
521
+ async function readPiAuthProviders(path) {
522
+ const inspection = await inspectPiAuthStore(path);
523
+ if (inspection.status !== "ok")
524
+ return inspection;
525
+ return { status: "ok", providers: inspection.auth };
193
526
  }
194
527
  /**
195
528
  * What each SDK-authenticated backend truthfully reads for credentials. Only the
@@ -215,14 +548,165 @@ const SDK_AUTH_SCHEMES = {
215
548
  loginCommand: "claude /login",
216
549
  loginDetail: "a `claude /login` subscription session or a Bedrock/Vertex configuration (CLAUDE_CODE_USE_BEDROCK/VERTEX)",
217
550
  failureHint: 'the first turn fails with an opaque "Claude Code process exited with code 1" that names nothing',
551
+ statusCommand: "claude auth status --json",
552
+ statusArgs: ["auth", "status", "--json"],
218
553
  },
219
554
  codex: {
220
555
  envKeys: ["OPENAI_API_KEY"],
221
556
  loginCommand: "codex login",
222
557
  loginDetail: "a `codex login` ChatGPT session (`~/.codex/auth.json`, outside the environment)",
223
558
  failureHint: "the first turn fails to authenticate",
559
+ statusCommand: "codex login status",
560
+ statusArgs: ["login", "status"],
224
561
  },
225
562
  };
563
+ const SDK_AUTH_STATUS_TIMEOUT_MS = 5_000;
564
+ const SDK_AUTH_STATUS_MAX_BUFFER_BYTES = 64 * 1024;
565
+ function isSdkAuthName(value) {
566
+ return value === "claude" || value === "codex";
567
+ }
568
+ const defaultSdkAuthStatusExecFile = async (file, args, options) => {
569
+ const { stdout } = await execFile(file, [...args], options);
570
+ return { stdout };
571
+ };
572
+ /**
573
+ * Performs the SDK's local, read-only login-status command. A zero Codex exit
574
+ * confirms its external login; Claude additionally requires strict JSON with
575
+ * `loggedIn: true`. Missing binaries, timeouts, non-zero exits, and malformed
576
+ * output all fail closed without leaking command output into validation.
577
+ */
578
+ async function checkSdkExternalLoginStatus(sdk, env, cwd, run) {
579
+ const scheme = SDK_AUTH_SCHEMES[sdk];
580
+ try {
581
+ const { stdout } = await run(sdk, scheme.statusArgs, {
582
+ cwd,
583
+ env,
584
+ timeout: SDK_AUTH_STATUS_TIMEOUT_MS,
585
+ maxBuffer: SDK_AUTH_STATUS_MAX_BUFFER_BYTES,
586
+ encoding: "utf8",
587
+ });
588
+ if (sdk === "codex") {
589
+ return true;
590
+ }
591
+ const parsed = JSON.parse(stdout);
592
+ return parsed !== null
593
+ && typeof parsed === "object"
594
+ && !Array.isArray(parsed)
595
+ && parsed.loggedIn === true;
596
+ }
597
+ catch {
598
+ return false;
599
+ }
600
+ }
601
+ /** Read provider IDs directly from auth.json without launching mutation-capable OpenCode middleware. */
602
+ async function inspectOpenCodeCredentialProviders(env) {
603
+ if (env.OPENCODE_AUTH_CONTENT !== undefined) {
604
+ return { status: "inline_auth_unsupported" };
605
+ }
606
+ const dataHome = typeof env.XDG_DATA_HOME === "string" && env.XDG_DATA_HOME.length > 0
607
+ ? env.XDG_DATA_HOME
608
+ : join(typeof env.HOME === "string" && env.HOME.length > 0 ? env.HOME : homedir(), ".local", "share");
609
+ const opencodeData = join(dataHome, "opencode");
610
+ const marker = await regularCurrentUserFile(join(opencodeData, "opencode.db"));
611
+ if (!marker)
612
+ return { status: "migration_required" };
613
+ const authPath = join(opencodeData, "auth.json");
614
+ if (!(await regularCurrentUserFile(authPath)))
615
+ return { status: "auth_missing" };
616
+ try {
617
+ const parsed = JSON.parse(await readFile(authPath, "utf8"));
618
+ if (!isUnknownRecord(parsed))
619
+ return { status: "auth_invalid" };
620
+ const providers = new Set();
621
+ for (const [provider, credential] of Object.entries(parsed)) {
622
+ if (provider.length === 0 || provider.trim() !== provider || !isOpenCodeCredentialEntry(credential)) {
623
+ return { status: "auth_invalid" };
624
+ }
625
+ providers.add(provider);
626
+ }
627
+ return { status: "ok", providers };
628
+ }
629
+ catch {
630
+ return { status: "auth_invalid" };
631
+ }
632
+ }
633
+ async function regularCurrentUserFile(path) {
634
+ try {
635
+ const info = await lstat(path);
636
+ return info.isFile() && (typeof process.getuid !== "function" || info.uid === process.getuid());
637
+ }
638
+ catch {
639
+ return false;
640
+ }
641
+ }
642
+ function isOpenCodeCredentialEntry(value) {
643
+ if (!isUnknownRecord(value))
644
+ return false;
645
+ if (value.type === "oauth") {
646
+ return typeof value.refresh === "string"
647
+ && typeof value.access === "string"
648
+ && (value.refresh.trim().length > 0 || value.access.trim().length > 0)
649
+ && typeof value.expires === "number"
650
+ && Number.isSafeInteger(value.expires)
651
+ && value.expires >= 0
652
+ && (value.accountId === undefined || typeof value.accountId === "string")
653
+ && (value.enterpriseUrl === undefined || typeof value.enterpriseUrl === "string");
654
+ }
655
+ if (value.type === "api") {
656
+ return typeof value.key === "string"
657
+ && value.key.trim().length > 0
658
+ && (value.metadata === undefined
659
+ || (isUnknownRecord(value.metadata) && Object.values(value.metadata).every((entry) => typeof entry === "string")));
660
+ }
661
+ if (value.type === "wellknown") {
662
+ return typeof value.key === "string" && value.key.trim().length > 0
663
+ && typeof value.token === "string" && value.token.trim().length > 0;
664
+ }
665
+ return false;
666
+ }
667
+ async function checkOpenCodeVersion(env, cwd, run) {
668
+ const versionEnv = {};
669
+ for (const key of [
670
+ "PATH",
671
+ "LANG",
672
+ "LC_ALL",
673
+ "LC_CTYPE",
674
+ "SHELL",
675
+ "TMPDIR",
676
+ "TMP",
677
+ "TEMP",
678
+ "SystemRoot",
679
+ "WINDIR",
680
+ "ComSpec",
681
+ "PATHEXT",
682
+ ]) {
683
+ const value = env[key];
684
+ if (typeof value === "string" && value.length > 0)
685
+ versionEnv[key] = value;
686
+ }
687
+ try {
688
+ const { stdout } = await run("opencode", ["--version"], {
689
+ cwd,
690
+ env: versionEnv,
691
+ timeout: SDK_AUTH_STATUS_TIMEOUT_MS,
692
+ maxBuffer: SDK_AUTH_STATUS_MAX_BUFFER_BYTES,
693
+ encoding: "utf8",
694
+ });
695
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/u.exec(stdout.trim());
696
+ if (match === null)
697
+ return false;
698
+ const major = Number(match[1]);
699
+ const minor = Number(match[2]);
700
+ const patch = Number(match[3]);
701
+ return Number.isSafeInteger(major)
702
+ && Number.isSafeInteger(minor)
703
+ && Number.isSafeInteger(patch)
704
+ && (major > 1 || (major === 1 && minor >= 15));
705
+ }
706
+ catch {
707
+ return false;
708
+ }
709
+ }
226
710
  /** First env key whose value is present and non-blank, or undefined when none are set. */
227
711
  function firstPresentEnvKey(env, keys) {
228
712
  for (const key of keys) {
@@ -241,26 +725,35 @@ function firstPresentEnvKey(env, keys) {
241
725
  * and the E1 fresh-instance case where `claude:*` with no `ANTHROPIC_API_KEY` "validates"
242
726
  * clean but the first turn dies with an opaque "process exited with code 1").
243
727
  *
244
- * The check is intentionally STATIC and read-only a validator must not mutate the auth
245
- * store or hit the network.
728
+ * The check is read-only. Static validation never launches a process; live validation may
729
+ * run bounded, local SDK login-status commands, which inspect durable external login state
730
+ * without making a model turn or mutating the auth store.
246
731
  *
247
- * - Pi providers: inspect the auth store (`piAuthPath`), the sibling `models.json`, AND the
248
- * config's own `providers.local` custom providers. A custom/local provider needs no OAuth;
249
- * an OAuth provider absent from the store, or whose access token has expired, is flagged
250
- * `waiting` with a re-auth hint.
251
- * - SDK-authenticated providers (`claude:*` / `codex:*`): inspect only the RESOLVED ENV
252
- * (process env + loaded `.env`) for the backend's accepted keys. When none is present we
253
- * flag `waiting` but the warning explicitly acknowledges the interactive login path
254
- * (`claude /login` / `codex login`), which lives outside the environment and CANNOT be
255
- * verified statically, so a logged-in user is not falsely told they are broken.
732
+ * - Pi providers: inspect the auth store (`piAuthPath`) and the config's own
733
+ * `providers.local` custom providers. A custom/local provider follows its declared
734
+ * `apiKey` / `apiKeyEnv` contract instead of Pi OAuth; an OAuth provider absent from
735
+ * the store, or whose access token has expired, is flagged `waiting` with a re-auth hint.
736
+ * - SDK-authenticated providers (`claude:*` / `codex:*`): inspect the RESOLVED ENV (process
737
+ * env + loaded `.env`) for the backend's accepted keys. During live validation only, a
738
+ * missing env credential falls back to `claude auth status --json` / `codex login status`.
739
+ * The commands use the same resolved environment (including PATH and HOME), are bounded,
740
+ * cached once per SDK, and never make a model turn. Static validation remains process-free.
741
+ * - Direct OpenCode (`opencode:<provider>:<model>`): inspect exact provider IDs in the
742
+ * standard auth.json and require the native DB migration marker. Static validation launches
743
+ * no process. Live validation runs only a bounded `opencode --version` preflight; it never
744
+ * runs the mutation-capable auth middleware or makes a model turn.
256
745
  *
257
746
  * `waiting` (never `error`) keeps the verdict non-fatal, mirroring the Ollama/Phoenix
258
747
  * probes — the goal is visibility, not blocking start.
259
748
  */
260
- async function credentialsSection(config, env) {
749
+ async function credentialsSection(config, env, cwd, liveness, verifiedCredentialModelRefs = [], sdkAuthStatusExecFile = defaultSdkAuthStatusExecFile, staticTriggerRefs = []) {
261
750
  const refs = [
262
751
  { label: "Primary", ref: config.runtime.model },
263
- ...(config.runtime.fallbackModels ?? []).map((ref) => ({ label: "Fallback", ref })),
752
+ ...configuredRuntimeRouteChecks(config).slice(1).map((route) => ({
753
+ label: "Fallback",
754
+ ref: route.model,
755
+ })),
756
+ ...staticTriggerRefs,
264
757
  ];
265
758
  if (config.memory?.llm !== undefined && config.memory.llm.provider !== "ollama") {
266
759
  try {
@@ -270,9 +763,10 @@ async function credentialsSection(config, env) {
270
763
  // A malformed memory model reference is surfaced by the memory/runtime shape checks.
271
764
  }
272
765
  }
273
- const piRefs = refs.filter((r) => r.ref.sdk === "pi" && typeof r.ref.provider === "string");
274
- const sdkRefs = refs.filter((r) => r.ref.sdk in SDK_AUTH_SCHEMES);
275
- if (piRefs.length === 0 && sdkRefs.length === 0) {
766
+ const authenticatedRefs = refs.filter((r) => (r.ref.sdk === "pi" && typeof r.ref.provider === "string")
767
+ || (r.ref.sdk === "opencode" && typeof r.ref.provider === "string")
768
+ || isSdkAuthName(r.ref.sdk));
769
+ if (authenticatedRefs.length === 0) {
276
770
  return {
277
771
  id: "credentials",
278
772
  label: "Provider credentials",
@@ -282,12 +776,49 @@ async function credentialsSection(config, env) {
282
776
  }
283
777
  const details = [];
284
778
  let status = "ok";
779
+ const verified = new Set(verifiedCredentialModelRefs);
780
+ for (const { label, ref } of authenticatedRefs) {
781
+ const refStr = referenceOf(ref);
782
+ if (verified.has(refStr)) {
783
+ details.push(`${label} ${refStr}: credentials verified by a successful live model check.`);
784
+ }
785
+ }
786
+ const unverifiedRefs = authenticatedRefs.filter(({ ref }) => !verified.has(referenceOf(ref)));
787
+ const piRefs = unverifiedRefs.filter((r) => r.ref.sdk === "pi" && typeof r.ref.provider === "string");
788
+ const openCodeRefs = unverifiedRefs.filter((r) => r.ref.sdk === "opencode" && typeof r.ref.provider === "string");
789
+ const sdkRefs = unverifiedRefs.filter((r) => isSdkAuthName(r.ref.sdk));
285
790
  if (piRefs.length > 0) {
286
- const piStatus = await appendPiCredentialDetails(config, piRefs, details);
791
+ const piStatus = await appendPiCredentialDetails(config, piRefs, details, env);
287
792
  if (piStatus === "waiting") {
288
793
  status = "waiting";
289
794
  }
290
795
  }
796
+ const openCodeInspection = openCodeRefs.length > 0
797
+ ? inspectOpenCodeCredentialProviders(env)
798
+ : undefined;
799
+ const openCodeVersion = liveness && openCodeRefs.length > 0
800
+ ? checkOpenCodeVersion(env, cwd, sdkAuthStatusExecFile)
801
+ : undefined;
802
+ const sdkAuthStatuses = new Map();
803
+ const externalLoginStatus = (sdk) => {
804
+ const cached = sdkAuthStatuses.get(sdk);
805
+ if (cached !== undefined) {
806
+ return cached;
807
+ }
808
+ const pending = checkSdkExternalLoginStatus(sdk, env, cwd, sdkAuthStatusExecFile);
809
+ sdkAuthStatuses.set(sdk, pending);
810
+ return pending;
811
+ };
812
+ // Start each unique local status check before awaiting details so two SDKs
813
+ // cost one bounded timeout window rather than running serially.
814
+ if (liveness) {
815
+ for (const { ref } of sdkRefs) {
816
+ const scheme = SDK_AUTH_SCHEMES[ref.sdk];
817
+ if (scheme !== undefined && firstPresentEnvKey(env, scheme.envKeys) === undefined) {
818
+ void externalLoginStatus(ref.sdk);
819
+ }
820
+ }
821
+ }
291
822
  for (const { label, ref } of sdkRefs) {
292
823
  const refStr = referenceOf(ref);
293
824
  const scheme = SDK_AUTH_SCHEMES[ref.sdk];
@@ -296,32 +827,69 @@ async function credentialsSection(config, env) {
296
827
  }
297
828
  const present = firstPresentEnvKey(env, scheme.envKeys);
298
829
  if (present !== undefined) {
299
- details.push(`${label} ${refStr}: SDK credential present in the resolved env (${present}).`);
830
+ details.push(`${label} ${refStr}: SDK credential present in the resolved env (${present}); credential detected, live model verification is still pending.`);
831
+ continue;
832
+ }
833
+ if (liveness && await externalLoginStatus(ref.sdk)) {
834
+ details.push(`${label} ${refStr}: external sign-in detected by read-only \`${scheme.statusCommand}\`; ` +
835
+ "credentials are not verified until a live model turn succeeds.");
300
836
  continue;
301
837
  }
302
838
  status = "waiting";
303
839
  details.push(`[WARN] ${label} ${refStr}: no SDK credential in the resolved env (checked ${scheme.envKeys.join(", ")}). ` +
304
- `If you authenticated via ${scheme.loginDetail} this is fine and can't be verified here; ` +
840
+ (liveness
841
+ ? `External login was not verified by \`${scheme.statusCommand}\`; `
842
+ : `If you authenticated via ${scheme.loginDetail} this is fine and can't be verified during static validation; `) +
305
843
  `otherwise ${scheme.failureHint} — set ${scheme.envKeys[0]} or run \`${scheme.loginCommand}\`.`);
306
844
  }
845
+ if (openCodeRefs.length > 0) {
846
+ const inspection = openCodeInspection === undefined ? undefined : await openCodeInspection;
847
+ const supportedVersion = openCodeVersion === undefined ? false : await openCodeVersion;
848
+ for (const { label, ref } of openCodeRefs) {
849
+ const refStr = referenceOf(ref);
850
+ const provider = ref.provider;
851
+ const credentialPresent = inspection?.status === "ok" && inspection.providers.has(provider);
852
+ if (credentialPresent && liveness && supportedVersion) {
853
+ details.push(`${label} ${refStr}: provider \`${provider}\` credential present in the standard OpenCode auth store; stable OpenCode CLI >=1.15.0 detected without a model turn. Credential detected; live model verification is still pending.`);
854
+ }
855
+ else {
856
+ status = "waiting";
857
+ const warning = inspection?.status === "migration_required"
858
+ ? "the native OpenCode database migration marker is missing or invalid; run `opencode db migrate --pure` once"
859
+ : inspection?.status === "inline_auth_unsupported"
860
+ ? "OPENCODE_AUTH_CONTENT is unsupported for direct runs; persist credentials with `opencode auth login` and unset it"
861
+ : inspection?.status === "auth_invalid"
862
+ ? "the standard OpenCode auth.json is malformed or contains an unsupported credential entry"
863
+ : credentialPresent && !liveness
864
+ ? "credentials and migration marker are present, but the required stable OpenCode CLI >=1.15.0 is unverified during static validation"
865
+ : credentialPresent && !supportedVersion
866
+ ? "credentials are present, but stable OpenCode CLI >=1.15.0 could not be verified"
867
+ : inspection?.status === "ok"
868
+ ? `no exact credential entry exists for provider \`${provider}\`; run \`opencode auth login\` for that provider`
869
+ : "the standard OpenCode auth.json is missing; run `opencode auth login`";
870
+ const safetyNote = liveness
871
+ ? "No model turn or mutation-capable OpenCode command was run."
872
+ : "No OpenCode process was launched.";
873
+ details.push(`[WARN] ${label} ${refStr}: ${warning}. ${safetyNote}`);
874
+ }
875
+ }
876
+ }
307
877
  return { id: "credentials", label: "Provider credentials", status, details };
308
878
  }
309
879
  /**
310
880
  * Appends one detail line per Pi provider reference and returns "waiting" if any
311
- * was flagged (missing/expired), else "ok". Recognizes providers configured via
312
- * the auth store's `models.json` AND the config's `providers.local` custom set.
881
+ * was flagged (missing/expired), else "ok". Recognizes custom providers only
882
+ * through the config's `providers.local` set, matching the runtime boundary.
313
883
  */
314
- async function appendPiCredentialDetails(config, piRefs, details) {
884
+ async function appendPiCredentialDetails(config, piRefs, details, env) {
315
885
  const authPath = config.providers?.piAuthPath;
316
- const authProviders = authPath === undefined ? undefined : await readPiAuthProviders(authPath);
317
- const modelsJsonProviders = authPath === undefined ? new Set() : await readPiCustomProviders(authPath);
318
- // A `pi:<id>:...` model whose `<id>` matches an ENABLED `providers.local` entry
319
- // is a keyless local/custom provider no OAuth, no auth-store entry. A DISABLED
320
- // entry is tracked separately: the runtime throws `provider disabled: <id>` on the
321
- // first turn (pi-models.js), so validate must name that instead of reporting a
322
- // clean OK or the misleading "no Pi credentials" advice.
323
- const enabledLocalProviders = new Set((config.providers?.local ?? []).filter((provider) => provider.enabled !== false).map((provider) => provider.id));
324
- const disabledLocalProviders = new Set((config.providers?.local ?? []).filter((provider) => provider.enabled === false).map((provider) => provider.id));
886
+ const authInspection = authPath === undefined ? undefined : await readPiAuthProviders(authPath);
887
+ const authProviders = authInspection?.status === "ok" ? authInspection.providers : undefined;
888
+ // A matching `providers.local` entry owns the runtime route, even when its ID
889
+ // collides with a Pi built-in provider. Report the local entry's declared auth
890
+ // contract instead of consulting the unrelated Pi auth store. A disabled entry
891
+ // remains authoritative because the runtime rejects it before credential use.
892
+ const localProviders = new Map((config.providers?.local ?? []).map((provider) => [provider.id, provider]));
325
893
  const now = Date.now();
326
894
  let status = "ok";
327
895
  if (authPath !== undefined) {
@@ -330,45 +898,99 @@ async function appendPiCredentialDetails(config, piRefs, details) {
330
898
  for (const { label, ref } of piRefs) {
331
899
  const provider = ref.provider;
332
900
  const refStr = referenceOf(ref);
333
- const loginCommand = piLoginCommandLine(provider);
334
- const loginCwd = piAuthWorkingDirectory(authPath);
335
- if (enabledLocalProviders.has(provider)) {
336
- details.push(`${label} ${refStr}: provider \`${provider}\` configured via config providers.local (keyless local provider).`);
901
+ const loginCommand = piAuthRecoveryCommand(provider, authPath);
902
+ const localProvider = localProviders.get(provider);
903
+ if (localProvider !== undefined) {
904
+ if (localProvider.enabled === false) {
905
+ status = "waiting";
906
+ details.push(`[WARN] ${label} ${refStr}: provider \`${provider}\` is configured in providers.local but disabled (\`enabled: false\`); the runtime will throw \`provider disabled: ${provider}\` on the first turn. Set \`enabled: true\` on that providers.local entry.`);
907
+ }
908
+ else if (localProvider.apiKey !== undefined) {
909
+ details.push(`${label} ${refStr}: provider \`${provider}\` configured via config providers.local (API key configured); credential detected, live model verification is still pending.`);
910
+ }
911
+ else if (localProvider.apiKeyEnv !== undefined) {
912
+ if (hasNonEmptyCredentialValue(env[localProvider.apiKeyEnv])) {
913
+ details.push(`${label} ${refStr}: provider \`${provider}\` configured via config providers.local with ${localProvider.apiKeyEnv} present in the resolved environment; credential detected, live model verification is still pending.`);
914
+ }
915
+ else {
916
+ status = "waiting";
917
+ details.push(`[WARN] ${label} ${refStr}: provider \`${provider}\` declares apiKeyEnv \`${localProvider.apiKeyEnv}\`, but the resolved environment has no non-empty value and no inline apiKey fallback. Set ${localProvider.apiKeyEnv} before starting.`);
918
+ }
919
+ }
920
+ else {
921
+ details.push(`${label} ${refStr}: provider \`${provider}\` configured via config providers.local (keyless local provider; no API key declared).`);
922
+ }
337
923
  continue;
338
924
  }
339
- if (disabledLocalProviders.has(provider)) {
340
- status = "waiting";
341
- details.push(`[WARN] ${label} ${refStr}: provider \`${provider}\` is configured in providers.local but disabled (\`enabled: false\`); the runtime will throw \`provider disabled: ${provider}\` on the first turn. Set \`enabled: true\` on that providers.local entry.`);
925
+ const apiKeyEnv = PI_API_KEY_ENV_BY_PROVIDER[provider];
926
+ if (apiKeyEnv !== undefined && hasNonEmptyCredentialValue(env[apiKeyEnv])) {
927
+ details.push(`${label} ${refStr}: Pi API-key credential for \`${provider}\` present in the resolved environment (${apiKeyEnv}); credential detected, live model verification is still pending.`);
342
928
  continue;
343
929
  }
344
- if (modelsJsonProviders.has(provider)) {
345
- details.push(`${label} ${refStr}: provider \`${provider}\` configured via pi models.json.`);
930
+ if (authInspection?.status === "unsafe") {
931
+ status = "waiting";
932
+ details.push(`[WARN] ${label} ${refStr}: Pi auth store was not trusted because ${describePiAuthStoreUnsafeReason(authInspection.reason)}. ` +
933
+ `Move the unsafe entry aside if needed, then run \`${loginCommand}\` to create or atomically harden a current-user 0600 store. ` +
934
+ "A current-user, non-writable legacy store can be replaced during this explicit repair, but is intentionally never trusted for credential detection. " +
935
+ "No credential values or file contents were displayed.");
346
936
  continue;
347
937
  }
348
938
  const entry = authProviders?.[provider];
349
939
  if (entry === undefined) {
350
940
  status = "waiting";
351
- const apiKeyEnv = PI_API_KEY_ENV_BY_PROVIDER[provider];
352
941
  details.push(apiKeyEnv === undefined
353
- ? `[WARN] ${label} ${refStr}: no Pi credentials found for provider \`${provider}\` (absent from the auth store and models.json). Authenticate it with \`${loginCommand}\` from ${loginCwd}, or set providers.piAuthPath.`
354
- : `[WARN] ${label} ${refStr}: no Pi API key credentials found for provider \`${provider}\` (absent from the auth store and models.json). Save an API key entry in providers.piAuthPath, or set ${apiKeyEnv}.`);
942
+ ? `[WARN] ${label} ${refStr}: no Pi credentials found for provider \`${provider}\` in the auth store. Authenticate it with \`${loginCommand}\`, or set providers.piAuthPath.`
943
+ : `[WARN] ${label} ${refStr}: no Pi API key credentials found for provider \`${provider}\` in the auth store or resolved environment. Run \`${loginCommand}\`, or set ${apiKeyEnv}.`);
944
+ continue;
945
+ }
946
+ const isOAuth = entry.type === "oauth";
947
+ const isApiKey = entry.type === "api_key";
948
+ if (isApiKey && !hasNonEmptyCredentialValue(entry.key)) {
949
+ status = "waiting";
950
+ details.push(`[WARN] ${label} ${refStr}: stored API-key credential for \`${provider}\` has no usable key. Run \`${loginCommand}\`; no secret value was displayed.`);
951
+ continue;
952
+ }
953
+ if (isOAuth && !hasNonEmptyCredentialValue(entry.access) && !hasNonEmptyCredentialValue(entry.refresh)) {
954
+ status = "waiting";
955
+ details.push(`[WARN] ${label} ${refStr}: stored OAuth credential for \`${provider}\` has no usable access or refresh token. Re-authenticate with \`${loginCommand}\`; no secret value was displayed.`);
956
+ continue;
957
+ }
958
+ if (!isOAuth && !isApiKey) {
959
+ status = "waiting";
960
+ details.push(`[WARN] ${label} ${refStr}: stored credential for \`${provider}\` has an unsupported or missing type. Re-authenticate with \`${loginCommand}\`; no secret value was displayed.`);
355
961
  continue;
356
962
  }
357
- const isOAuth = entry.type === "oauth" || typeof entry.expires === "number";
358
963
  const expired = typeof entry.expires === "number" && entry.expires < now;
359
964
  const whenNote = typeof entry.expires === "number" ? ` ${new Date(entry.expires).toISOString()}` : "";
360
965
  if (isOAuth && expired) {
361
966
  status = "waiting";
362
- details.push(`[WARN] ${label} ${refStr}: OAuth token for \`${provider}\` expired${whenNote} — the runtime auto-refreshes, but if runs fail with "No API key for provider: ${provider}" the refresh is dead; re-authenticate with \`${loginCommand}\` from ${loginCwd}.`);
967
+ details.push(`[WARN] ${label} ${refStr}: OAuth token for \`${provider}\` expired${whenNote} — the runtime may auto-refresh, but this credential is not ready until a request succeeds; if runs fail with "No API key for provider: ${provider}" re-authenticate with \`${loginCommand}\`.`);
363
968
  continue;
364
969
  }
365
970
  details.push(isOAuth
366
- ? `${label} ${refStr}: OAuth credentials for \`${provider}\` present (token valid${whenNote}).`
367
- : `${label} ${refStr}: API key credentials for \`${provider}\` present.`);
971
+ ? `${label} ${refStr}: OAuth credentials for \`${provider}\` present (token valid${whenNote}); credential detected, live model verification is still pending.`
972
+ : `${label} ${refStr}: API key credentials for \`${provider}\` present; credential detected, live model verification is still pending.`);
368
973
  }
369
974
  return status;
370
975
  }
371
- async function contextSection(config) {
976
+ function describePiAuthStoreUnsafeReason(reason) {
977
+ switch (reason) {
978
+ case "owner-check-unavailable": return "the current file owner could not be verified";
979
+ case "symbolic-link": return "the configured entry is a symbolic link";
980
+ case "not-regular-file": return "the configured entry is not a regular file";
981
+ case "multiple-hard-links": return "the file has multiple hard links";
982
+ case "oversized": return "the file exceeds the 1 MiB inspection limit";
983
+ case "foreign-owner": return "the file is not owned by the current user";
984
+ case "not-owner-only": return "its permissions are not owner-only";
985
+ case "changed-during-read": return "its identity or metadata changed during inspection";
986
+ case "malformed-json": return "it is not a valid JSON object";
987
+ case "unreadable": return "it could not be opened and inspected safely";
988
+ }
989
+ }
990
+ function hasNonEmptyCredentialValue(value) {
991
+ return typeof value === "string" && value.trim().length > 0 && value.length <= 65_536 && !value.includes("\0");
992
+ }
993
+ async function contextSection(config, cwd) {
372
994
  const details = [];
373
995
  let status = "ok";
374
996
  if (await pathExists(config.context.identityPath)) {
@@ -405,10 +1027,23 @@ async function contextSection(config) {
405
1027
  status = "error";
406
1028
  details.push("Skills are selected but context.skillsRoot is not set.");
407
1029
  }
1030
+ if (await managedProjectSkillsExist(cwd)) {
1031
+ const managed = await checkManagedProjectSkills(cwd);
1032
+ const drift = managed.statuses.filter((entry) => entry.status !== "ready");
1033
+ if (drift.length === 0) {
1034
+ details.push(`Managed project skills: current (${managed.manifestVersion ?? "unknown version"}).`);
1035
+ }
1036
+ else {
1037
+ if (status === "ok")
1038
+ status = "waiting";
1039
+ details.push(`Managed project skill drift: ${drift.map((entry) => `${entry.name}=${entry.status}`).join(", ")}. ` +
1040
+ "Run `mono-agent install-skill --project --check`; use --update only after reconciling modified copies.");
1041
+ }
1042
+ }
408
1043
  return { id: "context", label: "Context & skills", status, details };
409
1044
  }
410
1045
  const DEFAULT_CONSOLIDATION_CRON = "0 */2 * * *";
411
- async function memorySection(config, liveness, allowFilesystemWrites) {
1046
+ async function memorySection(config, cwd, env, liveness, allowFilesystemWrites, preferAppPluginInstall) {
412
1047
  if (config.memory === undefined) {
413
1048
  return { id: "memory", label: "Memory", status: "disabled", details: ["No memory configured."] };
414
1049
  }
@@ -425,6 +1060,32 @@ async function memorySection(config, liveness, allowFilesystemWrites) {
425
1060
  details: ["[ERROR] backend 'supermemory' requires a memory.supermemory block."],
426
1061
  };
427
1062
  }
1063
+ try {
1064
+ const plugin = await loadSupermemoryPlugin({ cwd, preferAppInstall: preferAppPluginInstall });
1065
+ const validation = plugin.validateSupermemoryConfig({
1066
+ baseUrl: sm.baseUrl,
1067
+ container: resolveSupermemoryContainer(config),
1068
+ ...(sm.apiKey === undefined ? {} : { apiKey: sm.apiKey }),
1069
+ ...(sm.timeoutMs === undefined ? {} : { timeoutMs: sm.timeoutMs }),
1070
+ ...(config.memory.maxBytes === undefined ? {} : { maxBytes: config.memory.maxBytes }),
1071
+ });
1072
+ if (!validation.valid) {
1073
+ return {
1074
+ id: "memory",
1075
+ label: "Memory",
1076
+ status: "error",
1077
+ details: validation.errors.map((detail) => `[ERROR] ${detail}`),
1078
+ };
1079
+ }
1080
+ }
1081
+ catch (error) {
1082
+ return {
1083
+ id: "memory",
1084
+ label: "Memory",
1085
+ status: "error",
1086
+ details: [`[ERROR] ${error instanceof Error ? error.message : String(error)}`],
1087
+ };
1088
+ }
428
1089
  return {
429
1090
  id: "memory",
430
1091
  label: "Memory",
@@ -442,39 +1103,191 @@ async function memorySection(config, liveness, allowFilesystemWrites) {
442
1103
  const details = [
443
1104
  `Mode: ${config.memory.mode}, path: ${config.memory.path}, writeMode: ${config.memory.writeMode}.`,
444
1105
  ];
1106
+ let status = "ok";
445
1107
  if (config.memory.llm !== undefined) {
446
1108
  details.push(`Chat LLM: ${memoryLlmLabel(config.memory.llm)}.`);
1109
+ if (config.memory.llm.provider === "agent-host") {
1110
+ try {
1111
+ const model = parseMonoRuntimeModelReference(config.memory.llm.model);
1112
+ const resolutionIssue = piModelResolutionIssue(config, model);
1113
+ if (resolutionIssue !== undefined) {
1114
+ status = "error";
1115
+ details.push(`Agent-host memory LLM ${referenceOf(model)}: ${resolutionIssue}.`);
1116
+ }
1117
+ }
1118
+ catch (error) {
1119
+ status = "error";
1120
+ details.push(`Agent-host memory LLM ${config.memory.llm.model}: ${error instanceof Error ? error.message : String(error)}.`);
1121
+ }
1122
+ }
447
1123
  }
448
1124
  if (config.memory.mode === "bujo") {
449
1125
  // Report consolidation scheduler cadence.
450
1126
  const consolidationEnabled = config.memory.consolidation?.enabled !== false;
451
1127
  const consolidationCron = config.memory.consolidation?.cron ?? DEFAULT_CONSOLIDATION_CRON;
452
- const hasLlm = config.memory.llm !== undefined;
453
- if (hasLlm) {
454
- if (consolidationEnabled) {
455
- details.push(`Consolidation: ${consolidationCron} (auto).`);
456
- }
457
- else {
458
- details.push("Consolidation: disabled.");
459
- }
1128
+ if (consolidationEnabled) {
1129
+ details.push(`Consolidation: ${consolidationCron} (auto).`);
460
1130
  }
461
1131
  else {
462
- details.push("Consolidation: not scheduled (no chat model — bujo runtime tier downgrades to journal).");
1132
+ details.push("Consolidation: disabled.");
463
1133
  }
464
1134
  }
1135
+ const managedIdentity = await managedMemoryIdentityStatus(config.memory);
1136
+ if (managedIdentity !== undefined) {
1137
+ return {
1138
+ id: "memory",
1139
+ label: "Memory",
1140
+ status: status === "error" ? "error" : managedIdentity.status,
1141
+ details: [...details, ...managedIdentity.details],
1142
+ };
1143
+ }
1144
+ const nativeAvailability = await builtInMemoryNativeStatus(config.memory);
1145
+ if (nativeAvailability !== undefined) {
1146
+ return {
1147
+ id: "memory",
1148
+ label: "Memory",
1149
+ status: "error",
1150
+ details: [...details, nativeAvailability],
1151
+ };
1152
+ }
465
1153
  if (config.memory.mode === "journal" || config.memory.mode === "bujo") {
466
- const warns = await memoryLivenessWarnings(config.memory, liveness, allowFilesystemWrites);
1154
+ const warns = await memoryLivenessWarnings(config.memory, env, liveness, allowFilesystemWrites);
467
1155
  if (warns.length > 0) {
468
- return { id: "memory", label: "Memory", status: "waiting", details: [...details, ...warns] };
1156
+ return {
1157
+ id: "memory",
1158
+ label: "Memory",
1159
+ status: status === "error" ? "error" : "waiting",
1160
+ details: [...details, ...warns],
1161
+ };
469
1162
  }
470
1163
  }
471
1164
  if (config.memory.mode === "lite") {
472
1165
  const liteWarns = await liteRootWritableWarning(config.memory.path, allowFilesystemWrites);
473
1166
  if (liteWarns.length > 0) {
474
- return { id: "memory", label: "Memory", status: "waiting", details: [...details, ...liteWarns] };
1167
+ return {
1168
+ id: "memory",
1169
+ label: "Memory",
1170
+ status: status === "error" ? "error" : "waiting",
1171
+ details: [...details, ...liteWarns],
1172
+ };
475
1173
  }
476
1174
  }
477
- return { id: "memory", label: "Memory", status: "ok", details };
1175
+ return { id: "memory", label: "Memory", status, details };
1176
+ }
1177
+ async function managedMemoryIdentityStatus(memory) {
1178
+ if (await pathExists(join(memory.path, FIRST_RUN_MEMORY_INITIALIZING_MARKER))) {
1179
+ return {
1180
+ status: "error",
1181
+ details: [
1182
+ "[ERROR] First-run managed memory initialization is incomplete.",
1183
+ "Re-run `mono-agent init` in a clean target or remove only the failed first-run root after inspecting it.",
1184
+ ],
1185
+ };
1186
+ }
1187
+ const manifestPath = join(memory.path, ".index", "manifest.json");
1188
+ if (!(await pathExists(manifestPath))) {
1189
+ // Lite has no semantic index authority. Journal/BuJo readiness is strict:
1190
+ // a missing manifest is fatal even for a wholly new/unmanaged root, and the
1191
+ // provider must not be probed until rebuild establishes that authority.
1192
+ if (memory.mode === "lite")
1193
+ return undefined;
1194
+ return {
1195
+ status: "error",
1196
+ details: [
1197
+ "[ERROR] Managed memory generation metadata is missing for Journal/BuJo memory.",
1198
+ "Stop the agent with `mono-agent stop`, run `mono-agent memory rebuild`, then re-run `mono-agent validate` before restarting.",
1199
+ ],
1200
+ };
1201
+ }
1202
+ let manifest;
1203
+ try {
1204
+ const { readManagedIndexManifest } = await import("@mono-agent/memory/bujo");
1205
+ manifest = readManagedIndexManifest(memory.path);
1206
+ }
1207
+ catch {
1208
+ return {
1209
+ status: "error",
1210
+ details: ["[ERROR] Managed memory generation metadata is invalid or unavailable."],
1211
+ };
1212
+ }
1213
+ if (manifest === undefined) {
1214
+ return {
1215
+ status: "error",
1216
+ details: ["[ERROR] Managed memory generation metadata disappeared during validation."],
1217
+ };
1218
+ }
1219
+ const configuredModel = memory.embeddings === undefined
1220
+ ? undefined
1221
+ : `${memory.embeddings.provider}:${memory.embeddings.model}`;
1222
+ const configuredDimension = memory.embeddings === undefined ? undefined : memory.embeddings.dim ?? 768;
1223
+ const active = manifest.active;
1224
+ if (active.tier === memory.mode
1225
+ && active.embeddingModel === configuredModel
1226
+ && active.dimension === configuredDimension) {
1227
+ return undefined;
1228
+ }
1229
+ const identity = (tier, model, dimension) => `tier=${tier}, model=${model ?? "none"}, dim=${dimension ?? "none"}`;
1230
+ return {
1231
+ status: "error",
1232
+ details: [
1233
+ `[ERROR] Active managed generation does not match the configured memory identity: active ${identity(active.tier, active.embeddingModel, active.dimension)}; configured ${identity(memory.mode, configuredModel, configuredDimension)}.`,
1234
+ "Stop the agent with `mono-agent stop`, run `mono-agent memory rebuild`, then re-run `mono-agent validate` before restarting.",
1235
+ ],
1236
+ };
1237
+ }
1238
+ async function builtInMemoryNativeStatus(memory) {
1239
+ let database;
1240
+ let managedGeneration = false;
1241
+ try {
1242
+ const { openMemoryDb } = await import("@mono-agent/memory/store");
1243
+ const manifestPath = join(memory.path, ".index", "manifest.json");
1244
+ if (await pathExists(manifestPath)) {
1245
+ managedGeneration = true;
1246
+ const { resolveActiveMemoryDbPath } = await import("@mono-agent/memory/bujo");
1247
+ database = openMemoryDb({ path: resolveActiveMemoryDbPath(memory.path), readOnly: true });
1248
+ }
1249
+ else {
1250
+ // Lite roots may not exist yet and validation must remain read-only. An
1251
+ // in-memory open exercises the exact native ABI + extension load without
1252
+ // scanning durable memory or creating the configured root.
1253
+ database = openMemoryDb({ path: ":memory:" });
1254
+ }
1255
+ // A single schema-row lookup proves the opened handle can actually read
1256
+ // SQLite state (constructor-only opens can accept a truncated file). This
1257
+ // remains constant-work and avoids the corpus/queue scans of strict audit.
1258
+ database.indexMetadata();
1259
+ database.close();
1260
+ database = undefined;
1261
+ return undefined;
1262
+ }
1263
+ catch (error) {
1264
+ if (isBuiltInMemoryNativeFailure(error)) {
1265
+ return "[ERROR] Built-in memory native module is unavailable for this Node runtime. Rebuild dependencies with the launch runtime, then re-run `mono-agent validate`.";
1266
+ }
1267
+ if (managedGeneration) {
1268
+ return "[ERROR] Built-in memory active generation is unavailable. Stop the agent with `mono-agent stop`, run `mono-agent memory rebuild`, then re-run `mono-agent validate` before restarting.";
1269
+ }
1270
+ return "[ERROR] Built-in memory database smoke check failed. Rebuild dependencies with the launch runtime; if the runtime is compatible, stop the agent and run `mono-agent memory rebuild`, then re-run `mono-agent validate`.";
1271
+ }
1272
+ finally {
1273
+ try {
1274
+ database?.close();
1275
+ }
1276
+ catch { /* best-effort smoke cleanup */ }
1277
+ }
1278
+ }
1279
+ function isBuiltInMemoryNativeFailure(error) {
1280
+ try {
1281
+ const message = error instanceof Error ? error.message : "";
1282
+ const code = typeof error === "object" && error !== null && "code" in error
1283
+ ? String(error.code ?? "")
1284
+ : "";
1285
+ return /(?:err_(?:dlopen|module_not_found)|module_not_found|better[-_ ]?sqlite|sqlite[-_ ]?vec|node_module_version|native module|dlopen|\.node\b)/iu
1286
+ .test(`${code} ${message}`);
1287
+ }
1288
+ catch {
1289
+ return false;
1290
+ }
478
1291
  }
479
1292
  async function liteRootWritableWarning(memoryPath, allowFilesystemWrites) {
480
1293
  return await memoryRootWritableWarnings("lite", memoryPath, allowFilesystemWrites);
@@ -496,20 +1309,34 @@ async function fetchOllamaModels(endpoint) {
496
1309
  clearTimeout(timer);
497
1310
  }
498
1311
  }
499
- async function memoryLivenessWarnings(memory, liveness, allowFilesystemWrites) {
1312
+ async function memoryLivenessWarnings(memory, env, liveness, allowFilesystemWrites) {
500
1313
  const warns = [];
501
1314
  const mode = memory.mode;
502
- const embeddingsUsesOllama = (memory.embeddings?.provider ?? "ollama") === "ollama";
503
1315
  const llmUsesOllama = memory.llm?.provider === "ollama";
504
1316
  const ollamaModelsByEndpoint = new Map();
505
1317
  // 1. Memory root writable (every embedded tier) — local I/O, always checked.
506
1318
  const rootWarns = await memoryRootWritableWarnings(mode, memory.path, allowFilesystemWrites);
507
1319
  warns.push(...rootWarns);
1320
+ const embeddings = memory.embeddings;
1321
+ let embeddingApiKey = embeddings?.apiKey;
1322
+ if (embeddings?.apiKeyEnv !== undefined) {
1323
+ embeddingApiKey = env[embeddings.apiKeyEnv]?.trim();
1324
+ if (embeddingApiKey === undefined || embeddingApiKey.length === 0) {
1325
+ warns.push(`[WARN] Embeddings apiKeyEnv ${embeddings.apiKeyEnv} is declared, but the resolved environment has no ` +
1326
+ `non-empty value. Set ${embeddings.apiKeyEnv} before starting managed memory; no keyless probe was attempted.`);
1327
+ embeddingApiKey = undefined;
1328
+ }
1329
+ }
508
1330
  // Network-dependent probes below only ever produce `waiting`, so the start
509
1331
  // preflight skips them (liveness=false) without changing the pass/fail verdict.
510
1332
  if (!liveness) {
511
1333
  return warns;
512
1334
  }
1335
+ if (embeddings !== undefined
1336
+ && (embeddings.provider === "ollama" || embeddings.provider === "lmstudio")
1337
+ && (embeddings.apiKeyEnv === undefined || embeddingApiKey !== undefined)) {
1338
+ warns.push(...await localEmbeddingLivenessWarnings(embeddings, embeddingApiKey));
1339
+ }
513
1340
  async function modelsForOllamaEndpoint(endpoint) {
514
1341
  const normalizedEndpoint = endpoint.replace(/\/$/u, "");
515
1342
  if (ollamaModelsByEndpoint.has(normalizedEndpoint)) {
@@ -526,18 +1353,9 @@ async function memoryLivenessWarnings(memory, liveness, allowFilesystemWrites) {
526
1353
  return undefined;
527
1354
  }
528
1355
  }
529
- // 2. Ollama liveness only probe components that actually use Ollama. OpenAI embeddings and
530
- // agent-host chat LLMs have no local model list to validate here.
531
- if (embeddingsUsesOllama) {
532
- const endpoint = memory.embeddings?.endpoint ?? "http://localhost:11434";
533
- const ollamaModels = await modelsForOllamaEndpoint(endpoint);
534
- if (ollamaModels !== undefined) {
535
- const embeddingsModel = memory.embeddings?.model ?? "nomic-embed-text:v1.5";
536
- if (!ollamaModels.includes(embeddingsModel)) {
537
- warns.push(`[WARN] Embeddings model ${embeddingsModel} not pulled — run \`ollama pull ${embeddingsModel}\`.`);
538
- }
539
- }
540
- }
1356
+ // 2. Chat-LLM Ollama liveness remains independent from the selected
1357
+ // embeddings service. OpenAI embeddings and agent-host chat LLMs have no
1358
+ // local typed model catalog to validate here.
541
1359
  if (llmUsesOllama && memory.llm !== undefined) {
542
1360
  const endpoint = memory.llm.endpoint ?? "http://localhost:11434";
543
1361
  const ollamaModels = await modelsForOllamaEndpoint(endpoint);
@@ -550,6 +1368,52 @@ async function memoryLivenessWarnings(memory, liveness, allowFilesystemWrites) {
550
1368
  }
551
1369
  return warns;
552
1370
  }
1371
+ async function localEmbeddingLivenessWarnings(embeddings, apiKey) {
1372
+ if (embeddings.provider !== "ollama" && embeddings.provider !== "lmstudio")
1373
+ return [];
1374
+ const provider = embeddings.provider;
1375
+ const label = provider === "ollama" ? "Ollama" : "LM Studio";
1376
+ const endpoint = (embeddings.endpoint ?? DEFAULT_MEMORY_EMBEDDING_ENDPOINTS[provider]).replace(/\/+$/u, "");
1377
+ // Runtime readiness is authoritative on the exact configured model. Typed
1378
+ // catalogs help the wizard offer choices, but an unavailable/incomplete
1379
+ // catalog must not make its explicit manual fallback permanently unready.
1380
+ try {
1381
+ await probeMemoryEmbeddingSelection({
1382
+ provider,
1383
+ endpoint,
1384
+ model: embeddings.model,
1385
+ expectedDimension: embeddings.dim ?? 768,
1386
+ timeoutMs: BUJO_PROBE_TIMEOUT_MS,
1387
+ ...(apiKey === undefined ? {} : { apiKey }),
1388
+ });
1389
+ return [];
1390
+ }
1391
+ catch (error) {
1392
+ const reason = error instanceof Error ? error.message : String(error);
1393
+ if (/HTTP 401/u.test(reason)) {
1394
+ return [
1395
+ `[WARN] ${label} authentication failed at ${endpoint} (HTTP 401). ` +
1396
+ "Set the configured embeddings apiKeyEnv to a valid bearer token and retry.",
1397
+ ];
1398
+ }
1399
+ if (/could not connect|timed out/iu.test(reason)) {
1400
+ return [
1401
+ `[WARN] ${label} not reachable at ${endpoint}; embeddings will fail at runtime (${reason}). ` +
1402
+ `Start ${label} or fix the endpoint.`,
1403
+ ];
1404
+ }
1405
+ if (provider === "ollama" && /HTTP 404/u.test(reason)) {
1406
+ return [
1407
+ `[WARN] Ollama embedding model ${embeddings.model} could not be proved at ${endpoint} (${reason}); ` +
1408
+ `run \`ollama pull ${embeddings.model}\` and verify its embedding capability.`,
1409
+ ];
1410
+ }
1411
+ return [
1412
+ `[WARN] ${label} embedding readiness failed for ${embeddings.model} at ${endpoint} (${reason}). ` +
1413
+ "Verify the selected model, authentication, and configured dimension.",
1414
+ ];
1415
+ }
1416
+ }
553
1417
  async function memoryRootWritableWarnings(mode, memoryPath, allowFilesystemWrites) {
554
1418
  if (allowFilesystemWrites) {
555
1419
  try {
@@ -652,18 +1516,102 @@ async function toolsSection(config, input) {
652
1516
  // Under allow-all the disallow list is already folded into the "except" clause above.
653
1517
  details.push(`Disallowed tools: ${config.tools.disallowedTools.join(", ")}.`);
654
1518
  }
1519
+ const directCodexModels = configuredRuntimeModels(config.runtime)
1520
+ .filter((model) => model.sdk === "codex")
1521
+ .map(referenceOf);
1522
+ const directOpenCodeModels = configuredRuntimeModels(config.runtime)
1523
+ .filter((model) => model.sdk === "opencode")
1524
+ .map(referenceOf);
1525
+ const claudeCliModels = [
1526
+ { model: config.runtime.model, executionMode: config.runtime.executionMode },
1527
+ ...configuredRuntimeFallbackModels(config.runtime).map((model) => ({
1528
+ model,
1529
+ executionMode: defaultExecutionModeForModel(model),
1530
+ })),
1531
+ ]
1532
+ .filter(({ model, executionMode }) => model.sdk === "claude" && executionMode === "cli")
1533
+ .map(({ model }) => referenceOf(model));
1534
+ const exactAllowAll = hasExactAllowAllToolPolicy(config.tools);
1535
+ const dedicatedNoToolsProbe = input.codexNoToolsProbe === true
1536
+ && allowedTools.length === 0
1537
+ && config.tools.disallowedTools.length === 0;
1538
+ if (directCodexModels.length > 0
1539
+ && !dedicatedNoToolsProbe
1540
+ && !exactAllowAll) {
1541
+ status = "error";
1542
+ details.push(`Direct Codex model${directCodexModels.length === 1 ? "" : "s"} ${directCodexModels.join(", ")} cannot enforce ` +
1543
+ "tools.allowedTools/tools.disallowedTools. Use exact allow-all (allowedTools: [\"*\"] with no disallowedTools), " +
1544
+ "or select a runtime that supports restrictive tool policies.");
1545
+ }
1546
+ if (directOpenCodeModels.length > 0 && !exactAllowAll) {
1547
+ status = "error";
1548
+ details.push(`Direct OpenCode model${directOpenCodeModels.length === 1 ? "" : "s"} ${directOpenCodeModels.join(", ")} cannot enforce ` +
1549
+ "tools.allowedTools/tools.disallowedTools. Use exact allow-all (allowedTools: [\"*\"] with no disallowedTools), " +
1550
+ "or use a Pi runtime (including pi:opencode-go:*).");
1551
+ }
1552
+ if (claudeCliModels.length > 0 && allowedTools.length === 0) {
1553
+ status = "error";
1554
+ details.push(`Claude CLI model${claudeCliModels.length === 1 ? "" : "s"} ${claudeCliModels.join(", ")} cannot enforce an empty ` +
1555
+ "tools.allowedTools list because omitting --tools enables Claude Code's default tool set. Use Claude SDK for a chat-only agent, or configure a non-empty enforceable tool list.");
1556
+ }
1557
+ let configuredMcpServerNames = [];
1558
+ let configuredMcpServers = {};
655
1559
  if (config.tools.mcpConfigPath !== undefined) {
656
1560
  if (await pathExists(config.tools.mcpConfigPath)) {
657
1561
  details.push(`MCP config: ${config.tools.mcpConfigPath}`);
1562
+ try {
1563
+ const policy = await loadToolPolicyFromJsonFile(config.tools.mcpConfigPath);
1564
+ configuredMcpServers = policy.mcpServers ?? {};
1565
+ configuredMcpServerNames = Object.keys(configuredMcpServers);
1566
+ }
1567
+ catch {
1568
+ status = "error";
1569
+ details.push(`MCP config is malformed or unreadable: ${config.tools.mcpConfigPath}`);
1570
+ }
658
1571
  }
659
1572
  else {
660
1573
  status = "error";
661
1574
  details.push(`MCP config file is missing: ${config.tools.mcpConfigPath}`);
662
1575
  }
663
1576
  }
1577
+ for (const serverName of config.tools.mcpRequestContextServers ?? []) {
1578
+ if (!isValidMcpServerName(serverName)) {
1579
+ status = "error";
1580
+ details.push(`tools.mcpRequestContextServers entry "${serverName}" is not a runtime-valid MCP server name (letters, digits, underscores, and hyphens only).`);
1581
+ continue;
1582
+ }
1583
+ const spec = configuredMcpServers[serverName];
1584
+ if (spec === undefined) {
1585
+ status = "error";
1586
+ details.push(`tools.mcpRequestContextServers names unknown MCP server "${serverName}"; declare it in tools.mcpConfigPath.`);
1587
+ continue;
1588
+ }
1589
+ if (!isStdioMcpServerSpec(spec)) {
1590
+ status = "error";
1591
+ details.push(`tools.mcpRequestContextServers entry "${serverName}" must reference a stdio MCP server (command/type:stdio), not HTTP/SSE.`);
1592
+ }
1593
+ }
1594
+ for (const serverName of config.tools.continuationServers ?? []) {
1595
+ if (!isValidMcpServerName(serverName)) {
1596
+ status = "error";
1597
+ details.push(`tools.continuationServers entry "${serverName}" is not a runtime-valid MCP server name (letters, digits, underscores, and hyphens only).`);
1598
+ continue;
1599
+ }
1600
+ const spec = configuredMcpServers[serverName];
1601
+ if (spec === undefined) {
1602
+ status = "error";
1603
+ details.push(`tools.continuationServers names unknown MCP server "${serverName}"; declare it in tools.mcpConfigPath.`);
1604
+ continue;
1605
+ }
1606
+ if (classifyContinuationMcpServerTransport(spec) === "unsupported") {
1607
+ status = "error";
1608
+ details.push(`tools.continuationServers entry "${serverName}" must reference a stdio or loopback HTTP MCP server; remote HTTP and SSE are not supported.`);
1609
+ }
1610
+ }
664
1611
  const adapterSendTools = await resolveAdapterSendToolsSettings(input, {
665
1612
  allowedTools: config.tools.allowedTools,
666
1613
  disallowedTools: config.tools.disallowedTools,
1614
+ suppressInteractionTools: directOpenCodeModels.length > 0,
667
1615
  });
668
1616
  if (adapterSendTools === undefined) {
669
1617
  details.push("No adapter-derived send tools enabled.");
@@ -671,9 +1619,518 @@ async function toolsSection(config, input) {
671
1619
  else {
672
1620
  details.push(`Adapter send tools: ${adapterSendToolNames(adapterSendTools).join(", ")}.`);
673
1621
  }
1622
+ const blockedAdapterEndpoints = await adapterSendToolNetworkPolicyWarnings(config, input, adapterSendTools, directOpenCodeModels.length > 0);
1623
+ if (blockedAdapterEndpoints.length > 0) {
1624
+ if (status !== "error") {
1625
+ status = "waiting";
1626
+ }
1627
+ details.push(...blockedAdapterEndpoints);
1628
+ }
1629
+ const effectiveMcpSources = effectiveMcpRuntimeSources(config, configuredMcpServerNames, adapterSendTools === undefined ? [] : adapterSendToolNames(adapterSendTools));
1630
+ if (directOpenCodeModels.length > 0 && effectiveMcpSources.length > 0) {
1631
+ status = "error";
1632
+ details.push(`Direct OpenCode model${directOpenCodeModels.length === 1 ? "" : "s"} ${directOpenCodeModels.join(", ")} cannot safely consume ` +
1633
+ `MCP runtime options from ${effectiveMcpSources.join("; ")}. Disable those MCP sources or use a Pi runtime (including pi:opencode-go:*).`);
1634
+ }
674
1635
  return { id: "tools", label: "Tools & MCP", status, details };
675
1636
  }
1637
+ async function continuationSection(config, input) {
1638
+ let settings;
1639
+ try {
1640
+ settings = await loadContinuationSettings({
1641
+ cwd: input.cwd,
1642
+ configPath: input.configPath,
1643
+ env: input.env,
1644
+ });
1645
+ }
1646
+ catch (error) {
1647
+ return {
1648
+ id: "continuations",
1649
+ label: "Durable continuations",
1650
+ status: "error",
1651
+ details: [`Continuation configuration is invalid: ${continuationReason(error)}`],
1652
+ };
1653
+ }
1654
+ const continuationServers = config.tools.continuationServers ?? [];
1655
+ if (!settings.configured && continuationServers.length === 0) {
1656
+ return {
1657
+ id: "continuations",
1658
+ label: "Durable continuations",
1659
+ status: "disabled",
1660
+ details: ["No continuation-capable MCP servers or detached continuation routes are configured."],
1661
+ };
1662
+ }
1663
+ if (!settings.enabled) {
1664
+ return {
1665
+ id: "continuations",
1666
+ label: "Durable continuations",
1667
+ status: "error",
1668
+ details: ["Continuation service is disabled while continuation functionality is configured."],
1669
+ };
1670
+ }
1671
+ const details = [
1672
+ `Loopback service: http://${formatContinuationHost(settings.host)}:${String(settings.port)}.`,
1673
+ `Owner-only state: ${settings.stateDir}.`,
1674
+ continuationServers.length === 0
1675
+ ? "Run-scoped continuation MCP servers: none."
1676
+ : `Run-scoped continuation MCP servers: ${continuationServers.join(", ")}.`,
1677
+ ];
1678
+ const routeNames = Object.entries(settings.namedRoutes).map(([name, route]) => `${name} (${route.mode})`);
1679
+ details.push(routeNames.length === 0 ? "Named detached routes: none." : `Named detached routes: ${routeNames.join(", ")}.`);
1680
+ const detachedNames = Object.keys(settings.detachedServices);
1681
+ details.push(detachedNames.length === 0 ? "Detached services: none." : `Detached services: ${detachedNames.join(", ")}.`);
1682
+ const state = await inspectContinuationState(settings.stateDir);
1683
+ details.push(...state.details);
1684
+ return {
1685
+ id: "continuations",
1686
+ label: "Durable continuations",
1687
+ status: state.status,
1688
+ details,
1689
+ };
1690
+ }
1691
+ async function inspectContinuationState(stateDir) {
1692
+ const details = [];
1693
+ let directory;
1694
+ try {
1695
+ directory = await lstat(stateDir);
1696
+ }
1697
+ catch (error) {
1698
+ if (continuationFsCode(error) === "ENOENT") {
1699
+ return {
1700
+ status: "ok",
1701
+ details: ["State has not been initialized; the app will create it with owner-only permissions on first start."],
1702
+ };
1703
+ }
1704
+ return { status: "error", details: [`Continuation state cannot be inspected: ${continuationReason(error)}`] };
1705
+ }
1706
+ if (!directory.isDirectory() || directory.isSymbolicLink()) {
1707
+ return { status: "error", details: ["Continuation state path must be a real directory, not a file or symlink."] };
1708
+ }
1709
+ const directorySecurityError = continuationOwnershipError(directory, "state directory", 0o700);
1710
+ if (directorySecurityError !== undefined) {
1711
+ return { status: "error", details: [directorySecurityError] };
1712
+ }
1713
+ const secretError = await inspectContinuationSecret(join(stateDir, "continuation-secret"));
1714
+ if (secretError !== undefined) {
1715
+ return { status: "error", details: [secretError] };
1716
+ }
1717
+ const manifestPath = join(stateDir, "continuation-store-v2.json");
1718
+ let manifestInfo;
1719
+ try {
1720
+ manifestInfo = await lstat(manifestPath);
1721
+ }
1722
+ catch (error) {
1723
+ if (continuationFsCode(error) !== "ENOENT") {
1724
+ return { status: "error", details: [`Continuation store manifest cannot be inspected: ${continuationReason(error)}`] };
1725
+ }
1726
+ }
1727
+ if (manifestInfo !== undefined) {
1728
+ if (!manifestInfo.isFile() || manifestInfo.isSymbolicLink()) {
1729
+ return { status: "error", details: ["Continuation store manifest must be a regular file, not a symlink."] };
1730
+ }
1731
+ const manifestSecurityError = continuationOwnershipError(manifestInfo, "store manifest", 0o600);
1732
+ if (manifestSecurityError !== undefined)
1733
+ return { status: "error", details: [manifestSecurityError] };
1734
+ let manifest;
1735
+ try {
1736
+ manifest = JSON.parse(await readFile(manifestPath, "utf8"));
1737
+ }
1738
+ catch (error) {
1739
+ return { status: "error", details: [`Continuation store manifest contains invalid JSON: ${continuationReason(error)}`] };
1740
+ }
1741
+ if (!isContinuationStoreManifest(manifest)) {
1742
+ return { status: "error", details: ["Continuation store manifest has an unsupported or malformed schema."] };
1743
+ }
1744
+ const recordsDirectoryError = await inspectContinuationRecordsDirectory(join(stateDir, "records-v2"));
1745
+ if (recordsDirectoryError !== undefined)
1746
+ return { status: "error", details: [recordsDirectoryError] };
1747
+ const transaction = await inspectContinuationTransaction(join(stateDir, "continuation-transaction-v2.json"));
1748
+ if (!transaction.valid)
1749
+ return { status: "error", details: [transaction.detail] };
1750
+ const owner = await inspectContinuationOwnerDatabase(join(stateDir, "continuations-owner.sqlite"));
1751
+ if (!owner.valid)
1752
+ return { status: "error", details: [owner.detail] };
1753
+ details.push(`Store v2: ${String(manifest.stats.records)} retained; ${String(manifest.stats.active)} active; ${String(manifest.stats.unresolvedDelivery)} delivery unknown; ${String(manifest.stats.deadLettered)} dead-lettered; ${String(manifest.stats.historyDegraded)} history-degraded deliveries; ${String(manifest.stats.terminalTombstones)} terminal tombstones; ${String(manifest.stats.capturedText)} captured answers.`, `Retention: at most ${String(manifest.stats.limits.terminalMaxRecords)} terminal tombstones and ${String(manifest.stats.limits.capturedTextMaxRecords)} captured answers.`, transaction.detail, owner.detail);
1754
+ return {
1755
+ status: transaction.pending
1756
+ || manifest.stats.unresolvedDelivery > 0
1757
+ || manifest.stats.deadLettered > 0
1758
+ || manifest.stats.historyDegraded > 0
1759
+ ? "waiting"
1760
+ : "ok",
1761
+ details,
1762
+ };
1763
+ }
1764
+ const storePath = join(stateDir, "continuations-v1.json");
1765
+ let storeInfo;
1766
+ try {
1767
+ storeInfo = await lstat(storePath);
1768
+ }
1769
+ catch (error) {
1770
+ if (continuationFsCode(error) === "ENOENT") {
1771
+ details.push("No continuation ledger has been written yet.");
1772
+ const owner = await inspectContinuationOwnerDatabase(join(stateDir, "continuations-owner.sqlite"));
1773
+ details.push(owner.detail);
1774
+ return { status: owner.valid ? "ok" : "error", details };
1775
+ }
1776
+ return { status: "error", details: [`Continuation ledger cannot be inspected: ${continuationReason(error)}`] };
1777
+ }
1778
+ if (!storeInfo.isFile() || storeInfo.isSymbolicLink()) {
1779
+ return { status: "error", details: ["Continuation ledger must be a regular file, not a symlink."] };
1780
+ }
1781
+ const storeSecurityError = continuationOwnershipError(storeInfo, "ledger", 0o600);
1782
+ if (storeSecurityError !== undefined) {
1783
+ return { status: "error", details: [storeSecurityError] };
1784
+ }
1785
+ let parsed;
1786
+ try {
1787
+ parsed = JSON.parse(await readFile(storePath, "utf8"));
1788
+ }
1789
+ catch (error) {
1790
+ return { status: "error", details: [`Continuation ledger contains invalid JSON: ${continuationReason(error)}`] };
1791
+ }
1792
+ if (!isContinuationLedger(parsed)) {
1793
+ return { status: "error", details: ["Continuation ledger has an unsupported or malformed schema."] };
1794
+ }
1795
+ const counts = Object.fromEntries(CONTINUATION_STATES.map((state) => [state, 0]));
1796
+ for (const record of Object.values(parsed.records)) {
1797
+ if (!isDoctorObject(record) || typeof record.state !== "string" || !CONTINUATION_STATES.includes(record.state)) {
1798
+ return { status: "error", details: ["Continuation ledger contains a record with an invalid lifecycle state."] };
1799
+ }
1800
+ counts[record.state] += 1;
1801
+ }
1802
+ const total = Object.values(counts).reduce((sum, count) => sum + count, 0);
1803
+ const pending = counts.claimed + counts.result_received + counts.synthesizing + counts.ready_to_deliver + counts.delivery_retry;
1804
+ details.push(`Legacy ledger awaiting v2 migration: ${String(total)} total; ${String(pending)} pending; ${String(counts.delivery_unknown)} delivery unknown; ${String(counts.dead_lettered)} dead-lettered.`);
1805
+ const owner = await inspectContinuationOwnerDatabase(join(stateDir, "continuations-owner.sqlite"));
1806
+ details.push(owner.detail);
1807
+ return {
1808
+ status: !owner.valid
1809
+ ? "error"
1810
+ : counts.delivery_unknown > 0 || counts.dead_lettered > 0
1811
+ ? "waiting"
1812
+ : "ok",
1813
+ details,
1814
+ };
1815
+ }
1816
+ async function inspectContinuationSecret(path) {
1817
+ let info;
1818
+ try {
1819
+ info = await lstat(path);
1820
+ }
1821
+ catch (error) {
1822
+ return continuationFsCode(error) === "ENOENT"
1823
+ ? undefined
1824
+ : `Continuation secret cannot be inspected: ${continuationReason(error)}`;
1825
+ }
1826
+ if (!info.isFile() || info.isSymbolicLink())
1827
+ return "Continuation secret must be a regular file, not a symlink.";
1828
+ const securityError = continuationOwnershipError(info, "secret", 0o600);
1829
+ if (securityError !== undefined)
1830
+ return securityError;
1831
+ try {
1832
+ const secret = Buffer.from((await readFile(path, "utf8")).trim(), "base64url");
1833
+ if (secret.length !== 32)
1834
+ return "Continuation secret contents are invalid.";
1835
+ }
1836
+ catch (error) {
1837
+ return `Continuation secret cannot be read: ${continuationReason(error)}`;
1838
+ }
1839
+ return undefined;
1840
+ }
1841
+ async function inspectContinuationOwnerDatabase(path) {
1842
+ let info;
1843
+ try {
1844
+ info = await lstat(path);
1845
+ }
1846
+ catch (error) {
1847
+ return continuationFsCode(error) === "ENOENT"
1848
+ ? { valid: true, detail: "The OS-backed continuation ownership database has not been initialized yet." }
1849
+ : { valid: false, detail: `Continuation ownership database cannot be inspected: ${continuationReason(error)}` };
1850
+ }
1851
+ if (!info.isFile() || info.isSymbolicLink()) {
1852
+ return { valid: false, detail: "Continuation ownership database is not a regular file." };
1853
+ }
1854
+ const securityError = continuationOwnershipError(info, "ownership database", 0o600);
1855
+ if (securityError !== undefined)
1856
+ return { valid: false, detail: securityError };
1857
+ return { valid: true, detail: "OS-backed exclusive ownership is released automatically on clean stop or process death." };
1858
+ }
1859
+ async function inspectContinuationRecordsDirectory(path) {
1860
+ let info;
1861
+ try {
1862
+ info = await lstat(path);
1863
+ }
1864
+ catch (error) {
1865
+ return continuationFsCode(error) === "ENOENT"
1866
+ ? "Continuation record directory is missing."
1867
+ : `Continuation record directory cannot be inspected: ${continuationReason(error)}`;
1868
+ }
1869
+ if (!info.isDirectory() || info.isSymbolicLink()) {
1870
+ return "Continuation record directory must be a real directory, not a file or symlink.";
1871
+ }
1872
+ const directorySecurityError = continuationOwnershipError(info, "record directory", 0o700);
1873
+ if (directorySecurityError !== undefined)
1874
+ return directorySecurityError;
1875
+ try {
1876
+ for (const entry of await readdir(path, { withFileTypes: true })) {
1877
+ if (!entry.name.endsWith(".json") && !(entry.name.startsWith(".") && entry.name.endsWith(".tmp"))) {
1878
+ return `Continuation record directory contains an unexpected entry: ${entry.name}.`;
1879
+ }
1880
+ const recordInfo = await lstat(join(path, entry.name));
1881
+ if (!recordInfo.isFile() || recordInfo.isSymbolicLink()) {
1882
+ return `Continuation record entry is not a regular file: ${entry.name}.`;
1883
+ }
1884
+ const securityError = continuationOwnershipError(recordInfo, "record", 0o600);
1885
+ if (securityError !== undefined)
1886
+ return securityError;
1887
+ }
1888
+ }
1889
+ catch (error) {
1890
+ return `Continuation record directory cannot be inspected: ${continuationReason(error)}`;
1891
+ }
1892
+ return undefined;
1893
+ }
1894
+ async function inspectContinuationTransaction(path) {
1895
+ let info;
1896
+ try {
1897
+ info = await lstat(path);
1898
+ }
1899
+ catch (error) {
1900
+ return continuationFsCode(error) === "ENOENT"
1901
+ ? { valid: true, pending: false, detail: "No interrupted durable transaction is awaiting recovery." }
1902
+ : { valid: false, pending: false, detail: `Continuation transaction cannot be inspected: ${continuationReason(error)}` };
1903
+ }
1904
+ if (!info.isFile() || info.isSymbolicLink()) {
1905
+ return { valid: false, pending: true, detail: "Continuation transaction must be a regular file, not a symlink." };
1906
+ }
1907
+ const securityError = continuationOwnershipError(info, "transaction", 0o600);
1908
+ if (securityError !== undefined)
1909
+ return { valid: false, pending: true, detail: securityError };
1910
+ return {
1911
+ valid: true,
1912
+ pending: true,
1913
+ detail: "An interrupted durable transaction is present and will be completed idempotently by the state owner.",
1914
+ };
1915
+ }
1916
+ function continuationOwnershipError(info, label, expectedMode) {
1917
+ if (typeof process.getuid === "function" && Number(info.uid) !== process.getuid()) {
1918
+ return `Continuation ${label} is not owned by the current user.`;
1919
+ }
1920
+ if (process.platform !== "win32" && (Number(info.mode) & 0o777) !== expectedMode) {
1921
+ return `Continuation ${label} permissions must be ${expectedMode.toString(8)}.`;
1922
+ }
1923
+ return undefined;
1924
+ }
1925
+ function isContinuationLedger(value) {
1926
+ return isDoctorObject(value)
1927
+ && value.schemaVersion === CONTINUATION_STORE_SCHEMA_VERSION
1928
+ && isDoctorObject(value.records);
1929
+ }
1930
+ function isContinuationStoreManifest(value) {
1931
+ if (!isDoctorObject(value)
1932
+ || value.schemaVersion !== CONTINUATION_RECORD_STORE_SCHEMA_VERSION
1933
+ || typeof value.generation !== "string"
1934
+ || !isDoctorObject(value.stats)
1935
+ || value.stats.format !== "per-record-v2"
1936
+ || !isDoctorObject(value.stats.limits))
1937
+ return false;
1938
+ return [
1939
+ value.stats.records,
1940
+ value.stats.active,
1941
+ value.stats.unresolvedDelivery,
1942
+ value.stats.deadLettered,
1943
+ value.stats.historyDegraded,
1944
+ value.stats.terminalTombstones,
1945
+ value.stats.compacted,
1946
+ value.stats.capturedText,
1947
+ value.stats.limits.terminalMaxRecords,
1948
+ value.stats.limits.terminalMaxAgeMs,
1949
+ value.stats.limits.capturedTextMaxRecords,
1950
+ value.stats.limits.capturedTextMaxAgeMs,
1951
+ ].every((entry) => Number.isSafeInteger(entry) && Number(entry) >= 0);
1952
+ }
1953
+ function isDoctorObject(value) {
1954
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1955
+ }
1956
+ function continuationFsCode(error) {
1957
+ return isDoctorObject(error) && typeof error.code === "string" ? error.code : undefined;
1958
+ }
1959
+ function continuationReason(error) {
1960
+ return error instanceof Error ? error.message : String(error);
1961
+ }
1962
+ function formatContinuationHost(host) {
1963
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
1964
+ }
1965
+ /**
1966
+ * Adapter-send tools run in a stdio MCP child governed by the native SRT
1967
+ * policy. Channel readiness alone therefore is not enough: the child also
1968
+ * needs the remote adapter API (or the app-owned interaction bridge) admitted
1969
+ * by sandbox.network.
1970
+ */
1971
+ async function adapterSendToolNetworkPolicyWarnings(config, input, settings, suppressInteractionTools) {
1972
+ if (config.sandbox === undefined || config.sandbox.mode !== "native") {
1973
+ return [];
1974
+ }
1975
+ const toolNames = settings === undefined ? [] : adapterSendToolNames(settings);
1976
+ const requirements = [];
1977
+ if (settings?.slack !== undefined) {
1978
+ requirements.push({
1979
+ label: "Slack adapter-send API",
1980
+ tools: ["SlackSendMessage"],
1981
+ url: "https://slack.com/api",
1982
+ });
1983
+ }
1984
+ if (settings?.telegram !== undefined) {
1985
+ requirements.push({
1986
+ label: "Telegram adapter-send API",
1987
+ tools: toolNames.filter((name) => name.startsWith("Telegram")),
1988
+ url: settings.telegram.apiRoot ?? "https://api.telegram.org",
1989
+ });
1990
+ }
1991
+ const askUserAllowed = !suppressInteractionTools && isAdapterSendToolAllowed("AskUser", {
1992
+ allowedTools: config.tools.allowedTools,
1993
+ disallowedTools: config.tools.disallowedTools,
1994
+ });
1995
+ const bridgeTools = [
1996
+ ...(askUserAllowed ? ["AskUser"] : []),
1997
+ ...(toolNames.includes("TelegramAskButtons") ? ["TelegramAskButtons"] : []),
1998
+ ];
1999
+ if (bridgeTools.length > 0) {
2000
+ const configuredBridgeUrl = input.env.MONO_AGENT_INTERACTION_BRIDGE_URL?.trim();
2001
+ const interaction = await loadInteractionSettings(input);
2002
+ const bridgeUrl = configuredBridgeUrl === undefined || configuredBridgeUrl.length === 0
2003
+ ? formatInteractionBridgeUrl(interaction.host, interaction.port)
2004
+ : configuredBridgeUrl;
2005
+ requirements.push({ label: "AskUser interaction bridge", tools: bridgeTools, url: bridgeUrl });
2006
+ }
2007
+ return requirements.flatMap((requirement) => {
2008
+ if (adapterSendChildNetworkAllowsUrl(config, requirement.url)) {
2009
+ return [];
2010
+ }
2011
+ const host = endpointHost(requirement.url);
2012
+ const allowlistHost = host === "::1" ? "localhost" : host;
2013
+ return [
2014
+ `Native sandbox network policy blocks ${requirement.label} host "${host}", required by ${requirement.tools.join(", ")}. ` +
2015
+ `Set sandbox.network.mode to "allowlist" and add "${allowlistHost}" to sandbox.network.allowlist, ` +
2016
+ `or disable ${requirement.tools.join(", ")}.`,
2017
+ ];
2018
+ });
2019
+ }
2020
+ function adapterSendChildNetworkAllowsUrl(config, url) {
2021
+ if (networkPolicyAllowsUrl(config.sandbox, url))
2022
+ return true;
2023
+ if (config.sandbox?.mode !== "native" || config.sandbox.network.mode !== "allowlist")
2024
+ return false;
2025
+ const host = endpointHost(url);
2026
+ return isLoopbackEndpointHost(host) && config.sandbox.network.allowlist.some(isLoopbackEndpointHost);
2027
+ }
2028
+ function isLoopbackEndpointHost(host) {
2029
+ return host === "localhost" || host === "::1" || (isIP(host) === 4 && host.split(".")[0] === "127");
2030
+ }
2031
+ function endpointHost(url) {
2032
+ try {
2033
+ const host = new URL(url).hostname.toLowerCase();
2034
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
2035
+ }
2036
+ catch {
2037
+ return url;
2038
+ }
2039
+ }
2040
+ function effectiveMcpRuntimeSources(config, configuredMcpServerNames, adapterToolNames) {
2041
+ const sources = [];
2042
+ if (configuredMcpServerNames.length > 0) {
2043
+ sources.push(`tools.mcpConfigPath (${configuredMcpServerNames.join(", ")})`);
2044
+ }
2045
+ if (config.memory?.recallTool?.enabled === true) {
2046
+ sources.push("memory.recallTool");
2047
+ }
2048
+ if (config.memory?.backend === "supermemory"
2049
+ && config.memory.supermemory?.exposeMcpServer === true
2050
+ && config.memory.supermemory.apiKey !== undefined) {
2051
+ sources.push("memory.supermemory.exposeMcpServer");
2052
+ }
2053
+ if (adapterToolNames.length > 0) {
2054
+ sources.push(`adapter send tools (${adapterToolNames.join(", ")})`);
2055
+ }
2056
+ return sources;
2057
+ }
676
2058
  async function sandboxSection(config, engine) {
2059
+ const runtimeModels = configuredRuntimeModels(config.runtime);
2060
+ const directCodexRefs = runtimeModels
2061
+ .filter((model) => model.sdk === "codex")
2062
+ .map((model) => model.reference ?? `codex:${model.model}`);
2063
+ const claudeRefs = runtimeModels
2064
+ .filter((model) => model.sdk === "claude")
2065
+ .map((model) => model.reference ?? `claude:${model.model}`);
2066
+ const directOpenCodeRefs = runtimeModels
2067
+ .filter((model) => model.sdk === "opencode")
2068
+ .map((model) => model.reference ?? `opencode:${model.provider ?? "unknown"}:${model.model}`);
2069
+ const routeSafety = config.runtime.routeSafety ?? "uniform";
2070
+ if (routeSafety === "per-route-native") {
2071
+ const monoSandboxActive = config.sandbox !== undefined && config.sandbox.mode !== "off";
2072
+ const piRoutes = runtimeModels.filter((model) => model.sdk === "pi");
2073
+ const details = runtimeModels.map((model, index) => routeNativeSafetyDetail(model, index, config));
2074
+ let status = !monoSandboxActive && piRoutes.length > 0 ? "disabled" : "ok";
2075
+ if (monoSandboxActive && piRoutes.length > 0) {
2076
+ const state = await resolveSandboxEffectiveState({
2077
+ policy: config.sandbox,
2078
+ ...(engine === undefined ? {} : { engine }),
2079
+ });
2080
+ const warning = sandboxEffectiveStateWarning(state);
2081
+ details.push(`Pi route SRT policy: mode ${config.sandbox.mode}, network ${config.sandbox.network.mode}, fallback ${config.sandbox.fallback}.`, describeSandboxEffectiveState(state), ...(warning === undefined ? [] : [warning]));
2082
+ if (warning !== undefined || state.effective === "blocked")
2083
+ status = "waiting";
2084
+ }
2085
+ else if (piRoutes.length > 0) {
2086
+ details.push("Pi route SRT policy: disabled; Bash and stdio MCP subprocesses run unsandboxed.");
2087
+ }
2088
+ else if (monoSandboxActive) {
2089
+ details.push("The configured mono-agent SRT policy has no Pi route to enforce it; provider-owned route contracts below apply instead.");
2090
+ status = "waiting";
2091
+ }
2092
+ if (monoSandboxActive && runtimeModels.some((model) => model.sdk !== "pi")) {
2093
+ details.push("[WARN] Per-route-native explicitly does not project mono-agent readableRoots, writableRoots, denyWrite, or network rules onto non-Pi routes; review each route contract before start.");
2094
+ status = "waiting";
2095
+ }
2096
+ if (runtimeModels.some((model) => model.sdk === "codex") && config.runtime.permissionMode === "bypassPermissions") {
2097
+ status = "waiting";
2098
+ }
2099
+ return { id: "sandbox", label: "Sandbox", status, details };
2100
+ }
2101
+ const incompatibleDetails = [];
2102
+ if (config.sandbox !== undefined && config.sandbox.mode !== "off" && directCodexRefs.length > 0) {
2103
+ const codexPosture = directCodexSandboxPosture(config.runtime.permissionMode);
2104
+ incompatibleDetails.push(`Native mono-agent sandbox policy cannot govern direct Codex runtime${directCodexRefs.length === 1 ? "" : "s"}: ${directCodexRefs.join(", ")}.`, `${codexPosture.detail} Remove the mono-agent sandbox block or use Pi when exact srt roots, denyWrite, or network policy are required.`);
2105
+ }
2106
+ if (config.sandbox !== undefined && config.sandbox.mode !== "off" && claudeRefs.length > 0) {
2107
+ incompatibleDetails.push(`Native mono-agent sandbox policy cannot govern Claude runtime${claudeRefs.length === 1 ? "" : "s"}: ${claudeRefs.join(", ")}.`, "Claude's provider-owned tool loop does not consume mono-agent sandboxPolicy. Set sandbox.mode to off, remove the sandbox block, or use a Pi runtime when mono-agent srt enforcement is required.");
2108
+ }
2109
+ if (config.sandbox !== undefined && config.sandbox.mode !== "off" && directOpenCodeRefs.length > 0) {
2110
+ incompatibleDetails.push(`Native mono-agent sandbox policy cannot govern direct OpenCode runtime${directOpenCodeRefs.length === 1 ? "" : "s"}: ${directOpenCodeRefs.join(", ")}.`, "Direct OpenCode's provider-owned tool loop does not consume mono-agent sandboxPolicy. Set sandbox.mode to off, remove the sandbox block, or use a pi:opencode-go:* runtime when mono-agent srt enforcement is required.");
2111
+ }
2112
+ if (incompatibleDetails.length > 0) {
2113
+ return {
2114
+ id: "sandbox",
2115
+ label: "Sandbox",
2116
+ status: "error",
2117
+ details: incompatibleDetails,
2118
+ };
2119
+ }
2120
+ if (directCodexRefs.length > 0 && (config.sandbox === undefined || config.sandbox.mode === "off")) {
2121
+ const posture = directCodexSandboxPosture(config.runtime.permissionMode);
2122
+ return {
2123
+ id: "sandbox",
2124
+ label: "Sandbox",
2125
+ status: posture.status,
2126
+ details: [
2127
+ posture.detail,
2128
+ config.sandbox === undefined
2129
+ ? "No mono-agent native srt policy is configured."
2130
+ : "The mono-agent native srt policy is explicitly off; the Codex-native posture still applies.",
2131
+ ],
2132
+ };
2133
+ }
677
2134
  if (config.sandbox === undefined) {
678
2135
  return { id: "sandbox", label: "Sandbox", status: "disabled", details: ["No sandbox policy configured."] };
679
2136
  }
@@ -701,6 +2158,44 @@ async function sandboxSection(config, engine) {
701
2158
  details,
702
2159
  };
703
2160
  }
2161
+ function routeNativeSafetyDetail(model, index, config) {
2162
+ const label = index === 0 ? "Primary" : `Fallback ${index}`;
2163
+ const ref = referenceOf(model);
2164
+ if (model.sdk === "pi") {
2165
+ if (config.sandbox === undefined || config.sandbox.mode === "off") {
2166
+ return `${label} ${ref}: Pi-owned tools use mono-agent tool policy; SRT is disabled, so Bash and stdio MCP subprocesses run unsandboxed.`;
2167
+ }
2168
+ if (config.sandbox.fallback === "unsafe-host-process") {
2169
+ return `${label} ${ref}: Pi-owned tools use the configured mono-agent SRT policy; its explicit unsafe-host-process fallback can run subprocesses unsandboxed when SRT is unavailable.`;
2170
+ }
2171
+ return `${label} ${ref}: Pi-owned tools use the configured mono-agent SRT policy and fail closed when it is unavailable.`;
2172
+ }
2173
+ if (model.sdk === "claude") {
2174
+ return `${label} ${ref}: Claude provider-owned permissions apply; mono-agent SRT filesystem/network rules are not projected.`;
2175
+ }
2176
+ if (model.sdk === "codex") {
2177
+ return `${label} ${ref}: ${directCodexSandboxPosture(config.runtime.permissionMode).detail}`;
2178
+ }
2179
+ return `${label} ${ref}: OpenCode provider-owned execution with exact allow-all tool policy applies; mono-agent SRT rules are not projected.`;
2180
+ }
2181
+ function directCodexSandboxPosture(permissionMode) {
2182
+ if (permissionMode === "bypassPermissions") {
2183
+ return {
2184
+ status: "waiting",
2185
+ detail: "[WARN] Direct Codex bypassPermissions uses native danger-full-access with no filesystem or network sandbox; unattended approval prompts are still disabled.",
2186
+ };
2187
+ }
2188
+ if (permissionMode === "plan") {
2189
+ return {
2190
+ status: "ok",
2191
+ detail: "Direct Codex plan mode uses its native read-only sandbox with network disabled; unattended escalation requests are denied.",
2192
+ };
2193
+ }
2194
+ return {
2195
+ status: "ok",
2196
+ detail: "Direct Codex default/acceptEdits mode uses its native workspace-write sandbox with network disabled; unattended escalation requests are denied.",
2197
+ };
2198
+ }
704
2199
  async function runsSection(input, config) {
705
2200
  const artifactDir = await resolveAppArtifactDir(input);
706
2201
  const { totalRuns, runs, warnings } = await listRecordedRuns({ artifactDir, maxRuns: RUNS_HEALTH_MAX_RUNS, scope: "agent" });