sliceagent 0.1.7__tar.gz → 0.1.8__tar.gz

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 (206) hide show
  1. {sliceagent-0.1.7 → sliceagent-0.1.8}/CHANGELOG.md +25 -0
  2. {sliceagent-0.1.7 → sliceagent-0.1.8}/PKG-INFO +1 -1
  3. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/__init__.py +1 -1
  4. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/cli.py +40 -10
  5. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/config.py +6 -1
  6. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/llm.py +6 -0
  7. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/onboarding.py +57 -7
  8. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/policy.py +28 -4
  9. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/tui.py +9 -2
  10. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bughunt_fixes.py +4 -1
  11. sliceagent-0.1.8/tests/test_config_journey.py +260 -0
  12. sliceagent-0.1.7/tests/test_config_journey.py +0 -119
  13. {sliceagent-0.1.7 → sliceagent-0.1.8}/.dockerignore +0 -0
  14. {sliceagent-0.1.7 → sliceagent-0.1.8}/.env.example +0 -0
  15. {sliceagent-0.1.7 → sliceagent-0.1.8}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  16. {sliceagent-0.1.7 → sliceagent-0.1.8}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  17. {sliceagent-0.1.7 → sliceagent-0.1.8}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  18. {sliceagent-0.1.7 → sliceagent-0.1.8}/.github/dependabot.yml +0 -0
  19. {sliceagent-0.1.7 → sliceagent-0.1.8}/.github/workflows/ci.yml +0 -0
  20. {sliceagent-0.1.7 → sliceagent-0.1.8}/.github/workflows/publish.yml +0 -0
  21. {sliceagent-0.1.7 → sliceagent-0.1.8}/.gitignore +0 -0
  22. {sliceagent-0.1.7 → sliceagent-0.1.8}/CODE_OF_CONDUCT.md +0 -0
  23. {sliceagent-0.1.7 → sliceagent-0.1.8}/CONTRIBUTING.md +0 -0
  24. {sliceagent-0.1.7 → sliceagent-0.1.8}/Dockerfile +0 -0
  25. {sliceagent-0.1.7 → sliceagent-0.1.8}/LICENSE +0 -0
  26. {sliceagent-0.1.7 → sliceagent-0.1.8}/NOTICE +0 -0
  27. {sliceagent-0.1.7 → sliceagent-0.1.8}/QUICKSTART.md +0 -0
  28. {sliceagent-0.1.7 → sliceagent-0.1.8}/README.md +0 -0
  29. {sliceagent-0.1.7 → sliceagent-0.1.8}/SECURITY.md +0 -0
  30. {sliceagent-0.1.7 → sliceagent-0.1.8}/examples/plugins/hello/__init__.py +0 -0
  31. {sliceagent-0.1.7 → sliceagent-0.1.8}/examples/plugins/hello/plugin.toml +0 -0
  32. {sliceagent-0.1.7 → sliceagent-0.1.8}/install.sh +0 -0
  33. {sliceagent-0.1.7 → sliceagent-0.1.8}/pyproject.toml +0 -0
  34. {sliceagent-0.1.7 → sliceagent-0.1.8}/scripts/gen_config_reference.py +0 -0
  35. {sliceagent-0.1.7 → sliceagent-0.1.8}/scripts/run_tests.sh +0 -0
  36. {sliceagent-0.1.7 → sliceagent-0.1.8}/sliceagent.toml.example +0 -0
  37. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/__main__.py +0 -0
  38. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/access.py +0 -0
  39. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/agents.py +0 -0
  40. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/background_review.py +0 -0
  41. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/binsniff.py +0 -0
  42. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/clock.py +0 -0
  43. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/code_grep.py +0 -0
  44. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/code_index.py +0 -0
  45. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/context_overflow.py +0 -0
  46. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/envspec.py +0 -0
  47. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/errors.py +0 -0
  48. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/events.py +0 -0
  49. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/finding_types.py +0 -0
  50. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/flags.py +0 -0
  51. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/fuzzy.py +0 -0
  52. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/guardrails.py +0 -0
  53. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/guidance.py +0 -0
  54. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/hippocampus.py +0 -0
  55. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/hooks.py +0 -0
  56. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/interfaces.py +0 -0
  57. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/loop.py +0 -0
  58. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/mcp_client.py +0 -0
  59. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/mcp_security.py +0 -0
  60. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/memory.py +0 -0
  61. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/metrics.py +0 -0
  62. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/model_catalog.py +0 -0
  63. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/monitor.py +0 -0
  64. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/neocortex.py +0 -0
  65. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/oracle.py +0 -0
  66. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/pagetable.py +0 -0
  67. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/pfc.py +0 -0
  68. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/plugins.py +0 -0
  69. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/procman.py +0 -0
  70. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/prompt.py +0 -0
  71. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/records.py +0 -0
  72. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/recovery.py +0 -0
  73. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/regions.py +0 -0
  74. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/registry.py +0 -0
  75. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/retriever.py +0 -0
  76. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/safety.py +0 -0
  77. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/sandbox.py +0 -0
  78. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/scheduler.py +0 -0
  79. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/search_index.py +0 -0
  80. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/seed.py +0 -0
  81. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/sensory_cortex.py +0 -0
  82. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/session.py +0 -0
  83. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/skill_provenance.py +0 -0
  84. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/skill_usage.py +0 -0
  85. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/skills.py +0 -0
  86. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/subagent.py +0 -0
  87. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/subdir_hints.py +0 -0
  88. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/swap.py +0 -0
  89. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/taskstate.py +0 -0
  90. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/telemetry.py +0 -0
  91. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/terminal.py +0 -0
  92. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/text_utils.py +0 -0
  93. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/tool_summary.py +0 -0
  94. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/tools.py +0 -0
  95. {sliceagent-0.1.7 → sliceagent-0.1.8}/src/sliceagent/web.py +0 -0
  96. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/README.md +0 -0
  97. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_active_focus.py +0 -0
  98. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_agents.py +0 -0
  99. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_ask_echo.py +0 -0
  100. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_ask_user.py +0 -0
  101. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_background_review.py +0 -0
  102. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_binary_view.py +0 -0
  103. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bound_is_relevance.py +0 -0
  104. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_breadth_e2e.py +0 -0
  105. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_2b.py +0 -0
  106. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_breadth_wave.py +0 -0
  107. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_current_request.py +0 -0
  108. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_linenum.py +0 -0
  109. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_loop_wave.py +0 -0
  110. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_memory_wave.py +0 -0
  111. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_p0a.py +0 -0
  112. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_p0b.py +0 -0
  113. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_p0c.py +0 -0
  114. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_selfreview.py +0 -0
  115. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_selfreview_siblings.py +0 -0
  116. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_skills_h1.py +0 -0
  117. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_slice_wave.py +0 -0
  118. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_subagent_wave.py +0 -0
  119. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_bugfix_tools_wave.py +0 -0
  120. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_cache_manifest.py +0 -0
  121. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_checkpoint.py +0 -0
  122. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_cli_smoke.py +0 -0
  123. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_closure.py +0 -0
  124. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_code_grep.py +0 -0
  125. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_code_index.py +0 -0
  126. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_consolidate.py +0 -0
  127. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_context_overflow.py +0 -0
  128. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_conversation.py +0 -0
  129. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_coresidency.py +0 -0
  130. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_echo_before_blocking.py +0 -0
  131. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_episode.py +0 -0
  132. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_errors_backoff.py +0 -0
  133. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_esc_sentinel.py +0 -0
  134. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_exec_env.py +0 -0
  135. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_finding_types.py +0 -0
  136. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_flags.py +0 -0
  137. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_fuzzy.py +0 -0
  138. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_ghost_index.py +0 -0
  139. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_guardrails.py +0 -0
  140. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_guidance.py +0 -0
  141. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_history.py +0 -0
  142. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_invariant_fixes.py +0 -0
  143. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_live_composer.py +0 -0
  144. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_llm_streaming.py +0 -0
  145. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_llm_usage_cache.py +0 -0
  146. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_llm_watchdog.py +0 -0
  147. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_log_redaction.py +0 -0
  148. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_loop_overflow.py +0 -0
  149. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_mcp_output_cap.py +0 -0
  150. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_mcp_runtime.py +0 -0
  151. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_memory.py +0 -0
  152. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_memory_persist.py +0 -0
  153. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_menu_select.py +0 -0
  154. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_metrics.py +0 -0
  155. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_mining.py +0 -0
  156. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_mission_tier.py +0 -0
  157. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_model_catalog.py +0 -0
  158. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_monitor.py +0 -0
  159. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_onboarding.py +0 -0
  160. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_pageout_compaction.py +0 -0
  161. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_permission_patterns.py +0 -0
  162. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_pinned_composer.py +0 -0
  163. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_plan_tier.py +0 -0
  164. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_procman.py +0 -0
  165. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_product_features.py +0 -0
  166. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_progress_status.py +0 -0
  167. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_prompt_ab.py +0 -0
  168. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_provenance.py +0 -0
  169. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_provider_lineup.py +0 -0
  170. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_proxy_interrupt.py +0 -0
  171. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_reach.py +0 -0
  172. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_readonly_subagent.py +0 -0
  173. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_recall_search.py +0 -0
  174. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_records.py +0 -0
  175. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_refault.py +0 -0
  176. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_registry_validation.py +0 -0
  177. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_reliability.py +0 -0
  178. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_repo_map_bounded.py +0 -0
  179. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_requirements.py +0 -0
  180. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_review_fixes.py +0 -0
  181. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_route_lexical.py +0 -0
  182. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_safety.py +0 -0
  183. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_seal_markdown.py +0 -0
  184. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_search_index.py +0 -0
  185. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_skill_meta.py +0 -0
  186. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_skill_metadata.py +0 -0
  187. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_subdir_hints.py +0 -0
  188. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_task_agnostic.py +0 -0
  189. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_task_state_roundtrip.py +0 -0
  190. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_telemetry_convergence.py +0 -0
  191. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_terminal.py +0 -0
  192. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_tool_dedup.py +0 -0
  193. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_tool_result_ok.py +0 -0
  194. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_tools_robust.py +0 -0
  195. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_topic_switch.py +0 -0
  196. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_trajectory.py +0 -0
  197. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_tui.py +0 -0
  198. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_tui_menus.py +0 -0
  199. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_tui_render.py +0 -0
  200. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_tui_widgets.py +0 -0
  201. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_turn_budget.py +0 -0
  202. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_verification_agent.py +0 -0
  203. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_web.py +0 -0
  204. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_workspace.py +0 -0
  205. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_world_region.py +0 -0
  206. {sliceagent-0.1.7 → sliceagent-0.1.8}/tests/test_world_state.py +0 -0
