thth 2.0.0__tar.gz → 2.2.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 (244) hide show
  1. {thth-2.0.0 → thth-2.2.0}/PKG-INFO +9 -7
  2. {thth-2.0.0 → thth-2.2.0}/README.md +8 -6
  3. {thth-2.0.0 → thth-2.2.0}/mcp/server.py +201 -6
  4. {thth-2.0.0 → thth-2.2.0}/tests/conftest.py +4 -2
  5. thth-2.2.0/tests/fixtures/callback/worker_responses.json +109 -0
  6. {thth-2.0.0 → thth-2.2.0}/tests/helpers/fake_oauth_server.py +12 -1
  7. thth-2.2.0/tests/helpers/fake_redirect_server.py +102 -0
  8. thth-2.2.0/tests/helpers/fake_search_adapter.py +78 -0
  9. thth-2.2.0/tests/helpers/fake_threads_writes.py +139 -0
  10. {thth-2.0.0 → thth-2.2.0}/tests/helpers/hold_lock.py +13 -2
  11. thth-2.2.0/tests/helpers/worker_probe.mjs +56 -0
  12. thth-2.2.0/tests/test_account_name_check.py +72 -0
  13. {thth-2.0.0 → thth-2.2.0}/tests/test_accounts_dir.py +18 -6
  14. {thth-2.0.0 → thth-2.2.0}/tests/test_adapter_boundary.py +26 -8
  15. {thth-2.0.0 → thth-2.2.0}/tests/test_ask.py +41 -2
  16. thth-2.2.0/tests/test_audit_v201.py +796 -0
  17. {thth-2.0.0 → thth-2.2.0}/tests/test_auth.py +152 -18
  18. thth-2.2.0/tests/test_auth_identity_required.py +152 -0
  19. {thth-2.0.0 → thth-2.2.0}/tests/test_board_running.py +65 -3
  20. thth-2.2.0/tests/test_collect_runs.py +132 -0
  21. thth-2.2.0/tests/test_collect_sent.py +344 -0
  22. thth-2.2.0/tests/test_control_chars_refused.py +210 -0
  23. {thth-2.0.0 → thth-2.2.0}/tests/test_conversation_paging.py +49 -0
  24. thth-2.2.0/tests/test_count_for.py +158 -0
  25. {thth-2.0.0 → thth-2.2.0}/tests/test_doctor.py +5 -1
  26. thth-2.2.0/tests/test_doctor_all_permissions.py +427 -0
  27. thth-2.2.0/tests/test_duplicate_front_matter_keys.py +176 -0
  28. {thth-2.0.0 → thth-2.2.0}/tests/test_fresh_install.py +89 -113
  29. thth-2.2.0/tests/test_front_matter_injection.py +190 -0
  30. thth-2.2.0/tests/test_inbox_granted_scopes.py +463 -0
  31. {thth-2.0.0 → thth-2.2.0}/tests/test_mastodon_adapter.py +35 -2
  32. thth-2.2.0/tests/test_mastodon_visibility.py +92 -0
  33. thth-2.2.0/tests/test_mcp_robustness.py +270 -0
  34. {thth-2.0.0 → thth-2.2.0}/tests/test_media_accounts.py +86 -88
  35. {thth-2.0.0 → thth-2.2.0}/tests/test_media_roundtrip.py +2 -2
  36. {thth-2.0.0 → thth-2.2.0}/tests/test_packaging.py +6 -2
  37. thth-2.2.0/tests/test_redirect_token_leak.py +142 -0
  38. thth-2.2.0/tests/test_scopes_source_requested.py +122 -0
  39. thth-2.2.0/tests/test_search_engage_targets.py +259 -0
  40. thth-2.2.0/tests/test_secret_value_redact.py +234 -0
  41. {thth-2.0.0 → thth-2.2.0}/tests/test_secrets_grep.py +9 -4
  42. thth-2.2.0/tests/test_security_small_fixes.py +88 -0
  43. {thth-2.0.0 → thth-2.2.0}/tests/test_selfupdate.py +48 -0
  44. {thth-2.0.0 → thth-2.2.0}/tests/test_send_media.py +2 -2
  45. {thth-2.0.0 → thth-2.2.0}/tests/test_send_visibility.py +3 -1
  46. thth-2.2.0/tests/test_site.py +257 -0
  47. thth-2.2.0/tests/test_threads_read_permissions.py +598 -0
  48. {thth-2.0.0 → thth-2.2.0}/tests/test_threadshape.py +2 -1
  49. thth-2.2.0/tests/test_token_scopes_record.py +156 -0
  50. {thth-2.0.0 → thth-2.2.0}/tests/test_topic_cli.py +6 -8
  51. thth-2.2.0/tests/test_v21b_approved_writes.py +662 -0
  52. thth-2.2.0/thth/VERSION +1 -0
  53. {thth-2.0.0 → thth-2.2.0}/thth/account_cli.py +6 -7
  54. {thth-2.0.0 → thth-2.2.0}/thth/account_report.py +33 -7
  55. {thth-2.0.0 → thth-2.2.0}/thth/accounts.py +166 -5
  56. {thth-2.0.0 → thth-2.2.0}/thth/adapters/__init__.py +13 -1
  57. {thth-2.0.0 → thth-2.2.0}/thth/adapters/base.py +133 -0
  58. {thth-2.0.0 → thth-2.2.0}/thth/adapters/bluesky.py +13 -3
  59. {thth-2.0.0 → thth-2.2.0}/thth/adapters/mastodon.py +59 -9
  60. thth-2.2.0/thth/adapters/threads.py +1240 -0
  61. {thth-2.0.0 → thth-2.2.0}/thth/approval.py +87 -5
  62. {thth-2.0.0 → thth-2.2.0}/thth/ask_cli.py +36 -1
  63. {thth-2.0.0 → thth-2.2.0}/thth/bundle.py +63 -11
  64. {thth-2.0.0 → thth-2.2.0}/thth/cli.py +192 -21
  65. {thth-2.0.0 → thth-2.2.0}/thth/collect.py +428 -63
  66. {thth-2.0.0 → thth-2.2.0}/thth/core.py +86 -8
  67. {thth-2.0.0 → thth-2.2.0}/thth/doctor.py +72 -3
  68. thth-2.2.0/thth/httpsafe.py +76 -0
  69. {thth-2.0.0 → thth-2.2.0}/thth/jst.py +28 -0
  70. {thth-2.0.0 → thth-2.2.0}/thth/lint.py +67 -1
  71. {thth-2.0.0 → thth-2.2.0}/thth/measured.py +14 -2
  72. {thth-2.0.0 → thth-2.2.0}/thth/oauth.py +238 -10
  73. {thth-2.0.0 → thth-2.2.0}/thth/postid.py +16 -2
  74. {thth-2.0.0 → thth-2.2.0}/thth/queuefile.py +81 -5
  75. thth-2.2.0/thth/redact.py +95 -0
  76. {thth-2.0.0 → thth-2.2.0}/thth/replies.py +18 -4
  77. {thth-2.0.0 → thth-2.2.0}/thth/report.py +75 -0
  78. thth-2.2.0/thth/retract_cli.py +350 -0
  79. {thth-2.0.0 → thth-2.2.0}/thth/runs.py +5 -1
  80. {thth-2.0.0 → thth-2.2.0}/thth/secrets_fs.py +16 -3
  81. {thth-2.0.0 → thth-2.2.0}/thth/select.py +29 -2
  82. {thth-2.0.0 → thth-2.2.0}/thth/selfupdate.py +52 -0
  83. {thth-2.0.0 → thth-2.2.0}/thth/sent.py +72 -17
  84. {thth-2.0.0 → thth-2.2.0}/thth/systemd_gen.py +85 -7
  85. thth-2.2.0/thth/threads_read_cli.py +581 -0
  86. {thth-2.0.0 → thth-2.2.0}/thth/threadshape.py +13 -0
  87. {thth-2.0.0 → thth-2.2.0}/thth/threadthrow.py +20 -0
  88. {thth-2.0.0 → thth-2.2.0}/thth/writeback.py +107 -0
  89. thth-2.0.0/thth/VERSION +0 -1
  90. thth-2.0.0/thth/adapters/threads.py +0 -580
  91. thth-2.0.0/thth/redact.py +0 -41
  92. {thth-2.0.0 → thth-2.2.0}/.gitignore +0 -0
  93. {thth-2.0.0 → thth-2.2.0}/LICENSE +0 -0
  94. {thth-2.0.0 → thth-2.2.0}/accounts.example/bluesky.json +0 -0
  95. {thth-2.0.0 → thth-2.2.0}/accounts.example/mastodon.json +0 -0
  96. {thth-2.0.0 → thth-2.2.0}/accounts.example/threads.json +0 -0
  97. {thth-2.0.0 → thth-2.2.0}/bin/thth +0 -0
  98. {thth-2.0.0 → thth-2.2.0}/bin/thth-remote +0 -0
  99. {thth-2.0.0 → thth-2.2.0}/bin/thth-run +0 -0
  100. {thth-2.0.0 → thth-2.2.0}/pyproject.toml +0 -0
  101. {thth-2.0.0 → thth-2.2.0}/skills/thth/SKILL.md +0 -0
  102. {thth-2.0.0 → thth-2.2.0}/systemd/thth@.service +0 -0
  103. {thth-2.0.0 → thth-2.2.0}/tests/__init__.py +0 -0
  104. {thth-2.0.0 → thth-2.2.0}/tests/fixtures/build_injection_eval.py +0 -0
  105. {thth-2.0.0 → thth-2.2.0}/tests/fixtures/injection_eval.json +0 -0
  106. {thth-2.0.0 → thth-2.2.0}/tests/fixtures/umami-bile.md +0 -0
  107. {thth-2.0.0 → thth-2.2.0}/tests/helpers/__init__.py +0 -0
  108. {thth-2.0.0 → thth-2.2.0}/tests/helpers/fake_push_adapter.py +0 -0
  109. {thth-2.0.0 → thth-2.2.0}/tests/helpers/fake_threads_reader.py +0 -0
  110. {thth-2.0.0 → thth-2.2.0}/tests/helpers/hold_repo_lock.py +0 -0
  111. {thth-2.0.0 → thth-2.2.0}/tests/helpers/share_ledger.py +0 -0
  112. {thth-2.0.0 → thth-2.2.0}/tests/test_account_report.py +0 -0
  113. {thth-2.0.0 → thth-2.2.0}/tests/test_api_error_body.py +0 -0
  114. {thth-2.0.0 → thth-2.2.0}/tests/test_app_env_cli.py +0 -0
  115. {thth-2.0.0 → thth-2.2.0}/tests/test_approval.py +0 -0
  116. {thth-2.0.0 → thth-2.2.0}/tests/test_approve_cmd.py +0 -0
  117. {thth-2.0.0 → thth-2.2.0}/tests/test_atlas_fifth_review.py +0 -0
  118. {thth-2.0.0 → thth-2.2.0}/tests/test_atlas_fourth_review.py +0 -0
  119. {thth-2.0.0 → thth-2.2.0}/tests/test_atlas_review.py +0 -0
  120. {thth-2.0.0 → thth-2.2.0}/tests/test_atlas_second_review.py +0 -0
  121. {thth-2.0.0 → thth-2.2.0}/tests/test_atlas_third_review.py +0 -0
  122. {thth-2.0.0 → thth-2.2.0}/tests/test_audit1_fixes.py +0 -0
  123. {thth-2.0.0 → thth-2.2.0}/tests/test_audit2_fixes.py +0 -0
  124. {thth-2.0.0 → thth-2.2.0}/tests/test_audit_b_counterexamples.py +0 -0
  125. {thth-2.0.0 → thth-2.2.0}/tests/test_audit_b_m1_semantics.py +0 -0
  126. {thth-2.0.0 → thth-2.2.0}/tests/test_audit_b_n1_depth.py +0 -0
  127. {thth-2.0.0 → thth-2.2.0}/tests/test_audit_b_r1_closure.py +0 -0
  128. {thth-2.0.0 → thth-2.2.0}/tests/test_audit_v23_details.py +0 -0
  129. {thth-2.0.0 → thth-2.2.0}/tests/test_bluesky_adapter.py +0 -0
  130. {thth-2.0.0 → thth-2.2.0}/tests/test_bluesky_secret_scrub.py +0 -0
  131. {thth-2.0.0 → thth-2.2.0}/tests/test_board_needs_review.py +0 -0
  132. {thth-2.0.0 → thth-2.2.0}/tests/test_bundle.py +0 -0
  133. {thth-2.0.0 → thth-2.2.0}/tests/test_charcount.py +0 -0
  134. {thth-2.0.0 → thth-2.2.0}/tests/test_cli_entrypoint.py +0 -0
  135. {thth-2.0.0 → thth-2.2.0}/tests/test_collect.py +0 -0
  136. {thth-2.0.0 → thth-2.2.0}/tests/test_comparability.py +0 -0
  137. {thth-2.0.0 → thth-2.2.0}/tests/test_docs_links.py +0 -0
  138. {thth-2.0.0 → thth-2.2.0}/tests/test_doctor_next_step.py +0 -0
  139. {thth-2.0.0 → thth-2.2.0}/tests/test_editorial_audit.py +0 -0
  140. {thth-2.0.0 → thth-2.2.0}/tests/test_editorial_n3_n4.py +0 -0
  141. {thth-2.0.0 → thth-2.2.0}/tests/test_external_acceptance.py +0 -0
  142. {thth-2.0.0 → thth-2.2.0}/tests/test_fake_api.py +0 -0
  143. {thth-2.0.0 → thth-2.2.0}/tests/test_form_spec.py +0 -0
  144. {thth-2.0.0 → thth-2.2.0}/tests/test_form_spec_cli.py +0 -0
  145. {thth-2.0.0 → thth-2.2.0}/tests/test_forms.py +0 -0
  146. {thth-2.0.0 → thth-2.2.0}/tests/test_hypotheses.py +0 -0
  147. {thth-2.0.0 → thth-2.2.0}/tests/test_hypothesis_boundaries.py +0 -0
  148. {thth-2.0.0 → thth-2.2.0}/tests/test_hypothesis_units.py +0 -0
  149. {thth-2.0.0 → thth-2.2.0}/tests/test_impact_cli.py +0 -0
  150. {thth-2.0.0 → thth-2.2.0}/tests/test_independent_thread_review.py +0 -0
  151. {thth-2.0.0 → thth-2.2.0}/tests/test_independent_topic_review.py +0 -0
  152. {thth-2.0.0 → thth-2.2.0}/tests/test_independent_topic_review_r2.py +0 -0
  153. {thth-2.0.0 → thth-2.2.0}/tests/test_inflight.py +0 -0
  154. {thth-2.0.0 → thth-2.2.0}/tests/test_jst.py +0 -0
  155. {thth-2.0.0 → thth-2.2.0}/tests/test_lint.py +0 -0
  156. {thth-2.0.0 → thth-2.2.0}/tests/test_list_projections.py +0 -0
  157. {thth-2.0.0 → thth-2.2.0}/tests/test_llm_trial.py +0 -0
  158. {thth-2.0.0 → thth-2.2.0}/tests/test_lock.py +0 -0
  159. {thth-2.0.0 → thth-2.2.0}/tests/test_m2_independent.py +0 -0
  160. {thth-2.0.0 → thth-2.2.0}/tests/test_maintain.py +0 -0
  161. {thth-2.0.0 → thth-2.2.0}/tests/test_max_per_run.py +0 -0
  162. {thth-2.0.0 → thth-2.2.0}/tests/test_mcp.py +0 -0
  163. {thth-2.0.0 → thth-2.2.0}/tests/test_measured.py +0 -0
  164. {thth-2.0.0 → thth-2.2.0}/tests/test_media_next_steps.py +0 -0
  165. {thth-2.0.0 → thth-2.2.0}/tests/test_mismatch_diagnostics.py +0 -0
  166. {thth-2.0.0 → thth-2.2.0}/tests/test_n5_reply_null_element.py +0 -0
  167. {thth-2.0.0 → thth-2.2.0}/tests/test_n6_replies_incomplete_ledger.py +0 -0
  168. {thth-2.0.0 → thth-2.2.0}/tests/test_n7_doctor_error_body.py +0 -0
  169. {thth-2.0.0 → thth-2.2.0}/tests/test_no_limits.py +0 -0
  170. {thth-2.0.0 → thth-2.2.0}/tests/test_observation_provenance.py +0 -0
  171. {thth-2.0.0 → thth-2.2.0}/tests/test_preview.py +0 -0
  172. {thth-2.0.0 → thth-2.2.0}/tests/test_production_mode.py +0 -0
  173. {thth-2.0.0 → thth-2.2.0}/tests/test_queue.py +0 -0
  174. {thth-2.0.0 → thth-2.2.0}/tests/test_quiet_hours.py +0 -0
  175. {thth-2.0.0 → thth-2.2.0}/tests/test_r2_r4_r5.py +0 -0
  176. {thth-2.0.0 → thth-2.2.0}/tests/test_reason_adoption.py +0 -0
  177. {thth-2.0.0 → thth-2.2.0}/tests/test_redact.py +0 -0
  178. {thth-2.0.0 → thth-2.2.0}/tests/test_refresh.py +0 -0
  179. {thth-2.0.0 → thth-2.2.0}/tests/test_refresh_destination.py +0 -0
  180. {thth-2.0.0 → thth-2.2.0}/tests/test_rejudge_new.py +0 -0
  181. {thth-2.0.0 → thth-2.2.0}/tests/test_rejudge_r2_r3.py +0 -0
  182. {thth-2.0.0 → thth-2.2.0}/tests/test_replies.py +0 -0
  183. {thth-2.0.0 → thth-2.2.0}/tests/test_replies_refresh.py +0 -0
  184. {thth-2.0.0 → thth-2.2.0}/tests/test_repo_lock.py +0 -0
  185. {thth-2.0.0 → thth-2.2.0}/tests/test_repo_sync.py +0 -0
  186. {thth-2.0.0 → thth-2.2.0}/tests/test_reverse_audit.py +0 -0
  187. {thth-2.0.0 → thth-2.2.0}/tests/test_review_case_id_cluster_m2.py +0 -0
  188. {thth-2.0.0 → thth-2.2.0}/tests/test_review_case_id_cluster_r2.py +0 -0
  189. {thth-2.0.0 → thth-2.2.0}/tests/test_review_cli.py +0 -0
  190. {thth-2.0.0 → thth-2.2.0}/tests/test_review_evidence_account.py +0 -0
  191. {thth-2.0.0 → thth-2.2.0}/tests/test_review_form_spec_and_case_id.py +0 -0
  192. {thth-2.0.0 → thth-2.2.0}/tests/test_review_provenance.py +0 -0
  193. {thth-2.0.0 → thth-2.2.0}/tests/test_review_records.py +0 -0
  194. {thth-2.0.0 → thth-2.2.0}/tests/test_revoke.py +0 -0
  195. {thth-2.0.0 → thth-2.2.0}/tests/test_roundtrip.py +0 -0
  196. {thth-2.0.0 → thth-2.2.0}/tests/test_run_missing_env.py +0 -0
  197. {thth-2.0.0 → thth-2.2.0}/tests/test_run_record_failclosed.py +0 -0
  198. {thth-2.0.0 → thth-2.2.0}/tests/test_runs_collected.py +0 -0
  199. {thth-2.0.0 → thth-2.2.0}/tests/test_schedule_multiple.py +0 -0
  200. {thth-2.0.0 → thth-2.2.0}/tests/test_schedule_view.py +0 -0
  201. {thth-2.0.0 → thth-2.2.0}/tests/test_select.py +0 -0
  202. {thth-2.0.0 → thth-2.2.0}/tests/test_send.py +0 -0
  203. {thth-2.0.0 → thth-2.2.0}/tests/test_send_cli.py +0 -0
  204. {thth-2.0.0 → thth-2.2.0}/tests/test_sent_integrity.py +0 -0
  205. {thth-2.0.0 → thth-2.2.0}/tests/test_server_json.py +0 -0
  206. {thth-2.0.0 → thth-2.2.0}/tests/test_share.py +0 -0
  207. {thth-2.0.0 → thth-2.2.0}/tests/test_share_safety.py +0 -0
  208. {thth-2.0.0 → thth-2.2.0}/tests/test_share_sync_concurrent.py +0 -0
  209. {thth-2.0.0 → thth-2.2.0}/tests/test_systemd.py +0 -0
  210. {thth-2.0.0 → thth-2.2.0}/tests/test_thread_publish.py +0 -0
  211. {thth-2.0.0 → thth-2.2.0}/tests/test_threadrun.py +0 -0
  212. {thth-2.0.0 → thth-2.2.0}/tests/test_thth_root.py +0 -0
  213. {thth-2.0.0 → thth-2.2.0}/tests/test_token_exists.py +0 -0
  214. {thth-2.0.0 → thth-2.2.0}/tests/test_token_set.py +0 -0
  215. {thth-2.0.0 → thth-2.2.0}/tests/test_topic.py +0 -0
  216. {thth-2.0.0 → thth-2.2.0}/tests/test_topic_advice.py +0 -0
  217. {thth-2.0.0 → thth-2.2.0}/tests/test_topic_models.py +0 -0
  218. {thth-2.0.0 → thth-2.2.0}/tests/test_topic_review_fixes.py +0 -0
  219. {thth-2.0.0 → thth-2.2.0}/tests/test_topic_shelf_observers.py +0 -0
  220. {thth-2.0.0 → thth-2.2.0}/tests/test_topic_store.py +0 -0
  221. {thth-2.0.0 → thth-2.2.0}/tests/test_topics_record_concurrent.py +0 -0
  222. {thth-2.0.0 → thth-2.2.0}/tests/test_topics_tool.py +0 -0
  223. {thth-2.0.0 → thth-2.2.0}/tests/test_trial_frictions.py +0 -0
  224. {thth-2.0.0 → thth-2.2.0}/tests/test_value_domains_shown.py +0 -0
  225. {thth-2.0.0 → thth-2.2.0}/tests/test_version.py +0 -0
  226. {thth-2.0.0 → thth-2.2.0}/tests/test_writeback_rebase.py +0 -0
  227. {thth-2.0.0 → thth-2.2.0}/tests/test_writeback_validate.py +0 -0
  228. {thth-2.0.0 → thth-2.2.0}/thth/__init__.py +0 -0
  229. {thth-2.0.0 → thth-2.2.0}/thth/__main__.py +0 -0
  230. {thth-2.0.0 → thth-2.2.0}/thth/appenv.py +0 -0
  231. {thth-2.0.0 → thth-2.2.0}/thth/ask.py +0 -0
  232. {thth-2.0.0 → thth-2.2.0}/thth/forms.py +0 -0
  233. {thth-2.0.0 → thth-2.2.0}/thth/inflight.py +0 -0
  234. {thth-2.0.0 → thth-2.2.0}/thth/lock.py +0 -0
  235. {thth-2.0.0 → thth-2.2.0}/thth/maintain.py +0 -0
  236. {thth-2.0.0 → thth-2.2.0}/thth/scopes.py +0 -0
  237. {thth-2.0.0 → thth-2.2.0}/thth/share.py +0 -0
  238. {thth-2.0.0 → thth-2.2.0}/thth/share_cli.py +0 -0
  239. {thth-2.0.0 → thth-2.2.0}/thth/threadrun.py +0 -0
  240. {thth-2.0.0 → thth-2.2.0}/thth/topic_advice.py +0 -0
  241. {thth-2.0.0 → thth-2.2.0}/thth/topic_cli.py +0 -0
  242. {thth-2.0.0 → thth-2.2.0}/thth/topic_models.py +0 -0
  243. {thth-2.0.0 → thth-2.2.0}/thth/topic_store.py +0 -0
  244. {thth-2.0.0 → thth-2.2.0}/thth/topics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thth
