thth 2.13.2__tar.gz → 2.14.0__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 (465) hide show
  1. {thth-2.13.2 → thth-2.14.0}/PKG-INFO +1 -1
  2. {thth-2.13.2 → thth-2.14.0}/mcp/server.py +2 -2
  3. thth-2.14.0/tests/helpers/fake_x.py +138 -0
  4. {thth-2.13.2 → thth-2.14.0}/tests/test_adapter_boundary.py +4 -2
  5. {thth-2.13.2 → thth-2.14.0}/tests/test_selfupdate.py +11 -4
  6. {thth-2.13.2 → thth-2.14.0}/tests/test_v211_auth_integration.py +2 -2
  7. {thth-2.13.2 → thth-2.14.0}/tests/test_v211_x_auth.py +7 -5
  8. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_webm_named.py +1 -1
  9. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_capabilities.py +6 -1
  10. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_media_foundation.py +4 -3
  11. thth-2.14.0/tests/test_v214_release_signing.py +157 -0
  12. thth-2.14.0/tests/test_v214_x_adapter.py +220 -0
  13. thth-2.14.0/tests/test_v214_x_auth.py +103 -0
  14. thth-2.14.0/tests/test_v214_x_media.py +324 -0
  15. thth-2.14.0/tests/test_v214_x_posts_budget.py +86 -0
  16. thth-2.14.0/thth/VERSION +1 -0
  17. {thth-2.13.2 → thth-2.14.0}/thth/adapters/__init__.py +3 -1
  18. {thth-2.13.2 → thth-2.14.0}/thth/adapters/auth_x.py +55 -11
  19. thth-2.14.0/thth/adapters/x.py +359 -0
  20. thth-2.14.0/thth/adapters/x_media.py +318 -0
  21. {thth-2.13.2 → thth-2.14.0}/thth/appconfig.py +6 -1
  22. {thth-2.13.2 → thth-2.14.0}/thth/budget_x.py +31 -3
  23. thth-2.14.0/thth/budget_x_posts.py +358 -0
  24. {thth-2.13.2 → thth-2.14.0}/thth/cli.py +6 -1
  25. {thth-2.13.2 → thth-2.14.0}/thth/doctor.py +1 -1
  26. {thth-2.13.2 → thth-2.14.0}/thth/leave.py +4 -3
  27. {thth-2.13.2 → thth-2.14.0}/thth/leave_gate.py +2 -2
  28. {thth-2.13.2 → thth-2.14.0}/thth/media.py +11 -3
  29. {thth-2.13.2 → thth-2.14.0}/thth/media_capabilities.py +42 -1
  30. {thth-2.13.2 → thth-2.14.0}/thth/media_delivery.py +11 -4
  31. {thth-2.13.2 → thth-2.14.0}/thth/queuefile.py +4 -1
  32. {thth-2.13.2 → thth-2.14.0}/thth/report.py +11 -2
  33. {thth-2.13.2 → thth-2.14.0}/thth/report_service.py +11 -2
  34. {thth-2.13.2 → thth-2.14.0}/thth/selfupdate.py +77 -16
  35. thth-2.13.2/thth/VERSION +0 -1
  36. {thth-2.13.2 → thth-2.14.0}/.gitignore +0 -0
  37. {thth-2.13.2 → thth-2.14.0}/LICENSE +0 -0
  38. {thth-2.13.2 → thth-2.14.0}/README.md +0 -0
  39. {thth-2.13.2 → thth-2.14.0}/accounts.example/bluesky.json +0 -0
  40. {thth-2.13.2 → thth-2.14.0}/accounts.example/mastodon.json +0 -0
  41. {thth-2.13.2 → thth-2.14.0}/accounts.example/threads.json +0 -0
  42. {thth-2.13.2 → thth-2.14.0}/accounts.example/x.json +0 -0
  43. {thth-2.13.2 → thth-2.14.0}/bin/thth +0 -0
  44. {thth-2.13.2 → thth-2.14.0}/bin/thth-remote +0 -0
  45. {thth-2.13.2 → thth-2.14.0}/bin/thth-run +0 -0
  46. {thth-2.13.2 → thth-2.14.0}/pyproject.toml +0 -0
  47. {thth-2.13.2 → thth-2.14.0}/skills/thth/SKILL.md +0 -0
  48. {thth-2.13.2 → thth-2.14.0}/skills/thth-admin/SKILL.md +0 -0
  49. {thth-2.13.2 → thth-2.14.0}/systemd/thth@.service +0 -0
  50. {thth-2.13.2 → thth-2.14.0}/tests/__init__.py +0 -0
  51. {thth-2.13.2 → thth-2.14.0}/tests/conftest.py +0 -0
  52. {thth-2.13.2 → thth-2.14.0}/tests/coordination_snapshot.py +0 -0
  53. {thth-2.13.2 → thth-2.14.0}/tests/fixtures/GraphemeBreakTest-17.0.0.txt +0 -0
  54. {thth-2.13.2 → thth-2.14.0}/tests/fixtures/build_injection_eval.py +0 -0
  55. {thth-2.13.2 → thth-2.14.0}/tests/fixtures/callback/worker_responses.json +0 -0
  56. {thth-2.13.2 → thth-2.14.0}/tests/fixtures/injection_eval.json +0 -0
  57. {thth-2.13.2 → thth-2.14.0}/tests/fixtures/umami-bile.md +0 -0
  58. {thth-2.13.2 → thth-2.14.0}/tests/helpers/__init__.py +0 -0
  59. {thth-2.13.2 → thth-2.14.0}/tests/helpers/fake_oauth_server.py +0 -0
  60. {thth-2.13.2 → thth-2.14.0}/tests/helpers/fake_push_adapter.py +0 -0
  61. {thth-2.13.2 → thth-2.14.0}/tests/helpers/fake_redirect_server.py +0 -0
  62. {thth-2.13.2 → thth-2.14.0}/tests/helpers/fake_search_adapter.py +0 -0
  63. {thth-2.13.2 → thth-2.14.0}/tests/helpers/fake_threads_reader.py +0 -0
  64. {thth-2.13.2 → thth-2.14.0}/tests/helpers/fake_threads_writes.py +0 -0
  65. {thth-2.13.2 → thth-2.14.0}/tests/helpers/hold_lock.py +0 -0
  66. {thth-2.13.2 → thth-2.14.0}/tests/helpers/hold_repo_lock.py +0 -0
  67. {thth-2.13.2 → thth-2.14.0}/tests/helpers/worker_probe.mjs +0 -0
  68. {thth-2.13.2 → thth-2.14.0}/tests/test_27_acceptance_fixes.py +0 -0
  69. {thth-2.13.2 → thth-2.14.0}/tests/test_account_name_check.py +0 -0
  70. {thth-2.13.2 → thth-2.14.0}/tests/test_account_report.py +0 -0
  71. {thth-2.13.2 → thth-2.14.0}/tests/test_accounts_dir.py +0 -0
  72. {thth-2.13.2 → thth-2.14.0}/tests/test_admin_http_mcp.py +0 -0
  73. {thth-2.13.2 → thth-2.14.0}/tests/test_admin_log.py +0 -0
  74. {thth-2.13.2 → thth-2.14.0}/tests/test_admin_notifications.py +0 -0
  75. {thth-2.13.2 → thth-2.14.0}/tests/test_admin_repairs.py +0 -0
  76. {thth-2.13.2 → thth-2.14.0}/tests/test_admin_report.py +0 -0
  77. {thth-2.13.2 → thth-2.14.0}/tests/test_after_cli.py +0 -0
  78. {thth-2.13.2 → thth-2.14.0}/tests/test_analytics_comparison.py +0 -0
  79. {thth-2.13.2 → thth-2.14.0}/tests/test_analytics_report.py +0 -0
  80. {thth-2.13.2 → thth-2.14.0}/tests/test_api_diagnostic.py +0 -0
  81. {thth-2.13.2 → thth-2.14.0}/tests/test_api_error_body.py +0 -0
  82. {thth-2.13.2 → thth-2.14.0}/tests/test_app_env_cli.py +0 -0
  83. {thth-2.13.2 → thth-2.14.0}/tests/test_approval.py +0 -0
  84. {thth-2.13.2 → thth-2.14.0}/tests/test_approve_cmd.py +0 -0
  85. {thth-2.13.2 → thth-2.14.0}/tests/test_ask.py +0 -0
  86. {thth-2.13.2 → thth-2.14.0}/tests/test_atlas_fifth_review.py +0 -0
  87. {thth-2.13.2 → thth-2.14.0}/tests/test_atlas_fourth_review.py +0 -0
  88. {thth-2.13.2 → thth-2.14.0}/tests/test_atlas_review.py +0 -0
  89. {thth-2.13.2 → thth-2.14.0}/tests/test_atlas_second_review.py +0 -0
  90. {thth-2.13.2 → thth-2.14.0}/tests/test_atlas_third_review.py +0 -0
  91. {thth-2.13.2 → thth-2.14.0}/tests/test_audit1_fixes.py +0 -0
  92. {thth-2.13.2 → thth-2.14.0}/tests/test_audit2_fixes.py +0 -0
  93. {thth-2.13.2 → thth-2.14.0}/tests/test_audit_b_counterexamples.py +0 -0
  94. {thth-2.13.2 → thth-2.14.0}/tests/test_audit_b_m1_semantics.py +0 -0
  95. {thth-2.13.2 → thth-2.14.0}/tests/test_audit_b_n1_depth.py +0 -0
  96. {thth-2.13.2 → thth-2.14.0}/tests/test_audit_b_r1_closure.py +0 -0
  97. {thth-2.13.2 → thth-2.14.0}/tests/test_audit_v201.py +0 -0
  98. {thth-2.13.2 → thth-2.14.0}/tests/test_audit_v23_details.py +0 -0
  99. {thth-2.13.2 → thth-2.14.0}/tests/test_auth.py +0 -0
  100. {thth-2.13.2 → thth-2.14.0}/tests/test_auth_identity_required.py +0 -0
  101. {thth-2.13.2 → thth-2.14.0}/tests/test_behind_remote.py +0 -0
  102. {thth-2.13.2 → thth-2.14.0}/tests/test_bluesky_adapter.py +0 -0
  103. {thth-2.13.2 → thth-2.14.0}/tests/test_bluesky_secret_scrub.py +0 -0
  104. {thth-2.13.2 → thth-2.14.0}/tests/test_board_needs_review.py +0 -0
  105. {thth-2.13.2 → thth-2.14.0}/tests/test_board_running.py +0 -0
  106. {thth-2.13.2 → thth-2.14.0}/tests/test_bundle.py +0 -0
  107. {thth-2.13.2 → thth-2.14.0}/tests/test_charcount.py +0 -0
  108. {thth-2.13.2 → thth-2.14.0}/tests/test_cli_entrypoint.py +0 -0
  109. {thth-2.13.2 → thth-2.14.0}/tests/test_cli_help.py +0 -0
  110. {thth-2.13.2 → thth-2.14.0}/tests/test_cli_vm_path.py +0 -0
  111. {thth-2.13.2 → thth-2.14.0}/tests/test_collect.py +0 -0
  112. {thth-2.13.2 → thth-2.14.0}/tests/test_collect_engagements.py +0 -0
  113. {thth-2.13.2 → thth-2.14.0}/tests/test_collect_runs.py +0 -0
  114. {thth-2.13.2 → thth-2.14.0}/tests/test_collect_sent.py +0 -0
  115. {thth-2.13.2 → thth-2.14.0}/tests/test_comparability.py +0 -0
  116. {thth-2.13.2 → thth-2.14.0}/tests/test_control_chars_refused.py +0 -0
  117. {thth-2.13.2 → thth-2.14.0}/tests/test_conversation_paging.py +0 -0
  118. {thth-2.13.2 → thth-2.14.0}/tests/test_count_for.py +0 -0
  119. {thth-2.13.2 → thth-2.14.0}/tests/test_docs_links.py +0 -0
  120. {thth-2.13.2 → thth-2.14.0}/tests/test_doctor.py +0 -0
  121. {thth-2.13.2 → thth-2.14.0}/tests/test_doctor_all_permissions.py +0 -0
  122. {thth-2.13.2 → thth-2.14.0}/tests/test_doctor_next_step.py +0 -0
  123. {thth-2.13.2 → thth-2.14.0}/tests/test_duplicate_front_matter_keys.py +0 -0
  124. {thth-2.13.2 → thth-2.14.0}/tests/test_editorial_audit.py +0 -0
  125. {thth-2.13.2 → thth-2.14.0}/tests/test_editorial_n3_n4.py +0 -0
  126. {thth-2.13.2 → thth-2.14.0}/tests/test_engagements.py +0 -0
  127. {thth-2.13.2 → thth-2.14.0}/tests/test_external_acceptance.py +0 -0
  128. {thth-2.13.2 → thth-2.14.0}/tests/test_fake_api.py +0 -0
  129. {thth-2.13.2 → thth-2.14.0}/tests/test_form_spec.py +0 -0
  130. {thth-2.13.2 → thth-2.14.0}/tests/test_form_spec_cli.py +0 -0
  131. {thth-2.13.2 → thth-2.14.0}/tests/test_forms.py +0 -0
  132. {thth-2.13.2 → thth-2.14.0}/tests/test_fresh_install.py +0 -0
  133. {thth-2.13.2 → thth-2.14.0}/tests/test_front_matter_injection.py +0 -0
  134. {thth-2.13.2 → thth-2.14.0}/tests/test_healthcheck_notifications.py +0 -0
  135. {thth-2.13.2 → thth-2.14.0}/tests/test_hypotheses.py +0 -0
  136. {thth-2.13.2 → thth-2.14.0}/tests/test_hypothesis_boundaries.py +0 -0
  137. {thth-2.13.2 → thth-2.14.0}/tests/test_hypothesis_units.py +0 -0
  138. {thth-2.13.2 → thth-2.14.0}/tests/test_impact_cli.py +0 -0
  139. {thth-2.13.2 → thth-2.14.0}/tests/test_inbox_granted_scopes.py +0 -0
  140. {thth-2.13.2 → thth-2.14.0}/tests/test_incident_notifications.py +0 -0
  141. {thth-2.13.2 → thth-2.14.0}/tests/test_independent_thread_review.py +0 -0
  142. {thth-2.13.2 → thth-2.14.0}/tests/test_independent_topic_review.py +0 -0
  143. {thth-2.13.2 → thth-2.14.0}/tests/test_independent_topic_review_r2.py +0 -0
  144. {thth-2.13.2 → thth-2.14.0}/tests/test_inflight.py +0 -0
  145. {thth-2.13.2 → thth-2.14.0}/tests/test_inflight_tag_body_hash.py +0 -0
  146. {thth-2.13.2 → thth-2.14.0}/tests/test_jst.py +0 -0
  147. {thth-2.13.2 → thth-2.14.0}/tests/test_lint.py +0 -0
  148. {thth-2.13.2 → thth-2.14.0}/tests/test_list_projections.py +0 -0
  149. {thth-2.13.2 → thth-2.14.0}/tests/test_llm_trial.py +0 -0
  150. {thth-2.13.2 → thth-2.14.0}/tests/test_llm_trial_engage.py +0 -0
  151. {thth-2.13.2 → thth-2.14.0}/tests/test_lock.py +0 -0
  152. {thth-2.13.2 → thth-2.14.0}/tests/test_m2_independent.py +0 -0
  153. {thth-2.13.2 → thth-2.14.0}/tests/test_maintain.py +0 -0
  154. {thth-2.13.2 → thth-2.14.0}/tests/test_mastodon_adapter.py +0 -0
  155. {thth-2.13.2 → thth-2.14.0}/tests/test_mastodon_visibility.py +0 -0
  156. {thth-2.13.2 → thth-2.14.0}/tests/test_max_per_run.py +0 -0
  157. {thth-2.13.2 → thth-2.14.0}/tests/test_mcp.py +0 -0
  158. {thth-2.13.2 → thth-2.14.0}/tests/test_mcp_robustness.py +0 -0
  159. {thth-2.13.2 → thth-2.14.0}/tests/test_measured.py +0 -0
  160. {thth-2.13.2 → thth-2.14.0}/tests/test_media_accounts.py +0 -0
  161. {thth-2.13.2 → thth-2.14.0}/tests/test_media_next_steps.py +0 -0
  162. {thth-2.13.2 → thth-2.14.0}/tests/test_media_roundtrip.py +0 -0
  163. {thth-2.13.2 → thth-2.14.0}/tests/test_mismatch_diagnostics.py +0 -0
  164. {thth-2.13.2 → thth-2.14.0}/tests/test_n5_reply_null_element.py +0 -0
  165. {thth-2.13.2 → thth-2.14.0}/tests/test_n6_replies_incomplete_ledger.py +0 -0
  166. {thth-2.13.2 → thth-2.14.0}/tests/test_n7_doctor_error_body.py +0 -0
  167. {thth-2.13.2 → thth-2.14.0}/tests/test_no_limits.py +0 -0
  168. {thth-2.13.2 → thth-2.14.0}/tests/test_observation_provenance.py +0 -0
  169. {thth-2.13.2 → thth-2.14.0}/tests/test_operations_handoff.py +0 -0
  170. {thth-2.13.2 → thth-2.14.0}/tests/test_packaging.py +0 -0
  171. {thth-2.13.2 → thth-2.14.0}/tests/test_preview.py +0 -0
  172. {thth-2.13.2 → thth-2.14.0}/tests/test_production_mode.py +0 -0
  173. {thth-2.13.2 → thth-2.14.0}/tests/test_pull_cli.py +0 -0
  174. {thth-2.13.2 → thth-2.14.0}/tests/test_queue.py +0 -0
  175. {thth-2.13.2 → thth-2.14.0}/tests/test_quiet_hours.py +0 -0
  176. {thth-2.13.2 → thth-2.14.0}/tests/test_r2_r4_r5.py +0 -0
  177. {thth-2.13.2 → thth-2.14.0}/tests/test_read_cli_bare_runtimeerror.py +0 -0
  178. {thth-2.13.2 → thth-2.14.0}/tests/test_reason_adoption.py +0 -0
  179. {thth-2.13.2 → thth-2.14.0}/tests/test_redact.py +0 -0
  180. {thth-2.13.2 → thth-2.14.0}/tests/test_redirect_token_leak.py +0 -0
  181. {thth-2.13.2 → thth-2.14.0}/tests/test_refresh.py +0 -0
  182. {thth-2.13.2 → thth-2.14.0}/tests/test_refresh_destination.py +0 -0
  183. {thth-2.13.2 → thth-2.14.0}/tests/test_rejudge_new.py +0 -0
  184. {thth-2.13.2 → thth-2.14.0}/tests/test_rejudge_r2_r3.py +0 -0
  185. {thth-2.13.2 → thth-2.14.0}/tests/test_replies.py +0 -0
  186. {thth-2.13.2 → thth-2.14.0}/tests/test_replies_refresh.py +0 -0
  187. {thth-2.13.2 → thth-2.14.0}/tests/test_repo_lock.py +0 -0
  188. {thth-2.13.2 → thth-2.14.0}/tests/test_repo_sync.py +0 -0
  189. {thth-2.13.2 → thth-2.14.0}/tests/test_report_checkout_import.py +0 -0
  190. {thth-2.13.2 → thth-2.14.0}/tests/test_report_http.py +0 -0
  191. {thth-2.13.2 → thth-2.14.0}/tests/test_report_isolation.py +0 -0
  192. {thth-2.13.2 → thth-2.14.0}/tests/test_report_service.py +0 -0
  193. {thth-2.13.2 → thth-2.14.0}/tests/test_reverse_audit.py +0 -0
  194. {thth-2.13.2 → thth-2.14.0}/tests/test_review_case_id_cluster_m2.py +0 -0
  195. {thth-2.13.2 → thth-2.14.0}/tests/test_review_case_id_cluster_r2.py +0 -0
  196. {thth-2.13.2 → thth-2.14.0}/tests/test_review_cli.py +0 -0
  197. {thth-2.13.2 → thth-2.14.0}/tests/test_review_evidence_account.py +0 -0
  198. {thth-2.13.2 → thth-2.14.0}/tests/test_review_form_spec_and_case_id.py +0 -0
  199. {thth-2.13.2 → thth-2.14.0}/tests/test_review_provenance.py +0 -0
  200. {thth-2.13.2 → thth-2.14.0}/tests/test_review_records.py +0 -0
  201. {thth-2.13.2 → thth-2.14.0}/tests/test_revoke.py +0 -0
  202. {thth-2.13.2 → thth-2.14.0}/tests/test_roundtrip.py +0 -0
  203. {thth-2.13.2 → thth-2.14.0}/tests/test_run_missing_env.py +0 -0
  204. {thth-2.13.2 → thth-2.14.0}/tests/test_run_record_failclosed.py +0 -0
  205. {thth-2.13.2 → thth-2.14.0}/tests/test_runs_collected.py +0 -0
  206. {thth-2.13.2 → thth-2.14.0}/tests/test_runs_read_rows.py +0 -0
  207. {thth-2.13.2 → thth-2.14.0}/tests/test_schedule_multiple.py +0 -0
  208. {thth-2.13.2 → thth-2.14.0}/tests/test_schedule_view.py +0 -0
  209. {thth-2.13.2 → thth-2.14.0}/tests/test_scopes_source_requested.py +0 -0
  210. {thth-2.13.2 → thth-2.14.0}/tests/test_search_engage_targets.py +0 -0
  211. {thth-2.13.2 → thth-2.14.0}/tests/test_search_media_routes.py +0 -0
  212. {thth-2.13.2 → thth-2.14.0}/tests/test_secret_value_redact.py +0 -0
  213. {thth-2.13.2 → thth-2.14.0}/tests/test_secrets_grep.py +0 -0
  214. {thth-2.13.2 → thth-2.14.0}/tests/test_security_small_fixes.py +0 -0
  215. {thth-2.13.2 → thth-2.14.0}/tests/test_select.py +0 -0
  216. {thth-2.13.2 → thth-2.14.0}/tests/test_send.py +0 -0
  217. {thth-2.13.2 → thth-2.14.0}/tests/test_send_cli.py +0 -0
  218. {thth-2.13.2 → thth-2.14.0}/tests/test_send_control_chars.py +0 -0
  219. {thth-2.13.2 → thth-2.14.0}/tests/test_send_media.py +0 -0
  220. {thth-2.13.2 → thth-2.14.0}/tests/test_send_visibility.py +0 -0
  221. {thth-2.13.2 → thth-2.14.0}/tests/test_sent_integrity.py +0 -0
  222. {thth-2.13.2 → thth-2.14.0}/tests/test_server_json.py +0 -0
  223. {thth-2.13.2 → thth-2.14.0}/tests/test_site.py +0 -0
  224. {thth-2.13.2 → thth-2.14.0}/tests/test_skill_doc.py +0 -0
  225. {thth-2.13.2 → thth-2.14.0}/tests/test_study_report.py +0 -0
  226. {thth-2.13.2 → thth-2.14.0}/tests/test_systemd.py +0 -0
  227. {thth-2.13.2 → thth-2.14.0}/tests/test_thread_publish.py +0 -0
  228. {thth-2.13.2 → thth-2.14.0}/tests/test_thread_read.py +0 -0
  229. {thth-2.13.2 → thth-2.14.0}/tests/test_threadrun.py +0 -0
  230. {thth-2.13.2 → thth-2.14.0}/tests/test_threads_read_permissions.py +0 -0
  231. {thth-2.13.2 → thth-2.14.0}/tests/test_threadshape.py +0 -0
  232. {thth-2.13.2 → thth-2.14.0}/tests/test_thth_root.py +0 -0
  233. {thth-2.13.2 → thth-2.14.0}/tests/test_token_exists.py +0 -0
  234. {thth-2.13.2 → thth-2.14.0}/tests/test_token_scopes_record.py +0 -0
  235. {thth-2.13.2 → thth-2.14.0}/tests/test_token_set.py +0 -0
  236. {thth-2.13.2 → thth-2.14.0}/tests/test_topic.py +0 -0
  237. {thth-2.13.2 → thth-2.14.0}/tests/test_topic_advice.py +0 -0
  238. {thth-2.13.2 → thth-2.14.0}/tests/test_topic_cli.py +0 -0
  239. {thth-2.13.2 → thth-2.14.0}/tests/test_topic_models.py +0 -0
  240. {thth-2.13.2 → thth-2.14.0}/tests/test_topic_review_fixes.py +0 -0
  241. {thth-2.13.2 → thth-2.14.0}/tests/test_topic_shelf_observers.py +0 -0
  242. {thth-2.13.2 → thth-2.14.0}/tests/test_topic_store.py +0 -0
  243. {thth-2.13.2 → thth-2.14.0}/tests/test_topics_record_concurrent.py +0 -0
  244. {thth-2.13.2 → thth-2.14.0}/tests/test_topics_tool.py +0 -0
  245. {thth-2.13.2 → thth-2.14.0}/tests/test_trial_frictions.py +0 -0
  246. {thth-2.13.2 → thth-2.14.0}/tests/test_v210_admin_transaction.py +0 -0
  247. {thth-2.13.2 → thth-2.14.0}/tests/test_v210_approve_account.py +0 -0
  248. {thth-2.13.2 → thth-2.14.0}/tests/test_v210_json_contract.py +0 -0
  249. {thth-2.13.2 → thth-2.14.0}/tests/test_v210_length.py +0 -0
  250. {thth-2.13.2 → thth-2.14.0}/tests/test_v210_lock_wait.py +0 -0
  251. {thth-2.13.2 → thth-2.14.0}/tests/test_v210_mastodon_scopes.py +0 -0
  252. {thth-2.13.2 → thth-2.14.0}/tests/test_v210_mentions.py +0 -0
  253. {thth-2.13.2 → thth-2.14.0}/tests/test_v210_post_urls.py +0 -0
  254. {thth-2.13.2 → thth-2.14.0}/tests/test_v210_repo_paths.py +0 -0
  255. {thth-2.13.2 → thth-2.14.0}/tests/test_v210_reposts.py +0 -0
  256. {thth-2.13.2 → thth-2.14.0}/tests/test_v210_study_add.py +0 -0
  257. {thth-2.13.2 → thth-2.14.0}/tests/test_v210_tool_version.py +0 -0
  258. {thth-2.13.2 → thth-2.14.0}/tests/test_v210_unanswered.py +0 -0
  259. {thth-2.13.2 → thth-2.14.0}/tests/test_v210_unanswered_scope.py +0 -0
  260. {thth-2.13.2 → thth-2.14.0}/tests/test_v210_where_arguments.py +0 -0
  261. {thth-2.13.2 → thth-2.14.0}/tests/test_v210_where_filters.py +0 -0
  262. {thth-2.13.2 → thth-2.14.0}/tests/test_v210_who_arguments.py +0 -0
  263. {thth-2.13.2 → thth-2.14.0}/tests/test_v211_authflow.py +0 -0
  264. {thth-2.13.2 → thth-2.14.0}/tests/test_v211_bluesky_stdin.py +0 -0
  265. {thth-2.13.2 → thth-2.14.0}/tests/test_v211_doctor_binding.py +0 -0
  266. {thth-2.13.2 → thth-2.14.0}/tests/test_v211_mastodon_auth.py +0 -0
  267. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_approval_relay.py +0 -0
  268. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_ask_coordination.py +0 -0
  269. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_budget.py +0 -0
  270. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_budget_auth.py +0 -0
  271. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_budget_mcp.py +0 -0
  272. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_deletion.py +0 -0
  273. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_job_completion_fault.py +0 -0
  274. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_leave.py +0 -0
  275. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_leave_gate.py +0 -0
  276. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_leave_providers.py +0 -0
  277. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_leave_token_safety.py +0 -0
  278. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_mastodon_guide.py +0 -0
  279. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_mcp_compat.py +0 -0
  280. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_missing_token.py +0 -0
  281. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_read_coordination.py +0 -0
  282. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_server_writes.py +0 -0
  283. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_token_parent.py +0 -0
  284. {thth-2.13.2 → thth-2.14.0}/tests/test_v212_write_storage.py +0 -0
  285. {thth-2.13.2 → thth-2.14.0}/tests/test_v2131_send_dry_run.py +0 -0
  286. {thth-2.13.2 → thth-2.14.0}/tests/test_v2131_threads_publish_wait.py +0 -0
  287. {thth-2.13.2 → thth-2.14.0}/tests/test_v2132_send_inflight_hint.py +0 -0
  288. {thth-2.13.2 → thth-2.14.0}/tests/test_v2132_threads_create_timeout.py +0 -0
  289. {thth-2.13.2 → thth-2.14.0}/tests/test_v2132_threads_create_unknown.py +0 -0
  290. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_acceptance_fixes.py +0 -0
  291. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_adts_asf.py +0 -0
  292. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_approval_attachments.py +0 -0
  293. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_attachment_kinds.py +0 -0
  294. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_external.py +0 -0
  295. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_media.py +0 -0
  296. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_metadata.py +0 -0
  297. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_metadata_uri.py +0 -0
  298. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_metadata_video.py +0 -0
  299. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_record.py +0 -0
  300. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_record_video.py +0 -0
  301. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_video.py +0 -0
  302. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bmff_typed_metadata.py +0 -0
  303. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_budget_lock.py +0 -0
  304. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_carryover_timeout.py +0 -0
  305. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_graphemes.py +0 -0
  306. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_auth_media.py +0 -0
  307. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_bmff_audio.py +0 -0
  308. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_flac.py +0 -0
  309. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_gif.py +0 -0
  310. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_link.py +0 -0
  311. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_media.py +0 -0
  312. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_mp3.py +0 -0
  313. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_ogg_opus.py +0 -0
  314. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_ogg_vorbis.py +0 -0
  315. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_poll.py +0 -0
  316. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_primary_size_boundary.py +0 -0
  317. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_quote.py +0 -0
  318. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_thumbnail.py +0 -0
  319. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_wav.py +0 -0
  320. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_webm_audio.py +0 -0
  321. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_webm_video.py +0 -0
  322. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_media_cleanup.py +0 -0
  323. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_media_formats.py +0 -0
  324. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_media_prepared.py +0 -0
  325. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_media_relay.py +0 -0
  326. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_media_relay_refusals.py +0 -0
  327. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_media_upload.py +0 -0
  328. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_receipt_deadline.py +0 -0
  329. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_review_followups.py +0 -0
  330. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_stage810_audit.py +0 -0
  331. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_static_diagnostics.py +0 -0
  332. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_stop_observation.py +0 -0
  333. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_systemd_worker.py +0 -0
  334. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_ghost.py +0 -0
  335. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_gif.py +0 -0
  336. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_link.py +0 -0
  337. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_media.py +0 -0
  338. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_media_spoiler.py +0 -0
  339. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_poll.py +0 -0
  340. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_quote.py +0 -0
  341. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_quote_media.py +0 -0
  342. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_replies.py +0 -0
  343. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_text.py +0 -0
  344. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_text_spoiler.py +0 -0
  345. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_video.py +0 -0
  346. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_transport_review.py +0 -0
  347. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_video_typed_metadata.py +0 -0
  348. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_vorbis_comment_boundary.py +0 -0
  349. {thth-2.13.2 → thth-2.14.0}/tests/test_v213_webm_schema_boundary.py +0 -0
  350. {thth-2.13.2 → thth-2.14.0}/tests/test_v21b_approved_writes.py +0 -0
  351. {thth-2.13.2 → thth-2.14.0}/tests/test_v28_tags.py +0 -0
  352. {thth-2.13.2 → thth-2.14.0}/tests/test_v31_analysis.py +0 -0
  353. {thth-2.13.2 → thth-2.14.0}/tests/test_v31_collection.py +0 -0
  354. {thth-2.13.2 → thth-2.14.0}/tests/test_value_domains_shown.py +0 -0
  355. {thth-2.13.2 → thth-2.14.0}/tests/test_version.py +0 -0
  356. {thth-2.13.2 → thth-2.14.0}/tests/test_where_cli.py +0 -0
  357. {thth-2.13.2 → thth-2.14.0}/tests/test_who_cli.py +0 -0
  358. {thth-2.13.2 → thth-2.14.0}/tests/test_writeback_rebase.py +0 -0
  359. {thth-2.13.2 → thth-2.14.0}/tests/test_writeback_validate.py +0 -0
  360. {thth-2.13.2 → thth-2.14.0}/tests/test_x_metrics.py +0 -0
  361. {thth-2.13.2 → thth-2.14.0}/thth/__init__.py +0 -0
  362. {thth-2.13.2 → thth-2.14.0}/thth/__main__.py +0 -0
  363. {thth-2.13.2 → thth-2.14.0}/thth/_grapheme_data.py +0 -0
  364. {thth-2.13.2 → thth-2.14.0}/thth/account_cli.py +0 -0
  365. {thth-2.13.2 → thth-2.14.0}/thth/account_report.py +0 -0
  366. {thth-2.13.2 → thth-2.14.0}/thth/accounts.py +0 -0
  367. {thth-2.13.2 → thth-2.14.0}/thth/adapters/auth_mastodon.py +0 -0
  368. {thth-2.13.2 → thth-2.14.0}/thth/adapters/auth_threads.py +0 -0
  369. {thth-2.13.2 → thth-2.14.0}/thth/adapters/base.py +0 -0
  370. {thth-2.13.2 → thth-2.14.0}/thth/adapters/bluesky.py +0 -0
  371. {thth-2.13.2 → thth-2.14.0}/thth/adapters/bluesky_media.py +0 -0
  372. {thth-2.13.2 → thth-2.14.0}/thth/adapters/bluesky_video.py +0 -0
  373. {thth-2.13.2 → thth-2.14.0}/thth/adapters/mastodon.py +0 -0
  374. {thth-2.13.2 → thth-2.14.0}/thth/adapters/mastodon_media.py +0 -0
  375. {thth-2.13.2 → thth-2.14.0}/thth/adapters/threads.py +0 -0
  376. {thth-2.13.2 → thth-2.14.0}/thth/adapters/threads_media.py +0 -0
  377. {thth-2.13.2 → thth-2.14.0}/thth/admin_log.py +0 -0
  378. {thth-2.13.2 → thth-2.14.0}/thth/admin_notifications.py +0 -0
  379. {thth-2.13.2 → thth-2.14.0}/thth/admin_report.py +0 -0
  380. {thth-2.13.2 → thth-2.14.0}/thth/after_cli.py +0 -0
  381. {thth-2.13.2 → thth-2.14.0}/thth/analytics_comparison.py +0 -0
  382. {thth-2.13.2 → thth-2.14.0}/thth/analytics_report.py +0 -0
  383. {thth-2.13.2 → thth-2.14.0}/thth/analytics_shapes.py +0 -0
  384. {thth-2.13.2 → thth-2.14.0}/thth/analytics_threads.py +0 -0
  385. {thth-2.13.2 → thth-2.14.0}/thth/api_diagnostic.py +0 -0
  386. {thth-2.13.2 → thth-2.14.0}/thth/appenv.py +0 -0
  387. {thth-2.13.2 → thth-2.14.0}/thth/approval.py +0 -0
  388. {thth-2.13.2 → thth-2.14.0}/thth/approval_jobs.py +0 -0
  389. {thth-2.13.2 → thth-2.14.0}/thth/approval_relay.py +0 -0
  390. {thth-2.13.2 → thth-2.14.0}/thth/ask.py +0 -0
  391. {thth-2.13.2 → thth-2.14.0}/thth/ask_cli.py +0 -0
  392. {thth-2.13.2 → thth-2.14.0}/thth/audioformats.py +0 -0
  393. {thth-2.13.2 → thth-2.14.0}/thth/authclients.py +0 -0
  394. {thth-2.13.2 → thth-2.14.0}/thth/authflow.py +0 -0
  395. {thth-2.13.2 → thth-2.14.0}/thth/authpassword.py +0 -0
  396. {thth-2.13.2 → thth-2.14.0}/thth/bluesky_metadata.py +0 -0
  397. {thth-2.13.2 → thth-2.14.0}/thth/bundle.py +0 -0
  398. {thth-2.13.2 → thth-2.14.0}/thth/collect.py +0 -0
  399. {thth-2.13.2 → thth-2.14.0}/thth/collection_context.py +0 -0
  400. {thth-2.13.2 → thth-2.14.0}/thth/collection_status.py +0 -0
  401. {thth-2.13.2 → thth-2.14.0}/thth/core.py +0 -0
  402. {thth-2.13.2 → thth-2.14.0}/thth/deletion.py +0 -0
  403. {thth-2.13.2 → thth-2.14.0}/thth/ebmlaudio.py +0 -0
  404. {thth-2.13.2 → thth-2.14.0}/thth/ebmlvideo.py +0 -0
  405. {thth-2.13.2 → thth-2.14.0}/thth/engagements.py +0 -0
  406. {thth-2.13.2 → thth-2.14.0}/thth/flacformats.py +0 -0
  407. {thth-2.13.2 → thth-2.14.0}/thth/forms.py +0 -0
  408. {thth-2.13.2 → thth-2.14.0}/thth/graphemes.py +0 -0
  409. {thth-2.13.2 → thth-2.14.0}/thth/handoff_cursor.py +0 -0
  410. {thth-2.13.2 → thth-2.14.0}/thth/healthcheck.py +0 -0
  411. {thth-2.13.2 → thth-2.14.0}/thth/httpsafe.py +0 -0
  412. {thth-2.13.2 → thth-2.14.0}/thth/incident.py +0 -0
  413. {thth-2.13.2 → thth-2.14.0}/thth/inflight.py +0 -0
  414. {thth-2.13.2 → thth-2.14.0}/thth/jst.py +0 -0
  415. {thth-2.13.2 → thth-2.14.0}/thth/lint.py +0 -0
  416. {thth-2.13.2 → thth-2.14.0}/thth/lock.py +0 -0
  417. {thth-2.13.2 → thth-2.14.0}/thth/maintain.py +0 -0
  418. {thth-2.13.2 → thth-2.14.0}/thth/managed_repo.py +0 -0
  419. {thth-2.13.2 → thth-2.14.0}/thth/measured.py +0 -0
  420. {thth-2.13.2 → thth-2.14.0}/thth/media_cleanup.py +0 -0
  421. {thth-2.13.2 → thth-2.14.0}/thth/media_relay.py +0 -0
  422. {thth-2.13.2 → thth-2.14.0}/thth/media_uploads.py +0 -0
  423. {thth-2.13.2 → thth-2.14.0}/thth/mediaformats.py +0 -0
  424. {thth-2.13.2 → thth-2.14.0}/thth/mpeg_audio.py +0 -0
  425. {thth-2.13.2 → thth-2.14.0}/thth/oauth.py +0 -0
  426. {thth-2.13.2 → thth-2.14.0}/thth/oggformats.py +0 -0
  427. {thth-2.13.2 → thth-2.14.0}/thth/operations_handoff.py +0 -0
  428. {thth-2.13.2 → thth-2.14.0}/thth/postid.py +0 -0
  429. {thth-2.13.2 → thth-2.14.0}/thth/read_coordination.py +0 -0
  430. {thth-2.13.2 → thth-2.14.0}/thth/read_window.py +0 -0
  431. {thth-2.13.2 → thth-2.14.0}/thth/redact.py +0 -0
  432. {thth-2.13.2 → thth-2.14.0}/thth/replies.py +0 -0
  433. {thth-2.13.2 → thth-2.14.0}/thth/report_details.py +0 -0
  434. {thth-2.13.2 → thth-2.14.0}/thth/report_http.py +0 -0
  435. {thth-2.13.2 → thth-2.14.0}/thth/report_isolation.py +0 -0
  436. {thth-2.13.2 → thth-2.14.0}/thth/retract_cli.py +0 -0
  437. {thth-2.13.2 → thth-2.14.0}/thth/runs.py +0 -0
  438. {thth-2.13.2 → thth-2.14.0}/thth/scopes.py +0 -0
  439. {thth-2.13.2 → thth-2.14.0}/thth/secrets_fs.py +0 -0
  440. {thth-2.13.2 → thth-2.14.0}/thth/select.py +0 -0
  441. {thth-2.13.2 → thth-2.14.0}/thth/sent.py +0 -0
  442. {thth-2.13.2 → thth-2.14.0}/thth/server_files.py +0 -0
  443. {thth-2.13.2 → thth-2.14.0}/thth/server_writes.py +0 -0
  444. {thth-2.13.2 → thth-2.14.0}/thth/stop_observation.py +0 -0
  445. {thth-2.13.2 → thth-2.14.0}/thth/study_cli.py +0 -0
  446. {thth-2.13.2 → thth-2.14.0}/thth/study_report.py +0 -0
  447. {thth-2.13.2 → thth-2.14.0}/thth/systemd_gen.py +0 -0
  448. {thth-2.13.2 → thth-2.14.0}/thth/tags.py +0 -0
  449. {thth-2.13.2 → thth-2.14.0}/thth/thread_read.py +0 -0
  450. {thth-2.13.2 → thth-2.14.0}/thth/threadrun.py +0 -0
  451. {thth-2.13.2 → thth-2.14.0}/thth/threads_read_cli.py +0 -0
  452. {thth-2.13.2 → thth-2.14.0}/thth/threadshape.py +0 -0
  453. {thth-2.13.2 → thth-2.14.0}/thth/threadthrow.py +0 -0
  454. {thth-2.13.2 → thth-2.14.0}/thth/tool_version.py +0 -0
  455. {thth-2.13.2 → thth-2.14.0}/thth/topic_advice.py +0 -0
  456. {thth-2.13.2 → thth-2.14.0}/thth/topic_cli.py +0 -0
  457. {thth-2.13.2 → thth-2.14.0}/thth/topic_models.py +0 -0
  458. {thth-2.13.2 → thth-2.14.0}/thth/topic_store.py +0 -0
  459. {thth-2.13.2 → thth-2.14.0}/thth/topics.py +0 -0
  460. {thth-2.13.2 → thth-2.14.0}/thth/unanswered.py +0 -0
  461. {thth-2.13.2 → thth-2.14.0}/thth/vorbisformats.py +0 -0
  462. {thth-2.13.2 → thth-2.14.0}/thth/where_cli.py +0 -0
  463. {thth-2.13.2 → thth-2.14.0}/thth/who_cli.py +0 -0
  464. {thth-2.13.2 → thth-2.14.0}/thth/writeback.py +0 -0
  465. {thth-2.13.2 → thth-2.14.0}/thth/x_metrics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thth