@@ -5,6 +5,31 @@ this project aims for [Semantic Versioning](https://semver.org/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.1.8] — 2026-07-02
9
+
10
+ Launch-day bug-hunt round: a security fix plus config-robustness and /model correctness.
11
+
12
+ ### Security
13
+ - `run_command` in the default *teenager* mode no longer auto-runs exec/write commands disguised as
14
+ "read-only": `env <program>`, `sort -o FILE`, `date -s`, `tree -o FILE`, `uniq IN OUT`, and
15
+ `git grep --open-files-in-pager` now take the confirm path (arbitrary code-exec / file-overwrite
16
+ confirm-bypass). Read-only siblings like `du -s` / `grep -o` still auto-run.
17
+
18
+ ### Fixed
19
+ - Boot no longer cross-wires a prefs-pinned provider's key with the DEFAULT provider's endpoint (an
20
+ env `gpt-5.5` @ OpenAI pin on a DeepSeek-default config used to 401 every call on relaunch).
21
+ - A prefs `provider`/model pin whose `[providers.<id>]` table was removed from config.toml is now
22
+ dropped at boot instead of forcing a model onto the wrong endpoint.
23
+ - The wizard no longer silently ERASES other providers' API keys when config.toml is unparseable —
24
+ the old file is moved to a non-clobbering `.bak` first (keys recoverable).
25
+ - `sliceagent config` / `config --use` / the init wizard no longer crash on a non-UTF-8 config.toml
26
+ or a scalar value under `[providers]` (they degrade).
27
+ - `llm.switch()` (a `/model` provider hop) closes the replaced HTTP client — no more fd leak per hop.
28
+ - The `/model` reasoning menu offers `high` (not a misleading `max`) for OpenRouter models, and offers
29
+ the levels the *target* provider supports when the pick rebinds the endpoint.
30
+ - The wizard's typed provider fallback rejects an unknown choice instead of silently configuring
31
+ OpenRouter.
32
+
8
33
  ## [0.1.7] — 2026-07-02
9
34
 
10
35
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sliceagent
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Summary: A coding agent with a new context-engineering framework: bounded, deterministic, reconstructed context (slice architecture) — built for long-horizon work.
5
5
  Author: TT-Wang
6
6
  License-Expression: MIT
@@ -1,3 +1,3 @@
1
1
  """sliceagent — a memory-native coding agent (deterministic, bounded, reconstructed context)."""
2
2
 
3
- __version__ = "0.1.7"
3
+ __version__ = "0.1.8"
@@ -151,6 +151,21 @@ def _ws_name(path: str) -> str:
151
151
  return "~" if p == os.path.realpath(os.path.expanduser("~")) else (os.path.basename(p) or p)
152
152
 
153
153
 
154
+ def _env_from_config(c, pid: str | None = None) -> None:
155
+ """Populate LLM_API_KEY/LLM_BASE_URL from config (ENV still wins). A prefs-pinned provider `pid`
156
+ binds AS A UNIT: its key with ITS OWN endpoint — an absent base_url means the SDK default (OpenAI),
157
+ NOT the default provider's base_url (that fallback cross-wired an openai key onto the deepseek
158
+ endpoint after `/model`-hop + restart when default_provider pointed elsewhere)."""
159
+ tbl = (c.providers() or {}).get(pid) if pid else None
160
+ if isinstance(tbl, dict) and tbl.get("api_key"):
161
+ key, base = tbl["api_key"], tbl.get("base_url") or ""
162
+ else: # no/unusable pin → the default provider's own pairing
163
+ key, base = c.api_key, c.base_url
164
+ for _env, _val in (("LLM_API_KEY", key), ("LLM_BASE_URL", base)):
165
+ if not os.environ.get(_env) and _val:
166
+ os.environ[_env] = _val
167
+
168
+
154
169
  def main() -> None:
155
170
  # subcommands (onboarding / discovery) are handled BEFORE any key gate, so `sliceagent init` runs on a
156
171
  # machine with nothing configured yet. A bare `sliceagent` (or one with non-subcommand args) falls through.
@@ -165,14 +180,6 @@ def main() -> None:
165
180
  from .config import load_config, load_prefs
166
181
  _boot_prefs = load_prefs() # the last /model choice may pin a PROVIDER (endpoint+key), not just a model
167
182
 
168
- def _env_from_config(c, pid: str | None = None) -> None:
169
- tbl = (c.providers() or {}).get(pid) if pid else None
170
- key = (tbl or {}).get("api_key") or c.api_key
171
- base = ((tbl or {}).get("base_url") if tbl and tbl.get("api_key") else None) or c.base_url
172
- for _env, _val in (("LLM_API_KEY", key), ("LLM_BASE_URL", base)):
173
- if not os.environ.get(_env) and _val:
174
- os.environ[_env] = _val
175
-
176
183
  def _key_present() -> bool:
177
184
  return bool(os.environ.get("LLM_API_KEY") or os.environ.get("OPENAI_API_KEY")
178
185
  or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("DEEPSEEK_API_KEY")
@@ -189,6 +196,14 @@ def main() -> None:
189
196
  return run_init() == 0
190
197
 
191
198
  cfg = load_config()
199
+ # A prefs `provider` pin whose [providers.<id>] table was since removed from config.toml is STALE:
200
+ # its key/endpoint already fall back to the default provider (see _env_from_config), but its pinned
201
+ # MODEL would still win at the resolution below and get sent to the wrong endpoint (model_not_found
202
+ # every turn). Drop the whole stale pin so model/endpoint resolve together from the live config.
203
+ if _boot_prefs.get("provider") and _boot_prefs["provider"] not in (cfg.providers() or {}):
204
+ from .config import save_prefs as _sp
205
+ _sp({"provider": None, "model": None}) # None DELETES (see save_prefs)
206
+ _boot_prefs.pop("provider", None); _boot_prefs.pop("model", None)
192
207
  _env_from_config(cfg, _boot_prefs.get("provider"))
193
208
  if not _key_present() and _first_run_setup("Welcome! No API key configured yet"):
194
209
  cfg = load_config() # the wizard just wrote ~/.sliceagent/config.toml — pick it up
@@ -468,6 +483,18 @@ def main() -> None:
468
483
  ans = input(f" ⚠ allow {name} {str(detail)[:60]!r}? ({reason}) [y]es/[n]o/[a]lways: ").strip().lower()
469
484
  return {"y": "yes", "yes": "yes", "a": "always", "always": "always"}.get(ans, "no")
470
485
 
486
+ def _live_pid():
487
+ """The configured provider the LIVE llm is actually bound to (endpoint+key match), or None.
488
+ Prefs must pin what's IN EFFECT, not the last menu pick — a typed `/model <name>` keeps the
489
+ endpoint, so blindly re-saving the old pin resurrected a dead endpoint+model pairing at boot."""
490
+ lbase = getattr(llm, "_base_url", "") or ""
491
+ lkey = getattr(llm.client, "api_key", None)
492
+ for p, t in (cfg.providers() or {}).items():
493
+ if isinstance(t, dict) and t.get("api_key") and t["api_key"] == lkey \
494
+ and (t.get("base_url") or "") == lbase:
495
+ return p
496
+ return None
497
+
471
498
  def _handle_slash(line): # TUI navigation palette — wired to existing session ops
472
499
  nonlocal cfg # /config hot-reloads the config after the in-session wizard
473
500
  parts = line.split(maxsplit=1)
@@ -595,8 +622,9 @@ def main() -> None:
595
622
  _kw = {"base_url": _tbl.get("base_url") or "", "api_key": _tbl["api_key"]}
596
623
  llm.switch(model=_model, reasoning=_reasoning, **_kw)
597
624
  _stats["model"] = llm.model
625
+ # pin the provider ACTUALLY in effect (None DELETES a stale pin — see save_prefs)
598
626
  save_prefs({"model": llm.model, "reasoning": llm.reasoning,
599
- **({"provider": _pid} if _pid else {})})
627
+ "provider": _pid or _live_pid()})
600
628
  note = _reasoning_note(llm)