3
- Version: 2.0.0
3
+ Version: 2.2.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
@@ -33,9 +33,9 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
33
33
 
34
34
  ## 版
35
35
 
36
- **2.0.0**。門を無料で開ける版(`pip install`・`thth ask`・`thth share`・台帳を repo の外へ)。範囲と裁定は [docs/設計_v2_泉と門_2026-09-13.md](docs/設計_v2_泉と門_2026-09-13.md) §3・§7、その前の版は [docs/設計_v1.0.0_他人が導入できる版_2026-09-12.md](docs/設計_v1.0.0_他人が導入できる版_2026-09-12.md)、導入は [docs/導入_自分のMetaアプリで動かす.md](docs/導入_自分のMetaアプリで動かす.md)、文書の索引は [docs/README.md](docs/README.md)。
36
+ **2.0.1**。門を無料で開ける版(2.0.1: 同席送信の採集・セキュリティ監査 2 回の直し)(`pip install`・`thth ask`・`thth share`・台帳を repo の外へ)。範囲と裁定は [docs/設計_v2_泉と門_2026-09-13.md](docs/設計_v2_泉と門_2026-09-13.md) §3・§7、その前の版は [docs/設計_v1.0.0_他人が導入できる版_2026-09-12.md](docs/設計_v1.0.0_他人が導入できる版_2026-09-12.md)、導入は [docs/導入_自分のMetaアプリで動かす.md](docs/導入_自分のMetaアプリで動かす.md)、文書の索引は [docs/README.md](docs/README.md)。
37
37
 