3
- Version: 2.13.2
3
+ Version: 2.14.0
4
4
  Summary: 承認済みの投稿だけを投げる門(Threads・Bluesky・Mastodon)。人が承認し、記録は利用者の git に残る。
5
5
  Project-URL: Homepage, https://thth.me
6
6
  Project-URL: Source, https://github.com/aokings/thth
@@ -505,8 +505,8 @@ ADMIN_TOOLS = [
505
505
  for name in ("inventory", "account", "log", "tokens", "release", "diff")]
506
506
 
507
507
 
508
- ADMIN_TOOLS.append({'name':'thth_admin_budget_set','description':'Set the X monthly read estimate cap; administrator only, by required; no provider call',
509
- 'inputSchema':{'type':'object','properties':{key:{'type':'string'} for key in ('monthly','currency','rate','rate_source','by')},
508
+ ADMIN_TOOLS.append({'name':'thth_admin_budget_set','description':'Set the X monthly read estimate cap (kind x_read, default) or the monthly post count cap (kind x_posts); administrator only, by required; no provider call',
509
+ 'inputSchema':{'type':'object','properties':{key:{'type':'string'} for key in ('monthly','currency','rate','rate_source','by','kind')},
510
510
  'required':['monthly','by'],'additionalProperties':False}})
511
511
 
512
512
  SERVER_TOOLS = [
@@ -0,0 +1,138 @@
1
+ """偽の X(loopback HTTP)。**本物の api.x.com は 1 度も叩かない。**
2
+
3
+ `initialize` → `append`(multipart・`segment_index`)→ `finalize` → STATUS →
4
+ `metadata` → `POST /2/tweets` の順序と本体をそのまま記録して、試験が wire を
5
+ 見られるようにする。応答は `state` の欄で差し替える。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+ import re
12
+ import threading
13
+ import urllib.parse
14
+
15
+
16
+ def parse_multipart(raw, content_type):
17
+ """`segment_index` と `media` の中身だけを取り出す(試験用の最小の実装)。"""
18
+ boundary = content_type.partition('boundary=')[2].encode('ascii')
19
+ fields = {}
20
+ for part in raw.split(b'--' + boundary):
21
+ head, separator, body = part.partition(b'\r\n\r\n')
22
+ if not separator:
23
+ continue
24
+ name = re.search(rb'name="([^"]+)"', head)
25
+ if not name:
26
+ continue
27
+ fields[name.group(1).decode()] = body[:-2] if body.endswith(b'\r\n') else body
28
+ return fields
29
+
30
+
31
+ def new_state():
32
+ return {'calls': [], 'uploads': {}, 'segments': {}, 'status': {}, 'next_media': 100,
33
+ 'next_post': '1900000000000000001', 'reply': {}, 'on_tweet': None,
34
+ 'on_append': None, 'on_status': None, 'metadata_status': {}, 'tweets': [],
35
+ 'me': {'data': {'id': '123', 'username': 'demo'}}, 'posts': None}
36
+
37
+
38
+ class _Handler(BaseHTTPRequestHandler):
39
+ state: dict = {}
40
+
41
+ def log_message(self, *a):
42
+ pass
43
+
44
+ def reply(self, code, body):
45
+ raw = b'' if body is None else json.dumps(body).encode()
46
+ self.send_response(code)
47
+ for key, value in self.state['reply'].get(self.path, {}).items():
48
+ self.send_header(key, value)
49
+ self.send_header('Content-Length', str(len(raw)))
50
+ self.end_headers()
51
+ self.wfile.write(raw)
52
+
53
+ def _body(self):
54
+ length = int(self.headers.get('Content-Length') or 0)
55
+ return self.rfile.read(length) if length else b''
56
+
57
+ def do_GET(self):
58
+ path, _, query = self.path.partition('?')
59
+ values = urllib.parse.parse_qs(query)
60
+ self.state['calls'].append(('GET', path, values))
61
+ if path == '/2/media/upload':
62
+ identifier = values.get('media_id', [''])[0]
63
+ if self.state['on_status']:
64
+ self.state['on_status']()
65
+ queue = self.state['status'].get(identifier) or []
66
+ info = queue.pop(0) if queue else {'state': 'succeeded'}
67
+ if isinstance(info, tuple):
68
+ return self.reply(info[0], info[1])
69
+ return self.reply(200, {'data': {'id': identifier, 'processing_info': info}})
70
+ if path == '/2/users/me':
71
+ return self.reply(200, self.state['me'])
72
+ if re.fullmatch(r'/2/users/[0-9]+/tweets', path):
73
+ return self.reply(200, self.state['posts'] if self.state['posts'] is not None
74
+ else {'data': [], 'meta': {'result_count': 0}})
75
+ return self.reply(404, {})
76
+
77
+ def do_DELETE(self):
78
+ self.state['calls'].append(('DELETE', self.path, {}))
79
+ return self.reply(200, {'data': {'deleted': True}})
80
+
81
+ def do_POST(self):
82
+ raw = self._body()
83
+ content = self.headers.get('Content-Type') or ''
84
+ if content.startswith('multipart/form-data'):
85
+ fields = parse_multipart(raw, content)
86
+ payload = fields.get('media', b'')
87
+ body = {'segment_index': fields.get('segment_index', b'').decode(),
88
+ 'bytes': len(payload)}
89
+ identifier = self.path.split('/')[-2]
90
+ self.state['segments'].setdefault(identifier, []).append(body['segment_index'])
91
+ self.state['uploads'][identifier] = self.state['uploads'].get(identifier, b'') + payload
92
+ self.state['calls'].append(('POST', self.path, body))
93
+ if self.state['on_append']:
94
+ self.state['on_append']()
95
+ return self.reply(200, {'data': {'id': identifier, 'expires_after_secs': 3600}})
96
+ body = json.loads(raw) if raw else {}
97
+ self.state['calls'].append(('POST', self.path, body))
98
+ if self.path == '/2/media/upload/initialize':
99
+ self.state['next_media'] += 1
100
+ identifier = str(self.state['next_media'])
101
+ return self.reply(200, {'data': {'id': identifier, 'expires_after_secs': 3600}})
102
+ if self.path.endswith('/finalize'):
103
+ identifier = self.path.split('/')[-2]
104
+ queue = self.state['status'].get(identifier)
105
+ info = {'state': 'pending', 'check_after_secs': 0} if queue else None
106
+ data = {'id': identifier}
107
+ if info is not None:
108
+ data['processing_info'] = info
109
+ return self.reply(200, {'data': data})
110
+ if self.path == '/2/media/metadata':
111
+ status = self.state['metadata_status'].get(body.get('id'), 200)
112
+ return self.reply(status, {} if status == 200 else {'title': 'refused'})
113
+ if self.path == '/2/tweets':
114
+ if self.state['on_tweet']:
115
+ self.state['on_tweet']()
116
+ self.state['tweets'].append(body)
117
+ return self.reply(201, {'data': {'id': self.state['next_post'], 'text': body.get('text', '')}})
118
+ return self.reply(404, {})
119
+
120
+
121
+ def serve(monkeypatch=None):
122
+ """試験のための server を 1 本立てて `(state, url, shutdown)` を返す。"""
123
+ state = new_state()
124
+ handler = type('Handler', (_Handler,), {'state': state})
125
+ server = ThreadingHTTPServer(('127.0.0.1', 0), handler)
126
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
127
+ thread.start()
128
+ url = 'http://127.0.0.1:' + str(server.server_port)
129
+
130
+ def shutdown():
131
+ server.shutdown()
132
+ server.server_close()
133
+ thread.join(3)
134
+ return state, url, shutdown
135
+
136
+
137
+ def calls(state, method, suffix):
138
+ return [row for row in state['calls'] if row[0] == method and row[1].endswith(suffix)]
@@ -706,7 +706,7 @@ def test_TB5_知らない媒体のdoctorは黙って異常なしにしない(tmp
706
706
 
707
707
  # --- T3 の配線(2026-09-13・Bluesky と Mastodon を REGISTRY に載せる) ---------
708
708
 
709
- def test_T3_REGISTRYに3媒体が載っている():
709
+ def test_T3_REGISTRYに4媒体が載っている():
710
710
  """**足すのは 1 行だけ**(設計 v2 §4.2「台帳と登録」)。
711
711
 
712
712
  ここが落ちると、台帳に `media: bluesky` と書いても
@@ -714,9 +714,11 @@ def test_T3_REGISTRYに3媒体が載っている():
714
714
  """
715
715
  from thth.adapters import bluesky as bluesky_mod
716
716
  from thth.adapters import mastodon as mastodon_mod
717
- assert adapters_mod.known_media() == ["bluesky", "mastodon", "threads"]
717
+ from thth.adapters import x as x_mod
718
+ assert adapters_mod.known_media() == ["bluesky", "mastodon", "threads", "x"]
718
719
  assert adapters_mod.REGISTRY["bluesky"] is bluesky_mod.BlueskyAdapter
719
720
  assert adapters_mod.REGISTRY["mastodon"] is mastodon_mod.MastodonAdapter
721
+ assert adapters_mod.REGISTRY["x"] is x_mod.XAdapter
720
722
 
721
723
 
722
724
  def test_T3_全媒体のCAPABILITIESが境界の語彙に収まる():
@@ -870,10 +870,12 @@ def test_D11_署名検証後にrefが動いても検証したOIDだけをmerge
870
870
  "+refs/heads/release:refs/remotes/origin/release"],
871
871
  check=True,
872
872
  )
873
- return True
873
+ return None
874
874
 
875
875
  monkeypatch.setenv(selfupdate.REQUIRE_SIGNED_ENV, "1")
876
- monkeypatch.setattr(selfupdate, "verify_release_signature", 検証後にrefを進める)
876
+ monkeypatch.setattr(selfupdate, "release_signature",
877
+ lambda app_dir, oid: {"state": "verified",
878
+ "key_id": 検証後にrefを進める(app_dir, oid)})
877
879
 
878
880
  message, moved = selfupdate._pull_locked(pair["work"])
879
881
  merged = selfupdate.head(pair["work"])
@@ -902,9 +904,14 @@ def test_D11_verify_commitの実git引数はrefでなく固定OID(tmp_path, monk
902
904
 
903
905
  monkeypatch.setattr(selfupdate, "_git", 記録して実行)
904
906
  # fixture の commit は未署名なので結果は False。ここで確かめるのは、実際の
905
- # `git verify-commit` が可変 ref でなく固定 OID を受け取ること。
907
+ # `git verify-commit` が可変 ref でなく固定 OID を受け取ること(2.14 から
908
+ # allowed_signers を明示し、落ちたら「無い」と「合わない」を言い分ける)。
906
909
  assert selfupdate.verify_release_signature(pair["work"], oid) is False
907
- assert calls == [["verify-commit", oid]]
910
+ signers = selfupdate.allowed_signers_path()
911
+ assert calls == [["-c", "gpg.format=ssh", "-c", "gpg.ssh.program=ssh-keygen",
912
+ "-c", "gpg.ssh.allowedSignersFile=" + signers, "verify-commit", oid],
913
+ ["cat-file", "commit", oid]]
914
+ assert selfupdate.release_signature(pair["work"], oid)["state"] == selfupdate.SIGNATURE_MISSING
908
915
 
909
916
 
910
917
  def test_P2_5_boardは署名を確かめているかを1語出す(tmp_path, monkeypatch, capsys):
@@ -42,7 +42,7 @@ def test_app_json_cli_reads_same_profile_store_presence_only(env,monkeypatch,cap
42
42
  if media=='x':values.update(client_type='confidential',redirect_uri=auth_x.CALLBACK)
43
43
  monkeypatch.setattr('sys.stdin',io.StringIO(json.dumps(values)))
44
44
  assert cli.main(['app','set',media,'--stdin','--by','operator'])==0
45
- path=env['app'] if media=='threads' else authclients.path_for('x',None,{})
45
+ path=env['app'] if media=='threads' else auth_x.client_path({})
46
46
  if media=='threads':assert appenv.load_app_env()==(values['client_id'],values['client_secret'])
47
47
  else:
48
48
  cfg=cfg_media(env,'x');profile=auth_x.XAuthProfile.prepare(cfg)
@@ -69,7 +69,7 @@ def test_app_by_before_any_read_input_or_write(env,legacy):
69
69
  def test_app_save_fault_retains_append_contract(env,monkeypatch,media,fault):
70
70
  values={'client_id':opaque(),'client_secret':opaque()}
71
71
  if media=='x':values.update(client_type='confidential',redirect_uri=auth_x.CALLBACK)
72
- path=env['app'] if media=='threads' else authclients.path_for('x',None,{})
72
+ path=env['app'] if media=='threads' else auth_x.client_path({})
73
73
  if media=='x':authclients.write(path,values)
74
74
  old=path.read_bytes();values['client_secret']=opaque();lines=[]
75
75
  emit_fault(monkeypatch,fault)
@@ -25,9 +25,7 @@ def env(tmp_path,monkeypatch):
25
25
  cfg.update(account='alpha',project='demo',handle='demo',repo_dir=str(root/'repos/_none'),token=str(root/'alpha.token'),env=str(root/'alpha.env'))
26
26
  ledger=ledgers/'alpha.json';ledger.write_text(json.dumps(cfg));cfg=accounts.load_account('alpha')
27
27
  data=dict(root=root,home=home,cfg=cfg,ledger=ledger,client_id=opaque(),client_secret=opaque(),code=opaque(),token=opaque(),refresh=opaque(),calls=[],behavior={},queries=[],lines=[],hook=None)
28
- path=authclients.path_for('x',None,cfg);data['client_path']=path
29
28
  data['client']={'client_id':data['client_id'],'client_secret':data['client_secret'],'client_type':'confidential','redirect_uri':x.CALLBACK}
30
- authclients.write(path,data['client'])
31
29
  class Handler(BaseHTTPRequestHandler):
32
30
  def log_message(self,*a):pass
33
31
  def do_GET(self):self.answer(None)
@@ -44,6 +42,9 @@ def env(tmp_path,monkeypatch):
44
42
  self.send_response(status);self.end_headers();self.wfile.write(json.dumps(body).encode())
45
43
  server=ThreadingHTTPServer(('127.0.0.1',0),Handler)
46
44
  monkeypatch.setenv('THTH_X_BASE_URL',f'http://127.0.0.1:{server.server_port}')
45
+ # 2.14: client は scope 集合ごとの世代に置く(origin は上書き後に決まる)。
46
+ path=x.client_path(cfg);data['client_path']=path
47
+ authclients.write(path,data['client'])
47
48
  # This auth fixture now explicitly provisions a synthetic read cap.
48
49
  # Budget default-zero and setter/audit contracts have separate 2.12 tests.
49
50
  from thth import budget_x,server_files
@@ -88,8 +89,9 @@ def test_success_pkce_basic_observed_expiry_and_auth_only(env,capsys):
88
89
  assert (jst.parse(token['expires_at'])-jst.parse(token['obtained_at'])).total_seconds()==7200
89
90
  assert token['scopes']==x.SCOPES and token['scopes_source']=='response' and token['auth_via']=='paste'
90
91
  assert stat.S_IMODE(Path(env['cfg']['token']).stat().st_mode)==0o600
91
- assert adapters.capabilities_for('x')==set()
92
- with pytest.raises(adapters.UnknownMedium):adapters.make_adapter(env['cfg'],token)
92
+ # 2.14: X は投稿 adapter を持つ(2.11 の auth-only ではなくなった)。
93
+ assert adapters.capabilities_for('x')=={'recent_posts'}
94
+ assert adapters.make_adapter(env['cfg'],token).user_id=='123'
93
95
  output='\n'.join(env['lines'])+json.dumps(admin_log.read())+capsys.readouterr().out
94
96
  for value in [env['token'],env['refresh'],env['client_secret'],env['code'],session['state'],session['read_key'],session['code_verifier']]:assert value not in output
95
97
 
@@ -212,7 +214,7 @@ def test_cli_account_add_then_auth_reaches_x_profile(env,monkeypatch):
212
214
  monkeypatch.setattr('builtins.input',lambda:x.CALLBACK+'?'+urllib.parse.urlencode({'code':env['code'],'state':authflow._read_session('newx')['state']}))
213
215
  assert cli.main(['auth','newx','--by','operator','--paste'])==0
214
216
  cfg=accounts.load_account('newx');assert cfg['production'] is False and cfg['scheduled'] is False
215
- assert accounts.load_token(cfg)['user_id']=='123' and adapters.capabilities_for('x')==set()
217
+ assert accounts.load_token(cfg)['user_id']=='123' and adapters.capabilities_for('x')=={'recent_posts'}
216
218
 
217
219
 
218
220
  @pytest.mark.parametrize('fault',['zero','partial','fsync','rollback'])
@@ -39,7 +39,7 @@ def test_mastodon_still_accepts_the_same_webm(tmp_path):
39
39
 
40
40
 
41
41
  def test_policy_table_names_every_medium():
42
- assert set(media.MEDIUM_POLICY) == {'threads', 'bluesky', 'mastodon'}
42
+ assert set(media.MEDIUM_POLICY) == {'threads', 'bluesky', 'mastodon', 'x'}
43
43
  assert media.MEDIUM_POLICY['mastodon']['allow_webm'] is True
44
44
  # An edit list is no longer a per-medium policy: every medium takes one.
45
45
  assert all(set(p) <= {'allow_webm'} for p in media.MEDIUM_POLICY.values())
@@ -17,7 +17,7 @@ from thth import media, media_capabilities as caps, mediaformats, tool_version
17
17
 
18
18
  SOURCE = {path: path.read_text(encoding='utf-8')
19
19
  for path in sorted(Path(thth.__file__).resolve().parent.rglob('*.py'))}
20
- LITERAL = re.compile(r"unsupported_attachment: (mastodon|bluesky|threads)/([a-z0-9_]*)")
20
+ LITERAL = re.compile(r"unsupported_attachment: (mastodon|bluesky|threads|x)/([a-z0-9_]*)")
21
21
  BARE = re.compile(r"unsupported_attachment: ([a-z0-9_][a-z0-9_ ]*)['\"]")
22
22
  INSPECTION = re.compile(r"Inspection\(\s*'([a-z0-9_]+)'")
23
23
  REQUIRED = ('image', 'video', 'audio', 'carousel', 'poll', 'quote', 'link', 'gif', 'text',
@@ -91,6 +91,11 @@ def test_the_rulings_are_written_down_as_given():
91
91
  assert caps.CAPABILITIES['mastodon']['visibility_private'] == 'unsupported: ledger_public_only'
92
92
  assert caps.CAPABILITIES['mastodon']['link'] == 'body_only'
93
93
  assert caps.CAPABILITIES['bluesky']['webm'] == 'unsupported: provider_format'
94
+ # 2.14: 引用は「API に無い」のではなく tier で使えない(設計 §0・§7)。
95
+ assert caps.CAPABILITIES['x']['quote'] == 'unsupported: provider_tier_enterprise_only'
96
+ assert caps.CAPABILITIES['x']['carousel_quote'] == 'unsupported: provider_tier_enterprise_only'
97
+ assert caps.CAPABILITIES['x']['link'] == 'body_only'
98
+ assert caps.CAPABILITIES['x']['reply_control'] == 'supported'
94
99
  for medium in caps.MEDIA:
95
100
  assert caps.CAPABILITIES[medium]['aac_adts'] == 'unsupported: privacy_inspection_boundary_unverified'
96
101
  assert caps.CAPABILITIES[medium]['asf'] == 'unsupported: deferred_by_ruling_c14'
@@ -181,8 +181,9 @@ def test_prepared_approved_threads_image_is_selected_until_source_changes(repo,
181
181
 
182
182
  def test_approved_attachment_for_unsupported_provider_is_rejected(repo):
183
183
  from thth import select
184
- cfg = {'account': 'demo', 'media': 'x', 'repo_dir': str(repo)}
185
- original = v1().replace('## threads', '## x')
184
+ # 2.14 で X は載ったので、**まだ載っていない媒体**でこの筋を守る。
185
+ cfg = {'account': 'demo', 'media': 'whatsapp', 'repo_dir': str(repo)}
186
+ original = v1().replace('## threads', '## whatsapp')
186
187
  draft = queuefile.parse_text(original, 'q.md')
187
188
  manifest = media.manifest_for(draft.front_matter, cfg)
188
189
  approved_sha = approval.compute_approved_sha(**args(), media_manifest=manifest)
@@ -213,7 +214,7 @@ def test_legacy_nondefault_posts_indent_writeback():
213
214
 
214
215
 
215
216
  @pytest.mark.parametrize('medium,expected', [
216
- ('x', 'media_provider_unavailable'),
217
+ ('whatsapp', 'media_provider_unavailable'),
217
218
  ('threads', 'approval_stale'),
218
219
  ])
219
220
  def test_direct_core_guard_never_constructs_adapter(medium, expected):
@@ -0,0 +1,157 @@
1
+ """2.14.0 §5: release の SSH 署名。鍵はこの試験の中で作って、外に出さない。"""
2
+ import argparse
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+
7
+ import pytest
8
+
9
+ from thth import cli as cli_mod, report as report_mod, selfupdate
10
+ from tests.test_selfupdate import _advance_main, _app_pair, _release
11
+
12
+
13
+ def _run(*args, **kwargs):
14
+ return subprocess.run(list(args), capture_output=True, text=True, **kwargs)
15
+
16
+
17
+ @pytest.fixture
18
+ def keys(tmp_path):
19
+ """使い捨ての ed25519 鍵を 2 本(信じる鍵・信じない鍵)。"""
20
+ if shutil.which('ssh-keygen') is None:
21
+ pytest.skip('ssh-keygen がありません(SSH 署名の検証は測れません)')
22
+ made = {}
23
+ for name in ('trusted', 'other'):
24
+ path = tmp_path / name
25
+ result = _run('ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-C', name, '-f', str(path))
26
+ if result.returncode != 0:
27
+ pytest.skip('ssh-keygen が鍵を作れません')
28
+ fingerprint = _run('ssh-keygen', '-lf', str(path) + '.pub').stdout.split()[1]
29
+ made[name] = {'private': str(path), 'public': str(path) + '.pub',
30
+ 'line': (tmp_path / (name + '.pub')).read_text().strip(),
31
+ 'fingerprint': fingerprint}
32
+ allowed = tmp_path / 'allowed_signers'
33
+ allowed.write_text('thth-release ' + made['trusted']['line'] + '\n')
34
+ allowed.chmod(0o600)
35
+ made['allowed'] = str(allowed)
36
+ return made
37
+
38
+
39
+ def sign_with(repo, key):
40
+ _run('git', '-C', repo, 'config', 'gpg.format', 'ssh')
41
+ _run('git', '-C', repo, 'config', 'user.signingkey', key['public'])
42
+
43
+
44
+ def commit(repo, text, *, signed):
45
+ path = os.path.join(repo, 'docs', 'sns', 'queue', 'version.txt')
46
+ with open(path, 'w', encoding='utf-8') as stream:
47
+ stream.write(text)
48
+ args = ['git', '-C', repo, 'commit', '-aq'] + (['-S'] if signed else []) + ['-m', 'advance']
49
+ return _run(*args)
50
+
51
+
52
+ @pytest.fixture
53
+ def signed_pair(tmp_path, keys, monkeypatch):
54
+ pair = _app_pair(tmp_path)
55
+ monkeypatch.setenv(selfupdate.ALLOWED_SIGNERS_ENV, keys['allowed'])
56
+ sign_with(pair['seed'], keys['trusted'])
57
+ if commit(pair['seed'], 'v2\n', signed=True).returncode != 0:
58
+ pytest.skip('この git は SSH 署名を作れません')
59
+ _release(pair)
60
+ return pair
61
+
62
+
63
+ def test_a_commit_signed_by_the_trusted_key_verifies_and_names_the_key(signed_pair, keys):
64
+ subprocess.run(['git', '-C', signed_pair['work'], 'fetch', '-q', 'origin',
65
+ '+refs/heads/release:refs/remotes/origin/release'], check=True)
66
+ oid = _run('git', '-C', signed_pair['work'], 'rev-parse', 'origin/release').stdout.strip()
67
+ found = selfupdate.release_signature(signed_pair['work'], oid)
68
+ assert found['state'] == 'verified'
69
+ assert found['key_id'] == keys['trusted']['fingerprint']
70
+ assert selfupdate.verify_release_signature(signed_pair['work'], oid) is True
71
+
72
+
73
+ def test_an_unsigned_commit_is_missing_not_invalid(tmp_path, keys, monkeypatch):
74
+ pair = _app_pair(tmp_path)
75
+ monkeypatch.setenv(selfupdate.ALLOWED_SIGNERS_ENV, keys['allowed'])
76
+ _advance_main(pair)
77
+ _release(pair)
78
+ subprocess.run(['git', '-C', pair['work'], 'fetch', '-q', 'origin',
79
+ '+refs/heads/release:refs/remotes/origin/release'], check=True)
80
+ oid = _run('git', '-C', pair['work'], 'rev-parse', 'origin/release').stdout.strip()
81
+ found = selfupdate.release_signature(pair['work'], oid)
82
+ assert found == {'state': selfupdate.SIGNATURE_MISSING, 'key_id': None}
83
+ assert selfupdate.verify_release_signature(pair['work'], oid) is False
84
+
85
+
86
+ def test_a_commit_signed_by_an_untrusted_key_is_invalid(tmp_path, keys, monkeypatch):
87
+ pair = _app_pair(tmp_path)
88
+ monkeypatch.setenv(selfupdate.ALLOWED_SIGNERS_ENV, keys['allowed'])
89
+ sign_with(pair['seed'], keys['other'])
90
+ if commit(pair['seed'], 'v2\n', signed=True).returncode != 0:
91
+ pytest.skip('この git は SSH 署名を作れません')
92
+ _release(pair)
93
+ subprocess.run(['git', '-C', pair['work'], 'fetch', '-q', 'origin',
94
+ '+refs/heads/release:refs/remotes/origin/release'], check=True)
95
+ oid = _run('git', '-C', pair['work'], 'rev-parse', 'origin/release').stdout.strip()
96
+ found = selfupdate.release_signature(pair['work'], oid)
97
+ assert found == {'state': selfupdate.SIGNATURE_INVALID, 'key_id': None}
98
+
99
+
100
+ def test_the_signed_release_is_taken_in_and_the_board_names_the_key(signed_pair, keys,
101
+ monkeypatch, capsys):
102
+ before = selfupdate.head(signed_pair['work'])
103
+ monkeypatch.setenv(selfupdate.REQUIRE_SIGNED_ENV, '1')
104
+ monkeypatch.setattr(os, 'execve', lambda *a: None)
105
+ selfupdate.pull_and_reexec(['thth'], app_dir=signed_pair['work'], log=lambda _l: None)
106
+ assert selfupdate.head(signed_pair['work']) != before
107
+ record = selfupdate.release_check(signed_pair['work'])
108
+ assert record['ok'] is True and record['signature_key'] == keys['trusted']['fingerprint']
109
+ monkeypatch.setattr(report_mod.selfupdate_mod, 'release_check', lambda *a, **k: dict(record))
110
+ cli_mod.cmd_board(argparse.Namespace(json=False, account=None))
111
+ assert '署名: 確認済み(' + keys['trusted']['fingerprint'] + ')' in capsys.readouterr().out
112
+
113
+
114
+ @pytest.mark.parametrize('flavour,expected,line', [
115
+ ('unsigned', selfupdate.SIGNATURE_MISSING, '署名: 未確認(取り込んでいません)'),
116
+ ('untrusted', selfupdate.SIGNATURE_INVALID, '署名: 不正(取り込んでいません)'),
117
+ ])
118
+ def test_an_unverifiable_release_is_not_taken_in_and_the_board_says_which(
119
+ tmp_path, keys, monkeypatch, capsys, flavour, expected, line):
120
+ pair = _app_pair(tmp_path)
121
+ monkeypatch.setenv(selfupdate.ALLOWED_SIGNERS_ENV, keys['allowed'])
122
+ if flavour == 'untrusted':
123
+ sign_with(pair['seed'], keys['other'])
124
+ if commit(pair['seed'], 'v2\n', signed=True).returncode != 0:
125
+ pytest.skip('この git は SSH 署名を作れません')
126
+ else:
127
+ _advance_main(pair)
128
+ _release(pair)
129
+ before = selfupdate.head(pair['work'])
130
+ monkeypatch.setenv(selfupdate.REQUIRE_SIGNED_ENV, '1')
131
+ execs = []
132
+ monkeypatch.setattr(os, 'execve', lambda *a: execs.append(a))
133
+ message = selfupdate.pull_and_reexec(['thth'], app_dir=pair['work'], log=lambda _l: None)
134
+ assert selfupdate.head(pair['work']) == before and execs == []
135
+ assert message and selfupdate.SIGNATURE_ERROR in message and expected in message
136
+ record = selfupdate.release_check(pair['work'])
137
+ assert record['ok'] is False and record['error'].endswith(expected)
138
+ monkeypatch.setattr(report_mod.selfupdate_mod, 'release_check', lambda *a, **k: dict(record))
139
+ cli_mod.cmd_board(argparse.Namespace(json=False, account=None))
140
+ assert line in capsys.readouterr().out
141
+
142
+
143
+ def test_the_default_still_takes_an_unsigned_release(tmp_path, keys, monkeypatch):
144
+ pair = _app_pair(tmp_path)
145
+ monkeypatch.delenv(selfupdate.REQUIRE_SIGNED_ENV, raising=False)
146
+ monkeypatch.setenv(selfupdate.ALLOWED_SIGNERS_ENV, keys['allowed'])
147
+ before = selfupdate.head(pair['work'])
148
+ _advance_main(pair)
149
+ _release(pair)
150
+ monkeypatch.setattr(os, 'execve', lambda *a: None)
151
+ selfupdate.pull_and_reexec(['thth'], app_dir=pair['work'], log=lambda _l: None)
152
+ assert selfupdate.head(pair['work']) != before
153
+
154
+
155
+ def test_the_allowed_signers_path_defaults_outside_the_project(monkeypatch):
156
+ monkeypatch.delenv(selfupdate.ALLOWED_SIGNERS_ENV, raising=False)
157
+ assert selfupdate.allowed_signers_path().endswith('/.config/thth/allowed_signers')