601
629
  _console.print(f" ✓ model → [bold]{llm.model}[/]"
602
630
  + (f" @ [bold]{_pid}[/]" if _pid else "")
@@ -619,7 +647,9 @@ def main() -> None:
619
647
  _console.print(" effort must be one of: fast | full | high | max"); return True
620
648
  llm.switch(model=name, reasoning=eff)
621
649
  _stats["model"] = llm.model
622
- save_prefs({"model": llm.model, "reasoning": llm.reasoning})
650
+ # typed /model keeps the endpoint — re-resolve the pin against the LIVE binding so a
651
+ # stale provider pin can't resurrect the old endpoint under this model at next boot
652
+ save_prefs({"model": llm.model, "reasoning": llm.reasoning, "provider": _live_pid()})
623
653
  note = _reasoning_note(llm)
624
654
  _console.print(f" ✓ model → [bold]{llm.model}[/]"
625
655
  + (f" · reasoning [bold]{llm.reasoning}[/]" if eff else "")
@@ -52,12 +52,17 @@ def load_prefs() -> dict:
52
52
 
53
53
 
54
54
  def save_prefs(updates: dict) -> None:
55
- """Merge non-empty `updates` into the prefs sidecar (atomic write). Best-effort; never raises."""
55
+ """Merge non-empty `updates` into the prefs sidecar (atomic write); an explicit None DELETES the
56
+ key (a stale `provider` pin must be removable — merge-only let an old endpoint pin resurrect at
57
+ the next boot under a model it doesn't serve). Best-effort; never raises."""
56
58
  try:
57
59
  import json
58
60
  path = _prefs_path()
59
61
  os.makedirs(os.path.dirname(path), exist_ok=True)
60
62
  cur = load_prefs()
63
+ for k, v in updates.items():
64
+ if v is None:
65
+ cur.pop(k, None)
61
66
  cur.update({k: v for k, v in updates.items() if v})
62
67
  tmp = path + ".tmp"
63
68
  with open(tmp, "w", encoding="utf-8") as f:
@@ -283,7 +283,13 @@ class OpenAILLM:
283
283
  ckw: dict = {"api_key": new_key}
284
284
  if new_base:
285
285
  ckw["base_url"] = new_base
286
+ old_client = getattr(self, "client", None)
286
287
  self.client = OpenAI(http_client=http_client, timeout=timeout, max_retries=2, **ckw)
288
+ if old_client is not None: # close the replaced connection pool — /model hops leaked fds
289
+ try:
290
+ old_client.close()
291
+ except Exception: # noqa: BLE001 — cleanup must never break the switch
292
+ pass
287
293
  self._base_url = new_base or ""
288
294
  self._drop_reasoning_effort = False
289
295
  if model:
@@ -140,10 +140,42 @@ def _read_config(path: str) -> dict:
140
140
  try:
141
141
  with open(path, "rb") as f:
142
142
  return tomllib.load(f)
143
- except (OSError, tomllib.TOMLDecodeError):
143
+ except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
144
+ # sibling parity with config.py:_read_toml — a non-UTF-8 or malformed config must DEGRADE, not
145
+ # crash `sliceagent config` / `config --use` / the init wizard with a traceback.
144
146
  return {}
145
147
 
146
148
 
149
+ def _backup_if_corrupt(path: str) -> "str | None":
150
+ """A rewrite reads the existing config to MERGE into; but _read_config returns {} for BOTH 'missing'
151
+ and 'unparseable', so a rewrite over a corrupt file would silently ERASE every provider key + section
152
+ it couldn't parse. Guard the writers: if `path` exists and is non-empty but does NOT parse, move it
153
+ aside to a non-clobbering .bak first (the keys survive, recoverable) and return the backup path; else
154
+ None (absent / empty / valid — nothing to protect)."""
155
+ import tomllib
156
+ try:
157
+ with open(path, "rb") as f:
158
+ raw = f.read()
159
+ except OSError:
160
+ return None
161
+ if not raw.strip():
162
+ return None
163
+ try:
164
+ tomllib.loads(raw.decode("utf-8"))
165
+ return None # parses fine → a normal merge is safe
166
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
167
+ pass
168
+ bak, n = path + ".bak", 0
169
+ while os.path.exists(bak): # never clobber an earlier backup
170
+ n += 1
171
+ bak = f"{path}.bak.{n}"
172
+ try:
173
+ os.replace(path, bak)
174
+ return bak
175
+ except OSError:
176
+ return None
177
+
178
+
147
179
  def _atomic_write(path: str, body: str) -> None:
148
180
  """ATOMIC 0600 write (the file holds an API key — never leave it half-written): write a temp in the same
149
181
  dir, fsync, then os.replace(); on ANY failure remove the temp so no key-bearing fragment is left behind."""
@@ -170,8 +202,10 @@ def _atomic_write(path: str, body: str) -> None:
170
202
  pass
171
203
 
172
204
 
173
- def _save_provider(path: str, *, pid: str, model: str, api_key: str, base_url: str) -> None:
174
- """Merge a provider into the config: add/update [providers.<pid>], set it as the default, keep the rest."""
205
+ def _save_provider(path: str, *, pid: str, model: str, api_key: str, base_url: str) -> "str | None":
206
+ """Merge a provider into the config: add/update [providers.<pid>], set it as the default, keep the rest.
207
+ Returns the path of a backup taken when the existing config was unparseable (else None)."""
208
+ bak = _backup_if_corrupt(path) # never silently erase keys in a corrupt config
175
209
  data = _read_config(path)
176
210
  provs = data.setdefault("providers", {})
177
211
  if not isinstance(provs, dict): # a corrupt non-dict providers must not crash on provs[pid]=
@@ -186,6 +220,7 @@ def _save_provider(path: str, *, pid: str, model: str, api_key: str, base_url: s
186
220
  agent["default_provider"] = pid
187
221
  agent["model"] = model # keep top-level model in sync (back-compat)
188
222
  _atomic_write(path, _emit_toml(data))
223
+ return bak
189
224
 
190
225
 
191
226
  def _test_key(model: str, api_key: str, base_url: str, llm_factory) -> tuple[bool, str]:
@@ -250,8 +285,20 @@ def run_init(*, inp=input, getpw=None, llm_factory=None, home=None) -> int:
250
285
  for k in keys:
251
286
  _id, label, _base, model0 = PROVIDERS[k]
252
287
  out(f" {k}. {label}" + (f" ({model0})" if model0 else ""))
253
- choice = inp(" > ").strip() or "1"
254
- pid, label, base_url, model = PROVIDERS.get(choice, PROVIDERS["1"])
288
+ # an unrecognized answer must NOT silently become provider 1 (a typed "openai" used to
289
+ # configure OpenRouter without a word) — accept a number OR a provider id, else re-ask.
290
+ choice = None
291
+ for _attempt in range(3):
292
+ raw = inp(" > ").strip() or "1"
293
+ if raw in PROVIDERS:
294
+ choice = raw; break
295
+ byname = next((k for k in keys if PROVIDERS[k][0] == raw.lower()), None)
296
+ if byname:
297
+ choice = byname; break
298
+ out(f" not a valid choice: {raw!r} — enter 1-{len(PROVIDERS)} or a provider name")
299
+ if choice is None:
300
+ out(" no valid choice — aborting."); return 1
301
+ pid, label, base_url, model = PROVIDERS[choice]
255
302
  if pid == "custom":
256
303
  base_url = inp(" Base URL (OpenAI-compatible, e.g. https://host/v1): ").strip()
257
304
  # RE-CONFIGURING an ALREADY-SAVED provider (re-running `sliceagent init` to update the model,
@@ -303,7 +350,9 @@ def run_init(*, inp=input, getpw=None, llm_factory=None, home=None) -> int:
303
350
  if cont not in ("y", "yes"):
304
351
  out(" Not saved. Re-run `sliceagent init` to try again."); return 1
305
352
 
306
- _save_provider(path, pid=pid, model=model, api_key=key, base_url=base_url)
353
+ bak = _save_provider(path, pid=pid, model=model, api_key=key, base_url=base_url)
354
+ if bak:
355
+ out(f" ⚠ the existing config didn't parse — moved it to {bak} (its keys are recoverable there).")
307
356
  out(f"\n Saved provider '{pid}' (model {model}) → {path} (0600)")
308
357
  out(" Ready. Run: sliceagent\n")
309
358
  return 0
@@ -356,7 +405,8 @@ def run_config(argv=None, *, home=None, env=None) -> int:
356
405
  out(" providers (* = default · `config --use <id>` to switch):")
357
406
  for pid, p in provs.items():
358
407
  mark = "*" if pid == default else " "
359
- out(f" {mark} {pid} ({(p or {}).get('model', '?')})")
408
+ model = p.get("model", "?") if isinstance(p, dict) else "?" # a scalar under [providers] must not crash
409
+ out(f" {mark} {pid} ({model})")
360
410
  out(" set values:")
361
411
  any_set = False
362
412
  for e in REGISTRY:
@@ -96,11 +96,30 @@ def ask_mutations(name: str, args: dict) -> Optional[ToolDecision]:
96
96
  # mode runs it without a confirm prompt. Deny-by-default: an unknown verb still asks. The catastrophic floor
97
97
  # (no_dangerous_commands) runs BEFORE this, so e.g. `cat /etc/passwd` is still denied.
98
98
  _RO_VERBS = frozenset((
99
- "ls", "pwd", "cat", "head", "tail", "wc", "echo", "which", "type", "env", "printenv", "date",
99
+ "ls", "pwd", "cat", "head", "tail", "wc", "echo", "which", "type", "printenv", "date",
100
100
  "whoami", "hostname", "uname", "id", "groups", "tree", "stat", "file", "du", "df", "realpath",
101
- "dirname", "basename", "grep", "rg", "egrep", "fgrep", "sort", "uniq", "nl", "cut", "column",
101
+ "dirname", "basename", "grep", "rg", "egrep", "fgrep", "sort", "nl", "cut", "column",
102
102
  "cksum", "md5", "md5sum", "sha1sum", "sha256sum", "true",
103
103
  ))
104
+ # `env` and `uniq` were REMOVED from the allowlist: `env <program>` executes an arbitrary program, and
105
+ # `uniq [IN] OUT` OVERWRITES its optional second positional — neither is provably read-only, so they now
106
+ # take the normal confirm path. A few remaining allowlisted verbs stay read-only ONLY without a specific
107
+ # WRITE/EXEC option; deny-by-default when that option is present (verb-scoped so read-only siblings like
108
+ # `du -s` / `grep -o` keep auto-running). Value-taking flags match `-o`, `-o=…`, `-oFILE`, `--output=…`.
109
+ _UNSAFE_OPTS = {
110
+ "sort": ("-o", "--output"), # -o FILE / --output=FILE overwrite a file
111
+ "date": ("-s", "--set"), # set the system clock (a mutation)
112
+ "tree": ("-o",), # -o FILE writes the listing to a file
113
+ }
114
+
115
+
116
+ def _has_unsafe_opt(verb: str, toks: list) -> bool:
117
+ bad = _UNSAFE_OPTS.get(verb, ())
118
+ for t in toks[1:]:
119
+ for b in bad:
120
+ if t == b or t.startswith(b + "=") or (len(b) == 2 and len(t) > 2 and t.startswith(b)):
121
+ return True
122
+ return False
104
123
  # git subcommands with NO mutating form (excludes branch/tag/config/remote/stash/reflog, which can write).
105
124
  _RO_GIT_SUB = frozenset((
106
125
  "status", "log", "diff", "show", "rev-parse", "ls-files", "ls-tree", "describe", "blame",
@@ -127,10 +146,15 @@ def _is_readonly_command(cmd: str) -> bool:
127
146
  verb = os.path.basename(toks[0])
128
147
  if verb == "git":
129
148
  sub = next((t for t in toks[1:] if not t.startswith("-")), "")
130
- return sub in _RO_GIT_SUB
149
+ if sub not in _RO_GIT_SUB:
150
+ return False
151
+ # `git grep --open-files-in-pager[=<cmd>]` / `-O[<cmd>]` runs a PAGER program (arbitrary exec).
152
+ if sub == "grep" and any(t.startswith("--open-files-in-pager") or t.startswith("-O") for t in toks):
153
+ return False
154
+ return True
131
155
  if verb == "find":
132
156
  return not any(t in _FIND_MUTATORS for t in toks)
133
- return verb in _RO_VERBS
157
+ return verb in _RO_VERBS and not _has_unsafe_opt(verb, toks)
134
158
 
135
159
 
136
160
  def ask_commands(name: str, args: dict) -> Optional[ToolDecision]:
@@ -864,12 +864,16 @@ def _model_candidates(llm, cfg):
864
864
 
865
865
  def _reasoning_levels(model, base_url):
866
866
  """Reasoning levels valid for a model, derived from its capability (provider-aware). Effort-capable
867
- models (gpt-5/o-series) expose all four; others only fast/full (high/max would degrade to default)."""
867
+ models (gpt-5/o-series) expose all four; OpenRouter exposes fast/full/high (its unified reasoning
868
+ object maps max→high, so offering max would be a lie); others only fast/full (high/max would
869
+ degrade to default)."""
868
870
  from .model_catalog import capability
869
871
  full4 = [("fast", "minimal reasoning — fastest, cheapest"),
870
872
  ("full", "provider default reasoning"),
871
873
  ("high", "deeper reasoning (effort=high, /v1/responses)"),
872
874
  ("max", "deepest reasoning (effort=xhigh)")]
875
+ if "openrouter" in (base_url or "").lower(): # unified reasoning object honors effort WITH tools
876
+ return full4[:2] + [("high", "deeper reasoning (unified reasoning, effort=high)")]
873
877
  if capability(model, base_url).supports_reasoning_effort:
874
878
  return full4
875
879
  return full4[:2] # fast | full only — the model has no effort knob
@@ -888,7 +892,10 @@ def select_model_reasoning(llm, cfg, *, pt_input=None, pt_output=None):
888
892
  if pick is None:
889
893
  return None
890
894
  model, _grp, pid = cands[pick]
891
- base = getattr(llm, "_base_url", "") if model == llm.model else ""
895
+ if pid: # the pick will REBIND to this provider offer the levels ITS endpoint supports
896
+ base = ((cfg.providers() or {}).get(pid) or {}).get("base_url") or ""
897
+ else:
898
+ base = getattr(llm, "_base_url", "") if model == llm.model else ""
892
899
  levels = _reasoning_levels(model, base)
893
900
  lvl_rows = [(name, desc) for name, desc in levels]
894
901
  lvl_cur = next((i for i, (n, _) in enumerate(levels) if n == llm.reasoning), -1)
@@ -918,7 +918,10 @@ def cost_chart_renders_flat_vs_rising():
918
918
  import sys
919
919
  root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
920
920
  sys.path.insert(0, os.path.join(root, "evals"))
921
- import realenv_multiturn as rem # importable WITHOUT the heavy swebench dep
921
+ try:
922
+ import realenv_multiturn as rem # importable WITHOUT the heavy swebench dep
923
+ except ModuleNotFoundError:
924
+ return # evals/ is a local-only dev tree, not shipped in the installed package (CI) — skip, don't fail
922
925
  rows = [{"turn": i, "peak_in": 6000, "transcript": 6000 * i} for i in range(1, 13)]
923
926
  chart = rem.render_cost_chart(rows)
924
927
  assert "sliceagent" in chart and "transcript" in chart and "12×" in chart, chart
@@ -0,0 +1,260 @@
1
+ """The clear config journey (user-designed): /config manages providers in-session, and /model shows
2
+ ONLY configured providers' models — picking one switches model + endpoint + key together.
3
+ Offline: stub objects, no network, no pytest. Run: PYTHONPATH=src python tests/test_config_journey.py
4
+ """
5
+ import os
6
+ import sys
7
+
8
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
9
+
10
+ from sliceagent.config import Config # noqa: E402
11
+ from sliceagent.llm import OpenAILLM # noqa: E402
12
+ from sliceagent.tui import _SLASH, _model_candidates, select_model_reasoning # noqa: E402
13
+
14
+ CHECKS = []
15
+ def check(fn):
16
+ CHECKS.append(fn)
17
+ return fn
18
+
19
+
20
+ class _Llm:
21
+ model = "deepseek-chat"
22
+ _base_url = "https://api.deepseek.com/v1"
23
+ reasoning = "full"
24
+
25
+
26
+ _CFG = Config({"providers": {
27
+ "deepseek": {"api_key": "k1", "base_url": "https://api.deepseek.com/v1", "model": "deepseek-chat"},
28
+ "openrouter": {"api_key": "k2", "base_url": "https://openrouter.ai/api/v1", "model": "openai/gpt-5.5"},
29
+ "keyless": {"model": "phantom-model"}, # no api_key → NOT usable → must not appear
30
+ }})
31
+
32
+
33
+ @check
34
+ def model_menu_lists_only_configured_providers():
35
+ cands = _model_candidates(_Llm(), _CFG)
36
+ pids = {pid for _, _, pid in cands if pid}
37
+ models = [m for m, _, _ in cands]
38
+ assert pids == {"deepseek", "openrouter"}, f"only keyed providers may appear, got {pids}"
39
+ assert "phantom-model" not in models, "a provider without a key must not contribute models"
40
+ assert "deepseek-chat" in models and "openai/gpt-5.5" in models
41
+ assert "anthropic/claude-sonnet-5" in models, "configured providers contribute their suggestions too"
42
+
43
+
44
+ @check
45
+ def every_candidate_carries_its_provider_for_endpoint_switch():
46
+ for m, _grp, pid in _model_candidates(_Llm(), _CFG):
47
+ assert pid in ("deepseek", "openrouter"), f"{m} must map to a configured provider, got {pid!r}"
48
+
49
+
50
+ @check
51
+ def env_current_model_is_labeled_honestly_not_as_a_provider():
52
+ class _EnvLlm: # env model NOT in any configured provider's list
53
+ model = "gpt-5.5"
54
+ _base_url = "https://api.deepseek.com/v1" # deepseek endpoint must NOT make it say "deepseek"
55
+ reasoning = "full"
56
+ cands = _model_candidates(_EnvLlm(), _CFG)
57
+ m, grp, pid = cands[0]
58
+ assert m == "gpt-5.5" and pid is None
59
+ assert grp == "current (env)", f"env model must not masquerade as a configured provider, got {grp!r}"
60
+
61
+
62
+ @check
63
+ def no_providers_configured_falls_back_to_known_set():
64
+ cands = _model_candidates(_Llm(), Config({}))
65
+ assert cands, "env-only setups still need a menu"
66
+ assert all(pid is None for _, _, pid in cands), "fallback entries have no provider to rebind to"
67
+ assert any(m == "deepseek-chat" for m, _, _ in cands)
68
+
69
+
70
+ @check
71
+ def switch_rebinds_endpoint_and_key():
72
+ saved = {k: os.environ.pop(k, None) for k in ("AGENT_PROXY", "HTTPS_PROXY", "HTTP_PROXY")}
73
+ try:
74
+ llm = OpenAILLM(model="deepseek-chat", api_key="k1", base_url="https://api.deepseek.com/v1")
75
+ assert llm._base_url == "https://api.deepseek.com/v1"
76
+ llm.switch(model="openai/gpt-5.5", base_url="https://openrouter.ai/api/v1", api_key="k2")
77
+ assert llm.model == "openai/gpt-5.5"
78
+ assert llm._base_url == "https://openrouter.ai/api/v1"
79
+ assert str(llm.client.base_url).startswith("https://openrouter.ai/api/v1"), \
80
+ "the OpenAI client itself must be rebuilt against the new endpoint"
81
+ assert llm.client.api_key == "k2", "the key must follow the provider"
82
+ # base_url="" → SDK default endpoint (OpenAI)
83
+ llm.switch(model="gpt-5.5", base_url="", api_key="k3")
84
+ assert llm._base_url == "" and "openrouter" not in str(llm.client.base_url)
85
+ # model-only switch keeps the endpoint (the classic /model <name> path)
86
+ llm.switch(model="gpt-5")
87
+ assert llm.model == "gpt-5" and llm.client.api_key == "k3"
88
+ finally:
89
+ for k, v in saved.items():
90
+ if v is not None:
91
+ os.environ[k] = v
92
+
93
+
94
+ @check
95
+ def selector_returns_the_provider_triple():
96
+ from prompt_toolkit.input.defaults import create_pipe_input
97
+ from prompt_toolkit.output import DummyOutput
98
+ with create_pipe_input() as pinp:
99
+ pinp.send_text("\r\r") # Enter (model) + Enter (reasoning)
100
+ got = select_model_reasoning(_Llm(), _CFG, pt_input=pinp, pt_output=DummyOutput())
101
+ assert got is not None and len(got) == 3, f"expected (model, reasoning, pid), got {got!r}"
102
+ model, _reasoning, pid = got
103
+ assert pid in ("deepseek", "openrouter") and isinstance(model, str)
104
+
105
+
106
+ @check
107
+ def config_command_is_in_the_slash_palette():
108
+ assert "/config" in _SLASH, "/config must be discoverable in the palette"
109
+
110
+
111
+ @check
112
+ def boot_binds_pinned_provider_as_a_unit_never_default_providers_base():
113
+ """The cross-wiring bug: prefs pin openai (no base_url = SDK default) while default_provider is
114
+ deepseek → boot must NOT pair the openai key with the deepseek base_url."""
115
+ from sliceagent.cli import _env_from_config
116
+ c = Config({"providers": {
117
+ "openai": {"api_key": "sk-openai", "model": "gpt-5.5"},
118
+ "deepseek": {"api_key": "sk-ds", "base_url": "https://api.deepseek.com/v1", "model": "deepseek-chat"},
119
+ }, "agent": {"default_provider": "deepseek"}})
120
+ saved = {k: os.environ.pop(k, None) for k in ("LLM_API_KEY", "LLM_BASE_URL")}
121
+ try:
122
+ _env_from_config(c, "openai")
123
+ assert os.environ.get("LLM_API_KEY") == "sk-openai"
124
+ assert not os.environ.get("LLM_BASE_URL"), \
125
+ f"openai pin must leave base at SDK default, got {os.environ.get('LLM_BASE_URL')!r}"
126
+ os.environ.pop("LLM_API_KEY", None); os.environ.pop("LLM_BASE_URL", None)
127
+ _env_from_config(c, "deepseek") # a pin WITH base_url binds its own pair
128
+ assert os.environ.get("LLM_API_KEY") == "sk-ds"
129
+ assert os.environ.get("LLM_BASE_URL") == "https://api.deepseek.com/v1"
130
+ os.environ.pop("LLM_API_KEY", None); os.environ.pop("LLM_BASE_URL", None)
131
+ _env_from_config(c, "gone") # deleted pin → default provider's own pair
132
+ assert os.environ.get("LLM_API_KEY") == "sk-ds" and "deepseek" in os.environ.get("LLM_BASE_URL", "")
133
+ finally:
134
+ for k in ("LLM_API_KEY", "LLM_BASE_URL"):
135
+ os.environ.pop(k, None)
136
+ for k, v in saved.items():
137
+ if v is not None:
138
+ os.environ[k] = v
139
+
140
+
141
+ @check
142
+ def save_prefs_none_deletes_a_stale_pin():
143
+ import json
144
+ import tempfile
145
+ import sliceagent.config as _cfgmod
146
+ with tempfile.TemporaryDirectory() as tmp:
147
+ orig = _cfgmod._prefs_path
148
+ _cfgmod._prefs_path = lambda: os.path.join(tmp, "prefs.json")
149
+ try:
150
+ _cfgmod.save_prefs({"model": "a", "provider": "openrouter"})
151
+ _cfgmod.save_prefs({"model": "b", "provider": None}) # typed /model → pin removed
152
+ got = json.load(open(os.path.join(tmp, "prefs.json")))
153
+ assert got.get("model") == "b" and "provider" not in got, f"stale pin survived: {got}"
154
+ finally:
155
+ _cfgmod._prefs_path = orig
156
+
157
+
158
+ @check
159
+ def openrouter_models_offer_high_reasoning_in_the_menu():
160
+ from sliceagent.tui import _reasoning_levels
161
+ names = [n for n, _ in _reasoning_levels("openai/gpt-5.5", "https://openrouter.ai/api/v1")]
162
+ assert "high" in names, "openrouter's unified reasoning honors high — the menu must offer it"
163
+ assert "max" not in names, "openrouter maps max→high; offering max would lie"
164
+ assert [n for n, _ in _reasoning_levels("deepseek-chat", "https://api.deepseek.com/v1")] == ["fast", "full"]
165
+
166
+
167
+ @check
168
+ def switch_closes_the_replaced_client():
169
+ saved = {k: os.environ.pop(k, None) for k in ("AGENT_PROXY", "HTTPS_PROXY", "HTTP_PROXY")}
170
+ try:
171
+ llm = OpenAILLM(model="deepseek-chat", api_key="k1", base_url="https://api.deepseek.com/v1")
172
+ first = llm.client
173
+ llm.switch(model="openai/gpt-5.5", base_url="https://openrouter.ai/api/v1", api_key="k2")
174
+ hc = getattr(first, "_client", None) # the underlying httpx client
175
+ assert hc is None or hc.is_closed, "the replaced client's connection pool must be closed"
176
+ assert llm.client is not first
177
+ finally:
178
+ for k, v in saved.items():
179
+ if v is not None:
180
+ os.environ[k] = v
181
+
182
+
183
+ @check
184
+ def config_save_backs_up_a_corrupt_config_instead_of_erasing_keys():
185
+ import tempfile
186
+ from sliceagent.onboarding import _save_provider
187
+ with tempfile.TemporaryDirectory() as d:
188
+ p = os.path.join(d, "config.toml")
189
+ with open(p, "w") as f:
190
+ f.write('[providers.deepseek]\napi_key = "sk-PRECIOUS"\nmodel = "deepseek-chat"\n')
191
+ with open(p, "w") as f:
192
+ f.write('[providers.deepseek\napi_key = "sk-PRECIOUS"\n') # one-char TOML typo
193
+ bak = _save_provider(p, pid="openai", model="gpt-5.5", api_key="sk-new", base_url="")
194
+ assert bak and os.path.exists(bak), "an unparseable config must be moved aside, not erased"
195
+ assert "sk-PRECIOUS" in open(bak).read(), "the old key must survive in the backup"
196
+ assert "sk-new" in open(p).read(), "the new provider must still be written"
197
+
198
+
199
+ @check
200
+ def config_read_degrades_on_non_utf8_instead_of_crashing():
201
+ import tempfile
202
+ from sliceagent.onboarding import _read_config, run_config
203
+ with tempfile.TemporaryDirectory() as d:
204
+ cfgdir = os.path.join(d, ".sliceagent"); os.makedirs(cfgdir)
205
+ p = os.path.join(cfgdir, "config.toml")
206
+ with open(p, "wb") as f:
207
+ f.write(b'[agent]\nmodel = "caf\xe9"\n') # non-UTF-8 byte
208
+ assert _read_config(p) == {}, "_read_config must degrade, not raise UnicodeDecodeError"
209
+ assert run_config([], home=d) == 0, "`sliceagent config` must not crash on a non-utf8 file"
210
+
211
+
212
+ @check
213
+ def config_show_tolerates_a_scalar_under_providers():
214
+ import tempfile
215
+ from sliceagent.onboarding import run_config
216
+ with tempfile.TemporaryDirectory() as d:
217
+ cfgdir = os.path.join(d, ".sliceagent"); os.makedirs(cfgdir)
218
+ with open(os.path.join(cfgdir, "config.toml"), "w") as f:
219
+ f.write('[providers]\nfoo = "bar"\n') # scalar, not a table
220
+ assert run_config([], home=d) == 0, "a scalar under [providers] must not crash `config`"
221
+
222
+
223
+ @check
224
+ def readonly_allowlist_rejects_exec_and_write_affordances():
225
+ from sliceagent.policy import _is_readonly_command as ro
226
+ for bypass in ["env sh -c 'touch x'", "env python3 e.py", "sort -o important.py /dev/null",
227
+ "sort --output=x a", "date -s 2020-01-01", "tree -o out.txt",
228
+ "uniq /dev/null important.py", "git grep --open-files-in-pager=/tmp/e.sh x",
229
+ "git grep -Oless foo"]:
230
+ assert not ro(bypass), f"must NOT auto-run (execs/writes): {bypass!r}"
231
+ for ok in ["ls -la", "cat f", "du -s .", "grep -o pat file", "sort a.txt", "git grep foo",
232
+ "git log --oneline", "printenv PATH", "date", "tree -L 2", "wc -l f"]:
233
+ assert ro(ok), f"read-only command must still auto-run: {ok!r}"
234
+
235
+
236
+ @check
237
+ def wizard_typed_fallback_rejects_unknown_provider_choice():
238
+ """A typed 'openai' must configure OPENAI (name accepted); pure garbage must abort, not silently
239
+ become OpenRouter."""
240
+ import tempfile
241
+ from sliceagent.onboarding import run_init
242
+ with tempfile.TemporaryDirectory() as tmp:
243
+ feed = iter(["garbage", "junk", "nope"]) # 3 strikes → abort
244
+ rc = run_init(inp=lambda *_: next(feed), getpw=lambda *_: "k", home=tmp)
245
+ assert rc == 1, f"3 invalid choices must abort, got rc={rc}"
246
+ with tempfile.TemporaryDirectory() as tmp:
247
+ feed = iter(["openai", "my-model", ""]) # name accepted; blank key → abort path
248
+ rc = run_init(inp=lambda *_: next(feed), getpw=lambda *_: "", home=tmp)
249
+ assert rc == 1, "no key entered must abort"
250
+
251
+
252
+ if __name__ == "__main__":
253
+ ok = 0
254
+ for fn in CHECKS:
255
+ try:
256
+ fn(); ok += 1; print(f"PASS {fn.__name__}")
257
+ except Exception as e: # noqa: BLE001
258
+ print(f"FAIL {fn.__name__}: {type(e).__name__}: {e}")
259
+ print(f"\n{ok}/{len(CHECKS)} passed")
260
+ sys.exit(0 if ok == len(CHECKS) else 1)