38
- `thth/VERSION` は `2.0.0`(版は `thth/VERSION` の 1 か所・`server.json` はテストで一致を強制)。`origin/release` は v1.3.0(`0bd8208`)のままで、**v2.0.0 はまだ配っていない**(配布は masaru の一言)。出口条件の試験は(運用日誌 thth-notes: `記録/試験_LLMに選ばせる_2026-09-13.md`)。
38
+ `thth/VERSION` は `2.0.1`(版は `thth/VERSION` の 1 か所・`server.json` はテストで一致を強制)。`origin/release` は v2.0.0(`216af9f`)で、**v2.0.1 はまだ配っていない**(配布は masaru の一言)。出口条件の試験は(運用日誌 thth-notes: `記録/試験_LLMに選ばせる_2026-09-13.md`)。
39
39
 
40
40
  **`main` への push は保存だけ。`release` を進める操作が配布**(VM は `release` だけを追う。設計 §3.2.1)。
41
41
 
@@ -45,7 +45,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
45
45
 
46
46
  ## いまの状態(2026-09-13)
47
47
 
48
- 版 **2.0.0**。全件テスト **1828 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。
48
+ 版 **2.2.0**。全件テスト **2196 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。
49
49
 
50
50
  **媒体**: Threads(稼働)・Bluesky・Mastodon(同席用の台帳あり・未稼働)。
51
51
 
@@ -55,14 +55,16 @@ timer(systemd・`thth systemd` で生成)で毎時投稿、返信の採集
55
55
 
56
56
  **入口は 4 つ**: 厚い CLI(`bin/thth`・`python -m thth`)、薄い MCP(`mcp/server.py`・読み取りと
57
57
  同席の投稿・`before_you_post`)、timer、そして `pip install`(`thth`・`thth-mcp` の entry point・
58
- **依存 0**。ただし **PyPI への登録と upload は masaru の手で、まだしていない**)。
58
+ **依存 0**。PyPI に公開済み・MCP registry に `io.github.aokings/thth` として登録済み)。
59
59
 
60
60
  **トピックの棚**(`thth topics`): 観測者ごとに並ぶ・打ち消し `retract-note`・`history`。
61
61
 
62
62
  ### v2.0.0 で増えた口
63
63
 
64
64
  - **台帳を repo の外へ**(設計 v2 §3・裁定 §7-1)。正は `$THTH_ROOT/accounts/`(`$THTH_ACCOUNTS_DIR` があればそちら)。
65
- repo の `accounts/` は**1 版だけ互換で読む**(stderr に警告 1 行・VM を止めないため)。
65
+ **repo に台帳は入っていない**(2026-09-14 に 6 本を削除)——clone しても他人の台帳は付いて来ないので、
66
+ `thth account add` で自分の 1 本を作るところから始まる。配るのは雛形 `accounts.example/` の 3 本だけ。
67
+ repo の `accounts/` は**1 版だけ互換で読む**(stderr に警告 1 行・移行前の機械を止めないため)。
66
68
  `thth account migrate` が repo の中を外へ **copy**(移動しない・上書きしない・冪等)、
67
69
  `thth account add <name> --media threads|bluesky|mastodon --project <p>` が
68
70
  `accounts.example/<media>.json` の雛形から 1 本書く(**必ず `production: false`**)。
@@ -85,7 +87,7 @@ timer(systemd・`thth systemd` で生成)で毎時投稿、返信の採集
85
87
  - **最初の本番投稿の記録**: 2026-09-09、@aoking に疎通確認を 1 本(`17916074118445631`)。
86
88
  - **未着手**: X・Facebook ページ・Instagram の各アダプタ。トピック検索の権限(tester には降りない)。泉のサーバ(v2-5)。
87
89
  - **権限の制約**: tester に降りる scope は 5 つ。削除はできない。
88
- - **masaru の手が要るもの**: PyPI の登録と upload、repo を public にする切替と `LICENSE`、MCP registry への登録、tag と `release` を進める操作。
90
+ - **masaru の手が要るもの**: repo を public にする切替と `LICENSE`、tag と `release` を進める操作(PyPI と MCP registry は 2.0.1 まで公開済み)。
89
91
 
90
92
  ## MCP registry
91
93
 
@@ -9,9 +9,9 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
9
9
 
10
10
  ## 版
11
11
 
12
- **2.0.0**。門を無料で開ける版(`pip install`・`thth ask`・`thth share`・台帳を repo の外へ)。範囲と裁定は [docs/設計_v2_泉と門_2026-09-13.md](docs/設計_v2_泉と門_2026-09-13.md) §3・§7、その前の版は [docs/設計_v1.0.0_他人が導入できる版_2026-09-12.md](docs/設計_v1.0.0_他人が導入できる版_2026-09-12.md)、導入は [docs/導入_自分のMetaアプリで動かす.md](docs/導入_自分のMetaアプリで動かす.md)、文書の索引は [docs/README.md](docs/README.md)。
12
+ **2.0.1**。門を無料で開ける版(2.0.1: 同席送信の採集・セキュリティ監査 2 回の直し)(`pip install`・`thth ask`・`thth share`・台帳を repo の外へ)。範囲と裁定は [docs/設計_v2_泉と門_2026-09-13.md](docs/設計_v2_泉と門_2026-09-13.md) §3・§7、その前の版は [docs/設計_v1.0.0_他人が導入できる版_2026-09-12.md](docs/設計_v1.0.0_他人が導入できる版_2026-09-12.md)、導入は [docs/導入_自分のMetaアプリで動かす.md](docs/導入_自分のMetaアプリで動かす.md)、文書の索引は [docs/README.md](docs/README.md)。
13
13
 
14
- `thth/VERSION` は `2.0.0`(版は `thth/VERSION` の 1 か所・`server.json` はテストで一致を強制)。`origin/release` は v1.3.0(`0bd8208`)のままで、**v2.0.0 はまだ配っていない**(配布は masaru の一言)。出口条件の試験は(運用日誌 thth-notes: `記録/試験_LLMに選ばせる_2026-09-13.md`)。
14
+ `thth/VERSION` は `2.0.1`(版は `thth/VERSION` の 1 か所・`server.json` はテストで一致を強制)。`origin/release` は v2.0.0(`216af9f`)で、**v2.0.1 はまだ配っていない**(配布は masaru の一言)。出口条件の試験は(運用日誌 thth-notes: `記録/試験_LLMに選ばせる_2026-09-13.md`)。
15
15
 
16
16
  **`main` への push は保存だけ。`release` を進める操作が配布**(VM は `release` だけを追う。設計 §3.2.1)。
17
17
 
@@ -21,7 +21,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
21
21
 
22
22
  ## いまの状態(2026-09-13)
23
23
 
24
- 版 **2.0.0**。全件テスト **1828 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。
24
+ 版 **2.2.0**。全件テスト **2196 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。
25
25
 
26
26
  **媒体**: Threads(稼働)・Bluesky・Mastodon(同席用の台帳あり・未稼働)。
27
27
 
@@ -31,14 +31,16 @@ timer(systemd・`thth systemd` で生成)で毎時投稿、返信の採集
31
31
 
32
32
  **入口は 4 つ**: 厚い CLI(`bin/thth`・`python -m thth`)、薄い MCP(`mcp/server.py`・読み取りと
33
33
  同席の投稿・`before_you_post`)、timer、そして `pip install`(`thth`・`thth-mcp` の entry point・
34
- **依存 0**。ただし **PyPI への登録と upload は masaru の手で、まだしていない**)。
34
+ **依存 0**。PyPI に公開済み・MCP registry に `io.github.aokings/thth` として登録済み)。
35
35
 
36
36
  **トピックの棚**(`thth topics`): 観測者ごとに並ぶ・打ち消し `retract-note`・`history`。
37
37
 
38
38
  ### v2.0.0 で増えた口
39
39
 
40
40
  - **台帳を repo の外へ**(設計 v2 §3・裁定 §7-1)。正は `$THTH_ROOT/accounts/`(`$THTH_ACCOUNTS_DIR` があればそちら)。
41
- repo の `accounts/` は**1 版だけ互換で読む**(stderr に警告 1 行・VM を止めないため)。
41
+ **repo に台帳は入っていない**(2026-09-14 に 6 本を削除)——clone しても他人の台帳は付いて来ないので、
42
+ `thth account add` で自分の 1 本を作るところから始まる。配るのは雛形 `accounts.example/` の 3 本だけ。
43
+ repo の `accounts/` は**1 版だけ互換で読む**(stderr に警告 1 行・移行前の機械を止めないため)。
42
44
  `thth account migrate` が repo の中を外へ **copy**(移動しない・上書きしない・冪等)、
43
45
  `thth account add <name> --media threads|bluesky|mastodon --project <p>` が
44
46
  `accounts.example/<media>.json` の雛形から 1 本書く(**必ず `production: false`**)。
@@ -61,7 +63,7 @@ timer(systemd・`thth systemd` で生成)で毎時投稿、返信の採集
61
63
  - **最初の本番投稿の記録**: 2026-09-09、@aoking に疎通確認を 1 本(`17916074118445631`)。
62
64
  - **未着手**: X・Facebook ページ・Instagram の各アダプタ。トピック検索の権限(tester には降りない)。泉のサーバ(v2-5)。
63
65
  - **権限の制約**: tester に降りる scope は 5 つ。削除はできない。
64
- - **masaru の手が要るもの**: PyPI の登録と upload、repo を public にする切替と `LICENSE`、MCP registry への登録、tag と `release` を進める操作。
66
+ - **masaru の手が要るもの**: repo を public にする切替と `LICENSE`、tag と `release` を進める操作(PyPI と MCP registry は 2.0.1 まで公開済み)。
65
67
 
66
68
  ## MCP registry
67
69
 
@@ -142,6 +142,135 @@ TOOLS = [
142
142
  ]
143
143
 
144
144
 
145
+ class ToolInputError(Exception):
146
+ """要求が受け取れない(JSON-RPC `-32602`・セキュリティ監査 2026-09-14・P2-1)。
147
+
148
+ **落ちない・黙らない。** 以前は `arguments["file"]` の `KeyError` や
149
+ `params` が list のときの `AttributeError` が `main()` のループの外まで抜けて、
150
+ **サーバが死んでいた**——クライアントから見ると応答が来ないまま接続が切れる
151
+ (どの要求が悪かったのかも判らない)。1 要求の失敗は 1 要求の error で返し、
152
+ ループは続ける。
153
+ """
154
+
155
+
156
+ def _schema_for(name):
157
+ for tool in TOOLS:
158
+ if tool["name"] == name:
159
+ return tool.get("inputSchema") or {}
160
+ return None
161
+
162
+
163
+ # `inputSchema` の `type` を Python の型に写す。**`bool` は `int` の子**なので、
164
+ # 整数の検査から明示的に外す(`True` を `window_days` に通さない)。
165
+ _TYPES = {
166
+ "string": ((str,), "文字列"),
167
+ "object": ((dict,), "object"),
168
+ "boolean": ((bool,), "真偽値"),
169
+ "integer": ((int,), "整数"),
170
+ "number": ((int, float), "数"),
171
+ }
172
+
173
+
174
+ def ledger_roots() -> list:
175
+ """台帳の `repo_dir` の realpath 一覧(`file` 引数の**唯一の行き先**)。
176
+
177
+ セキュリティ監査 2026-09-14・P2-3。`thth_preview`・`thth_lint`・
178
+ `thth_topic_context` の `file` は**どこでも指せた**——MCP を持つ LLM が
179
+ `/etc/passwd` や `~/.config/thth/*.token` を渡せば、`thth preview` は
180
+ front-matter が読めないと言うだけだが、**`thth lint` は中身の断片を返す**。
181
+ 読むだけの道具でも、読む先は縛る。
182
+
183
+ `queue_dir` は台帳で `repo_dir` からの相対なので、`repo_dir` の下で足りる。
184
+ """
185
+ try:
186
+ if APP_DIR not in sys.path:
187
+ sys.path.insert(0, APP_DIR)
188
+ from thth import accounts as accounts_mod
189
+ except ImportError:
190
+ return []
191
+ try:
192
+ names = accounts_mod.list_account_names()
193
+ except Exception: # noqa: BLE001(台帳が読めない)
194
+ return []
195
+ roots = []
196
+ for account in names:
197
+ try:
198
+ cfg = accounts_mod.load_account(account)
199
+ except Exception: # noqa: BLE001(1 本壊れていても続ける)
200
+ continue
201
+ # **正規の函数を通す**(監査 2026-09-16)。ここが台帳の `repo_dir` を
202
+ # 生のまま `os.path.realpath()` していたので、相対パスは**この
203
+ # プロセスの cwd** 基準で畳まれていた。`thth/accounts.py::resolved_repo_dir()`
204
+ # は `$THTH_ROOT` 基準——**同じ台帳で許可する場所が MCP だけ食い違って
205
+ # いた**(cwd は systemd 経由なら `/`、手で打てば人それぞれ)。
206
+ repo_dir = accounts_mod.resolved_repo_dir(cfg)
207
+ if repo_dir:
208
+ roots.append(os.path.realpath(repo_dir))
209
+ return roots
210
+
211
+
212
+ def check_file_argument(name: str, path: str) -> None:
213
+ """`file` は**台帳のどれかの `repo_dir` の下**だけ(外れれば `ToolInputError`)。
214
+
215
+ `realpath` してから比べる(symlink・`..`・相対パスを畳んでから見る)。
216
+ """
217
+ real = os.path.realpath(path)
218
+ roots = ledger_roots()
219
+ for root in roots:
220
+ if real == root or real.startswith(root + os.sep):
221
+ return
222
+ raise ToolInputError(
223
+ f"{name}: file は台帳の repo_dir の中のパスだけを渡してください"
224
+ f"(受け取ったものはどの repo_dir にも入っていません)")
225
+
226
+
227
+ def validate_arguments(name: str, arguments) -> dict:
228
+ """道具の `inputSchema` どおりの引数か。違えば `ToolInputError`。
229
+
230
+ 見るのは 3 つだけ: **object であること**・**必須が揃っていること**・
231
+ **型が合っていること**(知らない鍵も断る——綴りを間違えたまま既定値で
232
+ 動いたことにしない)。判断はしない(中身の意味は CLI の仕事)。
233
+ """
234
+ schema = _schema_for(name)
235
+ if schema is None:
236
+ return {} # 知らない道具は `call_tool()` が断る
237
+ if arguments is None:
238
+ arguments = {}
239
+ if not isinstance(arguments, dict):
240
+ raise ToolInputError(
241
+ f"{name}: arguments は object で渡してください"
242
+ f"(受け取った: {type(arguments).__name__})")
243
+ props = schema.get("properties") or {}
244
+ for key in schema.get("required") or []:
245
+ if arguments.get(key) is None:
246
+ raise ToolInputError(f"{name}: 必須の引数がありません: {key}")
247
+ for key, value in arguments.items():
248
+ spec = props.get(key)
249
+ if spec is None:
250
+ raise ToolInputError(f"{name}: 知らない引数です: {key}")
251
+ if value is None:
252
+ continue
253
+ types, 名 = _TYPES.get(spec.get("type"), ((object,), "値"))
254
+ if spec.get("type") == "integer" and isinstance(value, bool):
255
+ raise ToolInputError(f"{name}: {key} は{名}で渡してください(受け取った: bool)")
256
+ if not isinstance(value, types):
257
+ raise ToolInputError(
258
+ f"{name}: {key} は{名}で渡してください"
259
+ f"(受け取った: {type(value).__name__})")
260
+ # **位置引数に渡る値が `-` で始まらない**(監査 2026-09-14・P3-2)。
261
+ # `run_cli()` は値を argv の位置に置く。`--json` のような綴りを渡されると
262
+ # **CLI の別の旗として読まれる**(`thth lint --help` のように無害な形も
263
+ # あるが、旗の意味は CLI 側の都合で増える)。`--` を足して黙って通すより、
264
+ # **受け取れないものとして断る**(作法 5)。
265
+ if isinstance(value, str) and value.startswith("-"):
266
+ raise ToolInputError(
267
+ f"{name}: {key} が `-` で始まっています({value!r})。"
268
+ f"CLI の旗と区別できないので受け取りません")
269
+ if key == "file":
270
+ check_file_argument(name, value)
271
+ return arguments
272
+
273
+
145
274
  def cli_argv(args: list) -> list:
146
275
  """CLI を起こす argv。`THTH_BIN` があればそれ、無ければ `python -m thth`。"""
147
276
  head = [sys.executable, THTH_BIN] if THTH_BIN else [sys.executable, "-m", "thth"]
@@ -235,10 +364,32 @@ def _send(obj: dict) -> None:
235
364
  sys.stdout.flush()
236
365
 
237
366
 
367
+ def _error(req_id, code: int, message: str) -> dict:
368
+ return {"jsonrpc": "2.0", "id": req_id,
369
+ "error": {"code": code, "message": message}}
370
+
371
+
372
+ def _is_notification(req: dict) -> bool:
373
+ """JSON-RPC の**通知**(`id` を持たない要求)か。
374
+
375
+ 監査 2 回目・P3-6。**通知には応答を返してはいけない**(JSON-RPC 2.0 §4.1)。
376
+ `notifications/initialized` だけを名前で特定して黙っていたので、
377
+ **`id` の無い `tools/call` には結果を、`id` の無い未知の method には
378
+ エラーを返していた**——`"id": null` を付けた応答は仕様違反で、厳密な
379
+ クライアントは接続を切る。**名前ではなく形で決める。**
380
+ """
381
+ return "id" not in req
382
+
383
+
238
384
  def _handle_request(req: dict):
239
385
  method = req.get("method")
240
386
  req_id = req.get("id")
241
387
 
388
+ if _is_notification(req):
389
+ # **通知は黙って処理する**(応答を作らない)。いまのところ中身のある
390
+ # 通知は `notifications/initialized` だけで、それも何もしない。
391
+ return None
392
+
242
393
  if method == "initialize":
243
394
  return {
244
395
  "jsonrpc": "2.0", "id": req_id,
@@ -254,25 +405,69 @@ def _handle_request(req: dict):
254
405
  return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}}
255
406
  if method == "tools/call":
256
407
  params = req.get("params") or {}
257
- result = call_tool(params.get("name"), params.get("arguments"))
408
+ if not isinstance(params, dict):
409
+ raise ToolInputError(
410
+ f"params は object で渡してください(受け取った: {type(params).__name__})")
411
+ name = params.get("name")
412
+ if not isinstance(name, str) or not name:
413
+ raise ToolInputError("params.name(道具の名前)が要ります")
414
+ arguments = validate_arguments(name, params.get("arguments"))
415
+ result = call_tool(name, arguments)
258
416
  return {"jsonrpc": "2.0", "id": req_id, "result": result}
259
417
  if req_id is not None:
260
- return {"jsonrpc": "2.0", "id": req_id,
261
- "error": {"code": -32601, "message": f"method not found: {method}"}}
418
+ return _error(req_id, -32601, f"method not found: {method}")
262
419
  return None
263
420
 
264
421
 
265
422
  def main() -> None:
423
+ """1 行 1 要求のループ。**1 件の失敗でループを降りない**(監査 2026-09-14・P2-1)。
424
+
425
+ 以前は `_handle_request()` の中で上がった例外がここまで抜けてサーバが死んで
426
+ いた(`arguments` に `file` が無い `KeyError`・`params` が list のときの
427
+ `AttributeError`)。クライアントから見ると応答が来ないまま口が閉じる——
428
+ **どの要求が悪かったのかも判らない。** 1 要求の失敗は 1 要求の error にして、
429
+ 次の要求は今までどおり受ける。
430
+ """
266
431
  for line in sys.stdin:
267
432
  line = line.strip()
268
433
  if not line:
269
434
  continue
270
435
  try:
271
436
  req = json.loads(line)
272
- except json.JSONDecodeError:
437
+ except (ValueError, RecursionError):
438
+ # **`json.JSONDecodeError` だけでは足りない**(監査 2026-09-16)。
439
+ # `json.JSONDecodeError` は `ValueError` の子だが、Python 3.12 は
440
+ # 4300 桁を超える整数で別の `ValueError`
441
+ # ("Exceeds the limit (4300 digits) for integer string conversion")
442
+ # を上げる——`except json.JSONDecodeError` だけではここが漏れて
443
+ # プロセスが死ぬ。深い入れ子(`[` の連続)の `RecursionError` も同じ
444
+ # 筋で、ここでしか捕まえられない。**id が判らないので返しようがない**
445
+ # (JSON-RPC も id:null を許すが、従来どおり黙って次の行へ・方針は
446
+ # 変えない)。
447
+ continue
448
+ if isinstance(req, list):
449
+ # **batch(配列)は受けない。** 受けたふりをして 1 件目だけ処理する
450
+ # と、残りが黙って消える(`-32600 Invalid Request`)。
451
+ _send(_error(None, -32600,
452
+ "batch(配列)の要求は受け付けません。1 行 1 要求で送ってください"))
273
453
  continue
274
- resp = _handle_request(req)
275
- if resp is not None:
454
+ if not isinstance(req, dict):
455
+ _send(_error(None, -32600,
456
+ f"要求は object で送ってください(受け取った: {type(req).__name__})"))
457
+ continue
458
+ req_id = req.get("id")
459
+ try:
460
+ resp = _handle_request(req)
461
+ except ToolInputError as e:
462
+ resp = _error(req_id, -32602, str(e))
463
+ except Exception as e: # noqa: BLE001
464
+ # **死なない。** 想定していない失敗も 1 要求の error にして次へ。
465
+ resp = _error(req_id, -32602, f"{type(e).__name__}: {e}")
466
+ # **通知には何も返さない**(監査 2 回目・P3-6)。失敗しても同じ——
467
+ # 返す先の `id` が無いので、`"id": null` の error を送ることになる
468
+ # (JSON-RPC 2.0 §4.1 違反・厳密なクライアントは接続を切る)。
469
+ # **死なないことと、黙ることは両立する。**
470
+ if resp is not None and not _is_notification(req):
276
471
  _send(resp)
277
472
 
278
473
 
@@ -278,8 +278,10 @@ def make_account_json(accounts_dir: str, name: str, *, repo_dir: str, **override
278
278
  def isolated_account_factory(thth_root, nigamilab_repo, monkeypatch):
279
279
  """`isolated_account` の中身をテストごとの台帳上書きに対応させたファクトリ。
280
280
 
281
- `thth.accounts.APP_DIR` を差し替えて、実物の `accounts/nigamilab-threads.json`
282
- (repo_dir が `$THTH_ROOT/repos/nigamilab` の実運用値)を汚さずにテストする。
281
+ `thth.accounts.APP_DIR` を差し替えて、**打った人の実物の台帳**
282
+ (`$THTH_ROOT/accounts/*.json`・repo_dir が実クローンを指している)を
283
+ 読みにも書きにも行かせない。repo に台帳は無い(2026-09-14 に 6 本を
284
+ `git rm`・設計 v2 §3)が、`THTH_ROOT` を向け損ねれば実物に届いてしまう。
283
285
  """
284
286
  import thth.accounts as accounts_mod
285
287
 
@@ -0,0 +1,109 @@
1
+ [
2
+ {
3
+ "url": "https://thth.me/callback/",
4
+ "status": 200,
5
+ "headers": {
6
+ "cache-control": "no-store",
7
+ "content-type": "text/html; charset=utf-8",
8
+ "referrer-policy": "no-referrer",
9
+ "x-robots-tag": "noindex, nofollow"
10
+ },
11
+ "body": "<!doctype html>\n<html lang=\"ja\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow,noarchive\">\n<title>THTH 認可の受け口</title>\n<style>\n :root { color-scheme: light dark; }\n body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;\n font: 16px/1.7 -apple-system, BlinkMacSystemFont, \"Hiragino Sans\", \"Noto Sans JP\", sans-serif;\n background:#faf9f7; color:#1a1a1a; padding:24px; }\n @media (prefers-color-scheme: dark) { body { background:#16161a; color:#eee; } }\n main { max-width: 640px; width:100%; }\n h1 { font-size:1.15rem; font-weight:600; margin:0 0 4px; letter-spacing:.02em; }\n .sub { font-size:.85rem; opacity:.65; margin:0 0 24px; }\n .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.95rem;\n word-break:break-all; background:rgba(127,127,127,.12); border-radius:10px;\n padding:16px; margin:0 0 12px; user-select:all; }\n button { font:inherit; font-size:.9rem; padding:9px 18px; border-radius:8px; cursor:pointer;\n border:1px solid rgba(127,127,127,.4); background:transparent; color:inherit; }\n button:hover { background:rgba(127,127,127,.12); }\n .note { font-size:.85rem; opacity:.7; margin-top:20px; }\n .err { color:#b3261e; } @media (prefers-color-scheme: dark) { .err { color:#f2b8b5; } }\n</style></head><body><main><h1>THTH</h1>\n <p class=\"sub\">Threads の認可の受け口です。ここを直接開いても何もありません。</p>\n <p class=\"note\">ターミナルで <code>thth auth &lt;account&gt;</code> を実行すると、\n 認可 URL が表示されます。承認するとこのページに戻ってくるので、\n 表示された <strong>URL 全体</strong>をターミナルに貼ります。</p></main></body></html>"
12
+ },
13
+ {
14
+ "url": "https://thth.me/callback",
15
+ "status": 200,
16
+ "headers": {
17
+ "cache-control": "no-store",
18
+ "content-type": "text/html; charset=utf-8",
19
+ "referrer-policy": "no-referrer",
20
+ "x-robots-tag": "noindex, nofollow"
21
+ },
22
+ "body": "<!doctype html>\n<html lang=\"ja\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow,noarchive\">\n<title>THTH 認可の受け口</title>\n<style>\n :root { color-scheme: light dark; }\n body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;\n font: 16px/1.7 -apple-system, BlinkMacSystemFont, \"Hiragino Sans\", \"Noto Sans JP\", sans-serif;\n background:#faf9f7; color:#1a1a1a; padding:24px; }\n @media (prefers-color-scheme: dark) { body { background:#16161a; color:#eee; } }\n main { max-width: 640px; width:100%; }\n h1 { font-size:1.15rem; font-weight:600; margin:0 0 4px; letter-spacing:.02em; }\n .sub { font-size:.85rem; opacity:.65; margin:0 0 24px; }\n .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.95rem;\n word-break:break-all; background:rgba(127,127,127,.12); border-radius:10px;\n padding:16px; margin:0 0 12px; user-select:all; }\n button { font:inherit; font-size:.9rem; padding:9px 18px; border-radius:8px; cursor:pointer;\n border:1px solid rgba(127,127,127,.4); background:transparent; color:inherit; }\n button:hover { background:rgba(127,127,127,.12); }\n .note { font-size:.85rem; opacity:.7; margin-top:20px; }\n .err { color:#b3261e; } @media (prefers-color-scheme: dark) { .err { color:#f2b8b5; } }\n</style></head><body><main><h1>THTH</h1>\n <p class=\"sub\">Threads の認可の受け口です。ここを直接開いても何もありません。</p>\n <p class=\"note\">ターミナルで <code>thth auth &lt;account&gt;</code> を実行すると、\n 認可 URL が表示されます。承認するとこのページに戻ってくるので、\n 表示された <strong>URL 全体</strong>をターミナルに貼ります。</p></main></body></html>"
23
+ },
24
+ {
25
+ "url": "https://thth.me/callback/?code=FAKE-CODE-abc123&state=FAKE-STATE-xyz",
26
+ "status": 200,
27
+ "headers": {
28
+ "cache-control": "no-store",
29
+ "content-type": "text/html; charset=utf-8",
30
+ "referrer-policy": "no-referrer",
31
+ "x-robots-tag": "noindex, nofollow"
32
+ },
33
+ "body": "<!doctype html>\n<html lang=\"ja\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow,noarchive\">\n<title>THTH 認可の受け口</title>\n<style>\n :root { color-scheme: light dark; }\n body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;\n font: 16px/1.7 -apple-system, BlinkMacSystemFont, \"Hiragino Sans\", \"Noto Sans JP\", sans-serif;\n background:#faf9f7; color:#1a1a1a; padding:24px; }\n @media (prefers-color-scheme: dark) { body { background:#16161a; color:#eee; } }\n main { max-width: 640px; width:100%; }\n h1 { font-size:1.15rem; font-weight:600; margin:0 0 4px; letter-spacing:.02em; }\n .sub { font-size:.85rem; opacity:.65; margin:0 0 24px; }\n .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.95rem;\n word-break:break-all; background:rgba(127,127,127,.12); border-radius:10px;\n padding:16px; margin:0 0 12px; user-select:all; }\n button { font:inherit; font-size:.9rem; padding:9px 18px; border-radius:8px; cursor:pointer;\n border:1px solid rgba(127,127,127,.4); background:transparent; color:inherit; }\n button:hover { background:rgba(127,127,127,.12); }\n .note { font-size:.85rem; opacity:.7; margin-top:20px; }\n .err { color:#b3261e; } @media (prefers-color-scheme: dark) { .err { color:#f2b8b5; } }\n</style></head><body><main><h1>認可コードを受け取りました</h1>\n <p class=\"sub\">下の <strong>URL 全体</strong>をターミナルに貼ってください\n (<code>code</code> だけでは足りません)。1 時間で切れる使い捨てです。</p>\n <p class=\"code\" id=\"c\">https://thth.me/callback/?code=FAKE-CODE-abc123&amp;state=FAKE-STATE-xyz</p>\n <button id=\"b\">コピー</button>\n \n <p class=\"note\">この画面は撮らないでください。貼り終えたら閉じて構いません。</p>\n <script>\n // アドレスバーからコードを消す(撮影・肩越しの覗き見への備え)。\n try { history.replaceState(null, \"\", location.pathname); } catch (e) {}\n document.getElementById(\"b\").onclick = async () => {\n try {\n await navigator.clipboard.writeText(document.getElementById(\"c\").textContent);\n document.getElementById(\"b\").textContent = \"コピーしました\";\n } catch (e) { document.getElementById(\"b\").textContent = \"手で選んでコピーしてください\"; }\n };\n </script></main></body></html>"
34
+ },
35
+ {
36
+ "url": "https://thth.me/callback/?code=FAKE-CODE-abc123",
37
+ "status": 200,
38
+ "headers": {
39
+ "cache-control": "no-store",
40
+ "content-type": "text/html; charset=utf-8",
41
+ "referrer-policy": "no-referrer",
42
+ "x-robots-tag": "noindex, nofollow"
43
+ },
44
+ "body": "<!doctype html>\n<html lang=\"ja\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow,noarchive\">\n<title>THTH 認可の受け口</title>\n<style>\n :root { color-scheme: light dark; }\n body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;\n font: 16px/1.7 -apple-system, BlinkMacSystemFont, \"Hiragino Sans\", \"Noto Sans JP\", sans-serif;\n background:#faf9f7; color:#1a1a1a; padding:24px; }\n @media (prefers-color-scheme: dark) { body { background:#16161a; color:#eee; } }\n main { max-width: 640px; width:100%; }\n h1 { font-size:1.15rem; font-weight:600; margin:0 0 4px; letter-spacing:.02em; }\n .sub { font-size:.85rem; opacity:.65; margin:0 0 24px; }\n .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.95rem;\n word-break:break-all; background:rgba(127,127,127,.12); border-radius:10px;\n padding:16px; margin:0 0 12px; user-select:all; }\n button { font:inherit; font-size:.9rem; padding:9px 18px; border-radius:8px; cursor:pointer;\n border:1px solid rgba(127,127,127,.4); background:transparent; color:inherit; }\n button:hover { background:rgba(127,127,127,.12); }\n .note { font-size:.85rem; opacity:.7; margin-top:20px; }\n .err { color:#b3261e; } @media (prefers-color-scheme: dark) { .err { color:#f2b8b5; } }\n</style></head><body><main><h1>認可コードを受け取りました</h1>\n <p class=\"sub\">下の <strong>URL 全体</strong>をターミナルに貼ってください\n (<code>code</code> だけでは足りません)。1 時間で切れる使い捨てです。</p>\n <p class=\"code\" id=\"c\">https://thth.me/callback/?code=FAKE-CODE-abc123</p>\n <button id=\"b\">コピー</button>\n <p class=\"note err\">state が付いていません。この戻りは <code>thth auth</code> に\n 受け付けられません(もう一度 <code>thth auth</code> から始めてください)。</p>\n <p class=\"note\">この画面は撮らないでください。貼り終えたら閉じて構いません。</p>\n <script>\n // アドレスバーからコードを消す(撮影・肩越しの覗き見への備え)。\n try { history.replaceState(null, \"\", location.pathname); } catch (e) {}\n document.getElementById(\"b\").onclick = async () => {\n try {\n await navigator.clipboard.writeText(document.getElementById(\"c\").textContent);\n document.getElementById(\"b\").textContent = \"コピーしました\";\n } catch (e) { document.getElementById(\"b\").textContent = \"手で選んでコピーしてください\"; }\n };\n </script></main></body></html>"
45
+ },
46
+ {
47
+ "url": "https://thth.me/callback/?error=access_denied&error_description=The+user+denied+your+request",
48
+ "status": 200,
49
+ "headers": {
50
+ "cache-control": "no-store",
51
+ "content-type": "text/html; charset=utf-8",
52
+ "referrer-policy": "no-referrer",
53
+ "x-robots-tag": "noindex, nofollow"
54
+ },
55
+ "body": "<!doctype html>\n<html lang=\"ja\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow,noarchive\">\n<title>THTH 認可の受け口</title>\n<style>\n :root { color-scheme: light dark; }\n body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;\n font: 16px/1.7 -apple-system, BlinkMacSystemFont, \"Hiragino Sans\", \"Noto Sans JP\", sans-serif;\n background:#faf9f7; color:#1a1a1a; padding:24px; }\n @media (prefers-color-scheme: dark) { body { background:#16161a; color:#eee; } }\n main { max-width: 640px; width:100%; }\n h1 { font-size:1.15rem; font-weight:600; margin:0 0 4px; letter-spacing:.02em; }\n .sub { font-size:.85rem; opacity:.65; margin:0 0 24px; }\n .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.95rem;\n word-break:break-all; background:rgba(127,127,127,.12); border-radius:10px;\n padding:16px; margin:0 0 12px; user-select:all; }\n button { font:inherit; font-size:.9rem; padding:9px 18px; border-radius:8px; cursor:pointer;\n border:1px solid rgba(127,127,127,.4); background:transparent; color:inherit; }\n button:hover { background:rgba(127,127,127,.12); }\n .note { font-size:.85rem; opacity:.7; margin-top:20px; }\n .err { color:#b3261e; } @media (prefers-color-scheme: dark) { .err { color:#f2b8b5; } }\n</style></head><body><main><h1>認可されませんでした</h1>\n <p class=\"sub\">Threads 側から次の理由が返りました。</p>\n <p class=\"code err\">access_denied — The user denied your request</p>\n <p class=\"note\">ターミナルに戻って <code>thth auth</code> をやり直してください。</p></main></body></html>"
56
+ },
57
+ {
58
+ "url": "https://thth.me/callback/?code=%3Cscript%3E&x=1",
59
+ "status": 200,
60
+ "headers": {
61
+ "cache-control": "no-store",
62
+ "content-type": "text/html; charset=utf-8",
63
+ "referrer-policy": "no-referrer",
64
+ "x-robots-tag": "noindex, nofollow"
65
+ },
66
+ "body": "<!doctype html>\n<html lang=\"ja\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow,noarchive\">\n<title>THTH 認可の受け口</title>\n<style>\n :root { color-scheme: light dark; }\n body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;\n font: 16px/1.7 -apple-system, BlinkMacSystemFont, \"Hiragino Sans\", \"Noto Sans JP\", sans-serif;\n background:#faf9f7; color:#1a1a1a; padding:24px; }\n @media (prefers-color-scheme: dark) { body { background:#16161a; color:#eee; } }\n main { max-width: 640px; width:100%; }\n h1 { font-size:1.15rem; font-weight:600; margin:0 0 4px; letter-spacing:.02em; }\n .sub { font-size:.85rem; opacity:.65; margin:0 0 24px; }\n .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.95rem;\n word-break:break-all; background:rgba(127,127,127,.12); border-radius:10px;\n padding:16px; margin:0 0 12px; user-select:all; }\n button { font:inherit; font-size:.9rem; padding:9px 18px; border-radius:8px; cursor:pointer;\n border:1px solid rgba(127,127,127,.4); background:transparent; color:inherit; }\n button:hover { background:rgba(127,127,127,.12); }\n .note { font-size:.85rem; opacity:.7; margin-top:20px; }\n .err { color:#b3261e; } @media (prefers-color-scheme: dark) { .err { color:#f2b8b5; } }\n</style></head><body><main><h1>認可コードを受け取りました</h1>\n <p class=\"sub\">下の <strong>URL 全体</strong>をターミナルに貼ってください\n (<code>code</code> だけでは足りません)。1 時間で切れる使い捨てです。</p>\n <p class=\"code\" id=\"c\">https://thth.me/callback/?code=%3Cscript%3E</p>\n <button id=\"b\">コピー</button>\n <p class=\"note err\">state が付いていません。この戻りは <code>thth auth</code> に\n 受け付けられません(もう一度 <code>thth auth</code> から始めてください)。</p>\n <p class=\"note\">この画面は撮らないでください。貼り終えたら閉じて構いません。</p>\n <script>\n // アドレスバーからコードを消す(撮影・肩越しの覗き見への備え)。\n try { history.replaceState(null, \"\", location.pathname); } catch (e) {}\n document.getElementById(\"b\").onclick = async () => {\n try {\n await navigator.clipboard.writeText(document.getElementById(\"c\").textContent);\n document.getElementById(\"b\").textContent = \"コピーしました\";\n } catch (e) { document.getElementById(\"b\").textContent = \"手で選んでコピーしてください\"; }\n };\n </script></main></body></html>"
67
+ },
68
+ {
69
+ "url": "https://thth.me/deauthorize",
70
+ "status": 200,
71
+ "headers": {},
72
+ "body": ""
73
+ },
74
+ {
75
+ "url": "https://thth.me/data-deletion-status",
76
+ "status": 200,
77
+ "headers": {
78
+ "cache-control": "no-store",
79
+ "content-type": "text/html; charset=utf-8",
80
+ "referrer-policy": "no-referrer",
81
+ "x-robots-tag": "noindex, nofollow"
82
+ },
83
+ "body": "<!doctype html>\n<html lang=\"ja\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow,noarchive\">\n<title>THTH 認可の受け口</title>\n<style>\n :root { color-scheme: light dark; }\n body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;\n font: 16px/1.7 -apple-system, BlinkMacSystemFont, \"Hiragino Sans\", \"Noto Sans JP\", sans-serif;\n background:#faf9f7; color:#1a1a1a; padding:24px; }\n @media (prefers-color-scheme: dark) { body { background:#16161a; color:#eee; } }\n main { max-width: 640px; width:100%; }\n h1 { font-size:1.15rem; font-weight:600; margin:0 0 4px; letter-spacing:.02em; }\n .sub { font-size:.85rem; opacity:.65; margin:0 0 24px; }\n .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.95rem;\n word-break:break-all; background:rgba(127,127,127,.12); border-radius:10px;\n padding:16px; margin:0 0 12px; user-select:all; }\n button { font:inherit; font-size:.9rem; padding:9px 18px; border-radius:8px; cursor:pointer;\n border:1px solid rgba(127,127,127,.4); background:transparent; color:inherit; }\n button:hover { background:rgba(127,127,127,.12); }\n .note { font-size:.85rem; opacity:.7; margin-top:20px; }\n .err { color:#b3261e; } @media (prefers-color-scheme: dark) { .err { color:#f2b8b5; } }\n</style></head><body><main><h1>データ削除について</h1>\n <p class=\"sub\">THTH は masaru 個人の道具で、Threads から取得した内容は\n 本人の repository にのみ保存されます。</p>\n <p class=\"note\">削除の依頼は repository の所有者へ直接どうぞ。</p></main></body></html>"
84
+ },
85
+ {
86
+ "url": "https://thth.me/",
87
+ "status": 598,
88
+ "headers": {
89
+ "content-type": "text/plain; charset=utf-8"
90
+ },
91
+ "body": "[ASSETS] /"
92
+ },
93
+ {
94
+ "url": "https://thth.me/llms.txt",
95
+ "status": 598,
96
+ "headers": {
97
+ "content-type": "text/plain; charset=utf-8"
98
+ },
99
+ "body": "[ASSETS] /llms.txt"
100
+ },
101
+ {
102
+ "url": "https://thth.me/robots.txt",
103
+ "status": 598,
104
+ "headers": {
105
+ "content-type": "text/plain; charset=utf-8"
106
+ },
107
+ "body": "[ASSETS] /robots.txt"
108
+ }
109
+ ]
@@ -6,7 +6,9 @@
6
6
  - "long"(GET /access_token)
7
7
  - "me"(GET /v1.0/me)
8
8
  - "refresh"(GET /refresh_access_token)
9
- 値は "ok" | "4xx" | "5xx" | "no_token"。
9
+ - "debug"(GET /v1.0/debug_token・既定は "absent"=404。"ok" で `data.scopes` に
10
+ `debug_scopes` を返す。`thth auth` が scope の記録元を切り替える試験用)
11
+ 値は "ok" | "4xx" | "5xx" | "no_token"("debug" だけ "absent" も)。
10
12
  """
11
13
  from __future__ import annotations
12
14
 
@@ -24,6 +26,7 @@ class _Handler(http.server.BaseHTTPRequestHandler):
24
26
  refreshed_token = "REFRESHED-SECRET-TOKEN"
25
27
  me_payload = {"id": "999999", "username": "nigamilab"}
26
28
  expires_in = 5184000
29
+ debug_scopes = ["threads_basic", "threads_content_publish"]
27
30
 
28
31
  def _respond_json(self, status: int, payload: dict) -> None:
29
32
  body = json.dumps(payload).encode("utf-8")
@@ -70,6 +73,14 @@ class _Handler(http.server.BaseHTTPRequestHandler):
70
73
  self._respond_for(self.behavior.get("refresh", "ok"),
71
74
  {"access_token": self.refreshed_token, "token_type": "bearer",
72
75
  "expires_in": self.expires_in})
76
+ elif parsed.path == "/v1.0/debug_token":
77
+ mode = self.behavior.get("debug", "absent")
78
+ if mode == "absent":
79
+ self._respond_json(404, {"error": "not found"})
80
+ else:
81
+ self._respond_for(mode, {"data": {"is_valid": True,
82
+ "scopes": list(self.debug_scopes),
83
+ "user_id": "999999"}})
73
84
  else:
74
85
  self._respond_json(404, {"error": "not found"})
75
86
 
@@ -0,0 +1,102 @@
1
+ """リダイレクトでトークンが別ホストへ漏れるかを試す偽サーバ 2 本(127.0.0.1)。
2
+
3
+ セキュリティ監査 2026-09-14・P1-1 の再現(`redirect_leak.py`)と同じ型:
4
+
5
+ - **1 本目**(`origin`)はどの口に来ても `302 Location: <2 本目>` を返す。
6
+ - **2 本目**(`elsewhere`)は受け取った要求を `received` に積む
7
+ (`Authorization` ヘッダ・クエリ文字列を含む)。
8
+
9
+ 本物の媒体(graph.threads.net・mastodon の実インスタンス・bsky.social)には
10
+ 一切触らない。トークンは**偽の値**(`FAKE-TOKEN-…`)。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import http.server
16
+ import json
17
+ import threading
18
+
19
+
20
+ def _make_recorder(received: list):
21
+ class Recorder(http.server.BaseHTTPRequestHandler):
22
+ def _record_and_reply(self):
23
+ received.append({
24
+ "path": self.path,
25
+ "authorization": self.headers.get("Authorization"),
26
+ "headers": {k.lower(): v for k, v in self.headers.items()},
27
+ })
28
+ body = json.dumps({"id": "999999", "username": "leaked"}).encode("utf-8")
29
+ self.send_response(200)
30
+ self.send_header("Content-Type", "application/json")
31
+ self.send_header("Content-Length", str(len(body)))
32
+ self.end_headers()
33
+ self.wfile.write(body)
34
+
35
+ do_GET = _record_and_reply
36
+ do_POST = _record_and_reply
37
+
38
+ def log_message(self, format, *args): # noqa: A002
39
+ pass
40
+
41
+ return Recorder
42
+
43
+
44
+ def _make_redirector(target: dict, status: int, location=None):
45
+ class Redirector(http.server.BaseHTTPRequestHandler):
46
+ def _redirect(self):
47
+ where = location if location is not None else target["base"] + self.path
48
+ self.send_response(status)
49
+ self.send_header("Location", where)
50
+ self.send_header("Content-Length", "0")
51
+ self.end_headers()
52
+
53
+ do_GET = _redirect
54
+ do_POST = _redirect
55
+
56
+ def log_message(self, format, *args): # noqa: A002
57
+ pass
58
+
59
+ return Redirector
60
+
61
+
62
+ @contextlib.contextmanager
63
+ def redirect_pair(*, status: int = 302, location=None):
64
+ """`(origin_base, elsewhere_base, received)` を返す。
65
+
66
+ `location` を渡すと 1 本目はその値をそのまま `Location` に入れる
67
+ (相対 `//host/...` や `http://` への格下げを試すため)。
68
+ """
69
+ received: list = []
70
+ target = {"base": ""}
71
+
72
+ elsewhere = http.server.HTTPServer(("127.0.0.1", 0), _make_recorder(received))
73
+ target["base"] = f"http://127.0.0.1:{elsewhere.server_port}"
74
+ origin = http.server.HTTPServer(("127.0.0.1", 0),
75
+ _make_redirector(target, status, location))
76
+
77
+ threads = []
78
+ for server in (elsewhere, origin):
79
+ t = threading.Thread(target=server.serve_forever, daemon=True)
80
+ t.start()
81
+ threads.append(t)
82
+ try:
83
+ yield (f"http://127.0.0.1:{origin.server_port}", target["base"], received)
84
+ finally:
85
+ for server in (elsewhere, origin):
86
+ server.shutdown()
87
+ for t in threads:
88
+ t.join(timeout=5)
89
+
90
+
91
+ @contextlib.contextmanager
92
+ def recording_server():
93
+ """記録だけする 1 本(リダイレクトしない口の対照実験用)。"""
94
+ received: list = []
95
+ server = http.server.HTTPServer(("127.0.0.1", 0), _make_recorder(received))
96
+ t = threading.Thread(target=server.serve_forever, daemon=True)
97
+ t.start()
98
+ try:
99
+ yield (f"http://127.0.0.1:{server.server_port}", received)
100
+ finally:
101
+ server.shutdown()
102
+ t.join(timeout=5)