protege 0.1.0.alpha.1

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 (255) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +24 -0
  3. data/Rakefile +12 -0
  4. data/app/assets/builds/protege.css +1128 -0
  5. data/app/assets/javascripts/protege/attachment_picker.js +163 -0
  6. data/app/assets/javascripts/protege/copy.js +80 -0
  7. data/app/assets/javascripts/protege/forms.js +28 -0
  8. data/app/assets/javascripts/protege/keys.js +212 -0
  9. data/app/assets/javascripts/protege/resize.js +149 -0
  10. data/app/assets/javascripts/protege/theme.js +25 -0
  11. data/app/assets/stylesheets/protege/application.css +15 -0
  12. data/app/assets/tailwind/application.css +137 -0
  13. data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
  14. data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
  15. data/app/controllers/protege/access_rules_controller.rb +72 -0
  16. data/app/controllers/protege/application_controller.rb +18 -0
  17. data/app/controllers/protege/archives_controller.rb +52 -0
  18. data/app/controllers/protege/email_domains_controller.rb +72 -0
  19. data/app/controllers/protege/home_controller.rb +15 -0
  20. data/app/controllers/protege/messages_controller.rb +46 -0
  21. data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
  22. data/app/controllers/protege/personas_controller.rb +118 -0
  23. data/app/controllers/protege/replies_controller.rb +64 -0
  24. data/app/controllers/protege/responsibilities_controller.rb +82 -0
  25. data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
  26. data/app/controllers/protege/threads_controller.rb +75 -0
  27. data/app/controllers/protege/traces/searches_controller.rb +19 -0
  28. data/app/controllers/protege/traces_controller.rb +52 -0
  29. data/app/helpers/protege/application_helper.rb +30 -0
  30. data/app/helpers/protege/components/badges_helper.rb +59 -0
  31. data/app/helpers/protege/components/buttons_helper.rb +141 -0
  32. data/app/helpers/protege/components/cards_helper.rb +48 -0
  33. data/app/helpers/protege/components/components_helper.rb +30 -0
  34. data/app/helpers/protege/components/dialogs_helper.rb +36 -0
  35. data/app/helpers/protege/components/forms_helper.rb +223 -0
  36. data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
  37. data/app/helpers/protege/components/icons_helper.rb +41 -0
  38. data/app/helpers/protege/components/introspection_helper.rb +162 -0
  39. data/app/helpers/protege/components/layout_helper.rb +169 -0
  40. data/app/helpers/protege/components/menus_helper.rb +68 -0
  41. data/app/helpers/protege/components/pagination_helper.rb +100 -0
  42. data/app/helpers/protege/components/sidebar_helper.rb +211 -0
  43. data/app/helpers/protege/components/tables_helper.rb +59 -0
  44. data/app/helpers/protege/components/typography_helper.rb +64 -0
  45. data/app/helpers/protege/email_domains_helper.rb +22 -0
  46. data/app/helpers/protege/messages_helper.rb +152 -0
  47. data/app/helpers/protege/personas_helper.rb +90 -0
  48. data/app/helpers/protege/responsibilities_helper.rb +63 -0
  49. data/app/helpers/protege/threads_helper.rb +118 -0
  50. data/app/helpers/protege/trace_helper.rb +22 -0
  51. data/app/hooks/protege/event_logger_hook.rb +109 -0
  52. data/app/jobs/protege/application_job.rb +41 -0
  53. data/app/jobs/protege/console_inference_job.rb +57 -0
  54. data/app/jobs/protege/inference_job.rb +60 -0
  55. data/app/jobs/protege/mail_provisioning_job.rb +21 -0
  56. data/app/jobs/protege/responsibility_job.rb +70 -0
  57. data/app/jobs/protege/responsibility_tick_job.rb +19 -0
  58. data/app/mailboxes/protege/agent_mailbox.rb +123 -0
  59. data/app/mailers/protege/application_mailer.rb +65 -0
  60. data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
  61. data/app/models/concerns/protege/broadcastable_message.rb +47 -0
  62. data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
  63. data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
  64. data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
  65. data/app/models/concerns/protege/tool_scoped.rb +88 -0
  66. data/app/models/protege/access_rule.rb +50 -0
  67. data/app/models/protege/application_record.rb +18 -0
  68. data/app/models/protege/current.rb +23 -0
  69. data/app/models/protege/email_domain.rb +136 -0
  70. data/app/models/protege/email_thread.rb +81 -0
  71. data/app/models/protege/message.rb +401 -0
  72. data/app/models/protege/persona.rb +229 -0
  73. data/app/models/protege/responsibility.rb +98 -0
  74. data/app/models/protege/responsibility_run.rb +68 -0
  75. data/app/models/protege/tool_use.rb +115 -0
  76. data/app/models/protege/trace.rb +71 -0
  77. data/app/providers/protege/open_router_provider.rb +317 -0
  78. data/app/resolvers/README.md +255 -0
  79. data/app/resolvers/protege/load_file_resolver.rb +86 -0
  80. data/app/resolvers/protege/load_record_resolver.rb +80 -0
  81. data/app/resolvers/protege/load_text_resolver.rb +40 -0
  82. data/app/resolvers/protege/thread_history_resolver.rb +116 -0
  83. data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
  84. data/app/services/protege/message_search.rb +114 -0
  85. data/app/services/protege/trace_search.rb +102 -0
  86. data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
  87. data/app/tools/protege/create_file_tool.rb +109 -0
  88. data/app/tools/protege/read_attachment_tool.rb +101 -0
  89. data/app/tools/protege/search_emails_tool.rb +129 -0
  90. data/app/tools/protege/send_email_tool.rb +240 -0
  91. data/app/tools/protege/web_fetch_tool.rb +224 -0
  92. data/app/tools/protege/web_search_tool.rb +131 -0
  93. data/app/views/layouts/protege/application.html.slim +19 -0
  94. data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
  95. data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
  96. data/app/views/protege/email_domains/_form.html.slim +10 -0
  97. data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
  98. data/app/views/protege/email_domains/index.html.slim +6 -0
  99. data/app/views/protege/email_domains/new.html.slim +7 -0
  100. data/app/views/protege/email_domains/show.html.slim +36 -0
  101. data/app/views/protege/home/show.html.slim +26 -0
  102. data/app/views/protege/messages/_message.html.slim +19 -0
  103. data/app/views/protege/messages/_sidebar.html.slim +13 -0
  104. data/app/views/protege/messages/index.html.slim +20 -0
  105. data/app/views/protege/personas/_access_rule.html.slim +7 -0
  106. data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
  107. data/app/views/protege/personas/_access_rules.html.slim +19 -0
  108. data/app/views/protege/personas/_actions.html.slim +15 -0
  109. data/app/views/protege/personas/_form.html.slim +29 -0
  110. data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
  111. data/app/views/protege/personas/_sidebar.html.slim +11 -0
  112. data/app/views/protege/personas/edit.html.slim +7 -0
  113. data/app/views/protege/personas/index.html.slim +6 -0
  114. data/app/views/protege/personas/new.html.slim +7 -0
  115. data/app/views/protege/personas/show.html.slim +37 -0
  116. data/app/views/protege/responsibilities/_form.html.slim +21 -0
  117. data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
  118. data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
  119. data/app/views/protege/responsibilities/edit.html.slim +7 -0
  120. data/app/views/protege/responsibilities/index.html.slim +6 -0
  121. data/app/views/protege/responsibilities/new.html.slim +7 -0
  122. data/app/views/protege/responsibilities/show.html.slim +22 -0
  123. data/app/views/protege/responsibility_runs/show.html.slim +13 -0
  124. data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
  125. data/app/views/protege/shared/_header.html.slim +14 -0
  126. data/app/views/protege/shared/_hotkeys.html.slim +43 -0
  127. data/app/views/protege/threads/_composer_footer.html.slim +15 -0
  128. data/app/views/protege/threads/_introspection.html.slim +7 -0
  129. data/app/views/protege/threads/_reasoning.html.slim +4 -0
  130. data/app/views/protege/threads/_reply_form.html.slim +7 -0
  131. data/app/views/protege/threads/_run_marker.html.slim +3 -0
  132. data/app/views/protege/threads/_sidebar.html.slim +15 -0
  133. data/app/views/protege/threads/_thread.html.slim +6 -0
  134. data/app/views/protege/threads/_tool_call.html.slim +8 -0
  135. data/app/views/protege/threads/index.html.slim +6 -0
  136. data/app/views/protege/threads/new.html.slim +15 -0
  137. data/app/views/protege/threads/show.html.slim +25 -0
  138. data/app/views/protege/traces/_sidebar.html.slim +15 -0
  139. data/app/views/protege/traces/index.html.slim +11 -0
  140. data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
  141. data/app/views/protege/traces/searches/show.html.slim +22 -0
  142. data/app/views/protege/traces/show.html.slim +28 -0
  143. data/config/routes.rb +33 -0
  144. data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
  145. data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
  146. data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
  147. data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
  148. data/lib/generators/protege/extension_naming.rb +38 -0
  149. data/lib/generators/protege/hook/hook_generator.rb +33 -0
  150. data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
  151. data/lib/generators/protege/install/install_generator.rb +170 -0
  152. data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
  153. data/lib/generators/protege/persona/persona_generator.rb +35 -0
  154. data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
  155. data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
  156. data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
  157. data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
  158. data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
  159. data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
  160. data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
  161. data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
  162. data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
  163. data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
  164. data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
  165. data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
  166. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
  167. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
  168. data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
  169. data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
  170. data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
  171. data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
  172. data/lib/generators/protege/provider/provider_generator.rb +44 -0
  173. data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
  174. data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
  175. data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
  176. data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
  177. data/lib/generators/protege/tool/tool_generator.rb +34 -0
  178. data/lib/protege/configuration.rb +171 -0
  179. data/lib/protege/engine.rb +103 -0
  180. data/lib/protege/errors/bad_request_error.rb +8 -0
  181. data/lib/protege/errors/contract_violation_error.rb +8 -0
  182. data/lib/protege/errors/error.rb +30 -0
  183. data/lib/protege/errors/generic_error.rb +7 -0
  184. data/lib/protege/errors/internal_error.rb +8 -0
  185. data/lib/protege/errors/invalid_model_error.rb +8 -0
  186. data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
  187. data/lib/protege/errors/rate_limited_error.rb +8 -0
  188. data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
  189. data/lib/protege/errors/response_parse_failed_error.rb +8 -0
  190. data/lib/protege/errors/timeout_error.rb +8 -0
  191. data/lib/protege/errors/tool_not_available_error.rb +12 -0
  192. data/lib/protege/errors/tool_not_found_error.rb +9 -0
  193. data/lib/protege/errors/unauthorized_error.rb +8 -0
  194. data/lib/protege/errors/unavailable_error.rb +8 -0
  195. data/lib/protege/errors/unsupported_capability_error.rb +8 -0
  196. data/lib/protege/events/event.rb +98 -0
  197. data/lib/protege/events/inference_chunk_event.rb +17 -0
  198. data/lib/protege/events/inference_completed_event.rb +12 -0
  199. data/lib/protege/events/inference_failed_event.rb +12 -0
  200. data/lib/protege/events/inference_generated_event.rb +19 -0
  201. data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
  202. data/lib/protege/events/inference_started_event.rb +11 -0
  203. data/lib/protege/events/loop_run_completed_event.rb +11 -0
  204. data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
  205. data/lib/protege/events/loop_run_failed_event.rb +12 -0
  206. data/lib/protege/events/loop_run_started_event.rb +11 -0
  207. data/lib/protege/events/tool_call_completed_event.rb +12 -0
  208. data/lib/protege/events/tool_call_failed_event.rb +12 -0
  209. data/lib/protege/events/tool_call_started_event.rb +11 -0
  210. data/lib/protege/events/tool_calls_received_event.rb +11 -0
  211. data/lib/protege/extensions/hook.rb +13 -0
  212. data/lib/protege/extensions/hook_mixin.rb +90 -0
  213. data/lib/protege/extensions/provider.rb +12 -0
  214. data/lib/protege/extensions/provider_mixin.rb +124 -0
  215. data/lib/protege/extensions/resolver.rb +12 -0
  216. data/lib/protege/extensions/resolver_mixin.rb +58 -0
  217. data/lib/protege/extensions/tool.rb +12 -0
  218. data/lib/protege/extensions/tool_mixin.rb +217 -0
  219. data/lib/protege/gateway/access_control.rb +66 -0
  220. data/lib/protege/gateway/access_policy.rb +94 -0
  221. data/lib/protege/gateway/attachment_policy.rb +115 -0
  222. data/lib/protege/gateway/mail/address.rb +145 -0
  223. data/lib/protege/gateway/mail/attachment.rb +17 -0
  224. data/lib/protege/gateway/mail/message_id.rb +104 -0
  225. data/lib/protege/gateway/mail/outbound.rb +181 -0
  226. data/lib/protege/gateway/mail/references.rb +107 -0
  227. data/lib/protege/gateway.rb +398 -0
  228. data/lib/protege/inference/provider/request.rb +110 -0
  229. data/lib/protege/inference/provider/response.rb +50 -0
  230. data/lib/protege/inference/provider/tool_call.rb +52 -0
  231. data/lib/protege/inference.rb +121 -0
  232. data/lib/protege/loop/schedule.rb +66 -0
  233. data/lib/protege/loop/scheduler.rb +42 -0
  234. data/lib/protege/loop.rb +37 -0
  235. data/lib/protege/orchestrator/context.rb +65 -0
  236. data/lib/protege/orchestrator/harness.rb +305 -0
  237. data/lib/protege/orchestrator/reply_context.rb +21 -0
  238. data/lib/protege/orchestrator/reply_harness.rb +68 -0
  239. data/lib/protege/orchestrator/resolver_chain.rb +93 -0
  240. data/lib/protege/orchestrator/responsibility_context.rb +22 -0
  241. data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
  242. data/lib/protege/orchestrator.rb +37 -0
  243. data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
  244. data/lib/protege/subscribers/tracing.rb +65 -0
  245. data/lib/protege/types/document_part.rb +13 -0
  246. data/lib/protege/types/image_part.rb +11 -0
  247. data/lib/protege/types/model_message.rb +40 -0
  248. data/lib/protege/types/result.rb +113 -0
  249. data/lib/protege/types/stored_file.rb +127 -0
  250. data/lib/protege/types/text_part.rb +8 -0
  251. data/lib/protege/types/tool_result.rb +16 -0
  252. data/lib/protege/version.rb +6 -0
  253. data/lib/protege.rb +47 -0
  254. data/lib/tasks/protege_tasks.rake +16 -0
  255. metadata +494 -0
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env python3
2
+ """Provision the Protege mail accessory's OpenDKIM/Postfix config from a domain manifest.
3
+
4
+ The Rails app PUSHES the full domain manifest (every EmailDomain + its DKIM key) to this listener
5
+ whenever a domain changes — see Protege::Gateway.push_mail_provisioning. We rewrite the OpenDKIM key
6
+ tables and the Postfix transport/relay_domains from it, then reload. The same apply() runs once at
7
+ container boot from the cached manifest, so a restart self-heals without waiting for a push.
8
+
9
+ Auth reuses RAILS_INBOUND_EMAIL_PASSWORD (the same shared secret the inbound relay uses). The listener
10
+ binds the internal Kamal network only and is never published to the host — port 25 is the only exposed
11
+ port. This keeps the private keys on the internal hop, same trust boundary as the plaintext SMTP relay.
12
+ """
13
+ import base64
14
+ import grp
15
+ import hmac
16
+ import json
17
+ import os
18
+ import pwd
19
+ import signal
20
+ import subprocess
21
+ import sys
22
+ from http.server import BaseHTTPRequestHandler, HTTPServer
23
+
24
+ DATA_DIR = "/var/lib/protege-mail"
25
+ KEYS_DIR = os.path.join(DATA_DIR, "keys")
26
+ MANIFEST_PATH = os.path.join(DATA_DIR, "manifest.json")
27
+ KEY_TABLE = "/etc/opendkim/KeyTable"
28
+ SIGNING_TABLE = "/etc/opendkim/SigningTable"
29
+ TRANSPORT = "/etc/postfix/transport"
30
+ OPENDKIM_PID = "/run/opendkim/opendkim.pid"
31
+ HEADER = "# generated at runtime by provision.py — do not edit\n"
32
+ PORT = int(os.environ.get("MAIL_PROVISION_PORT", "8025"))
33
+
34
+
35
+ def _opendkim_ids():
36
+ """The opendkim uid/gid so written keys/tables are readable by the daemon, or (None, None)."""
37
+ try:
38
+ return pwd.getpwnam("opendkim").pw_uid, grp.getgrnam("opendkim").gr_gid
39
+ except KeyError:
40
+ return None, None
41
+
42
+
43
+ def apply(manifest):
44
+ """Rewrite key files + OpenDKIM/Postfix tables from the manifest, then reload (best-effort)."""
45
+ domains = manifest.get("domains", [])
46
+ uid, gid = _opendkim_ids()
47
+ os.makedirs(KEYS_DIR, exist_ok=True)
48
+
49
+ key_lines, signing_lines, transport_lines, relay_domains = [], [], [], []
50
+ for entry in domains:
51
+ domain = entry["domain"]
52
+ selector = entry.get("selector") or "default"
53
+ pem = entry["private_key_pem"]
54
+
55
+ key_dir = os.path.join(KEYS_DIR, domain)
56
+ os.makedirs(key_dir, exist_ok=True)
57
+ key_path = os.path.join(key_dir, selector + ".private")
58
+ with open(key_path, "w") as handle:
59
+ handle.write(pem if pem.endswith("\n") else pem + "\n")
60
+ os.chmod(key_path, 0o600)
61
+ if uid is not None:
62
+ os.chown(key_path, uid, gid)
63
+ os.chown(key_dir, uid, gid)
64
+
65
+ key_lines.append("%s._domainkey.%s %s:%s:%s" % (selector, domain, domain, selector, key_path))
66
+ signing_lines.append("*@%s %s._domainkey.%s" % (domain, selector, domain))
67
+ transport_lines.append("%s actionmailbox:" % domain)
68
+ relay_domains.append(domain)
69
+
70
+ _write(KEY_TABLE, key_lines, uid, gid)
71
+ _write(SIGNING_TABLE, signing_lines, uid, gid)
72
+ _write(TRANSPORT, transport_lines, None, None)
73
+
74
+ _run(["postmap", TRANSPORT])
75
+ _run(["postconf", "-e", "relay_domains=" + " ".join(relay_domains)])
76
+ _reload()
77
+ _cache(manifest)
78
+ return len(domains)
79
+
80
+
81
+ def _write(path, lines, uid, gid):
82
+ with open(path, "w") as handle:
83
+ handle.write(HEADER)
84
+ for line in lines:
85
+ handle.write(line + "\n")
86
+ if uid is not None:
87
+ try:
88
+ os.chown(path, uid, gid)
89
+ except OSError:
90
+ pass
91
+
92
+
93
+ def _run(cmd):
94
+ """Run a command, swallowing failures — reloads are best-effort (a service may not be up yet)."""
95
+ try:
96
+ subprocess.run(cmd, check=True)
97
+ except (subprocess.CalledProcessError, FileNotFoundError):
98
+ pass
99
+
100
+
101
+ def _reload():
102
+ _run(["postfix", "reload"])
103
+ try:
104
+ with open(OPENDKIM_PID) as handle:
105
+ os.kill(int(handle.read().strip()), signal.SIGUSR1)
106
+ except (OSError, ValueError):
107
+ pass
108
+
109
+
110
+ def _cache(manifest):
111
+ os.makedirs(DATA_DIR, exist_ok=True)
112
+ with open(MANIFEST_PATH, "w") as handle:
113
+ json.dump(manifest, handle)
114
+
115
+
116
+ def _load_cached():
117
+ try:
118
+ with open(MANIFEST_PATH) as handle:
119
+ return json.load(handle)
120
+ except (OSError, ValueError):
121
+ return None
122
+
123
+
124
+ class Handler(BaseHTTPRequestHandler):
125
+ def do_POST(self):
126
+ if self.path != "/provision":
127
+ return self._send(404, {"error": "not found"})
128
+ if not self._authorized():
129
+ self.send_response(401)
130
+ self.send_header("WWW-Authenticate", 'Basic realm="protege-mail"')
131
+ self.end_headers()
132
+ return
133
+ length = int(self.headers.get("Content-Length", 0))
134
+ try:
135
+ manifest = json.loads(self.rfile.read(length) or b"{}")
136
+ count = apply(manifest)
137
+ except (ValueError, KeyError) as error:
138
+ return self._send(400, {"error": str(error)})
139
+ self._send(200, {"ok": True, "domains": count})
140
+
141
+ def _authorized(self):
142
+ expected = os.environ.get("RAILS_INBOUND_EMAIL_PASSWORD", "")
143
+ header = self.headers.get("Authorization", "")
144
+ if not expected or not header.startswith("Basic "):
145
+ return False
146
+ try:
147
+ user, _, password = base64.b64decode(header[6:]).decode().partition(":")
148
+ except (ValueError, UnicodeDecodeError):
149
+ return False
150
+ return user == "provision" and hmac.compare_digest(password, expected)
151
+
152
+ def _send(self, status, body):
153
+ payload = json.dumps(body).encode()
154
+ self.send_response(status)
155
+ self.send_header("Content-Type", "application/json")
156
+ self.send_header("Content-Length", str(len(payload)))
157
+ self.end_headers()
158
+ self.wfile.write(payload)
159
+
160
+ def log_message(self, *args):
161
+ pass # keep the accessory log focused on mail
162
+
163
+
164
+ def serve():
165
+ HTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
166
+
167
+
168
+ def main(argv):
169
+ command = argv[1] if len(argv) > 1 else "serve"
170
+ if command == "serve":
171
+ serve()
172
+ elif command == "apply":
173
+ manifest = _load_cached()
174
+ if manifest is not None:
175
+ apply(manifest)
176
+ else:
177
+ sys.stderr.write("usage: provision.py [serve|apply]\n")
178
+ sys.exit(2)
179
+
180
+
181
+ if __name__ == "__main__":
182
+ main(sys.argv)
@@ -0,0 +1,28 @@
1
+ #!/bin/sh
2
+ # Postfix pipe transport: forward one inbound message to the Rails relay ingress.
3
+ #
4
+ # Postfix feeds the raw RFC822 message on stdin (see the actionmailbox service in master.cf). We POST it
5
+ # verbatim to Action Mailbox's relay endpoint with HTTP Basic auth (user "actionmailbox", password =
6
+ # the ingress password). On any failure we exit EX_TEMPFAIL (75) so Postfix keeps the message queued and
7
+ # retries, rather than bouncing mail the app briefly couldn't accept.
8
+ set -eu
9
+
10
+ # Postfix's pipe daemon scrubs the environment, so the entrypoint stashed the relay credentials in a
11
+ # file (readable only by this pipe user). Load them here.
12
+ if [ -r /etc/protege-mail/relay.env ]; then
13
+ . /etc/protege-mail/relay.env
14
+ fi
15
+
16
+ : "${APP_RELAY_URL:?APP_RELAY_URL not set}"
17
+ : "${RAILS_INBOUND_EMAIL_PASSWORD:?RAILS_INBOUND_EMAIL_PASSWORD not set}"
18
+
19
+ if curl --fail --silent --show-error \
20
+ --user "actionmailbox:${RAILS_INBOUND_EMAIL_PASSWORD}" \
21
+ --header "Content-Type: message/rfc822" \
22
+ --data-binary @- \
23
+ "${APP_RELAY_URL}"; then
24
+ exit 0
25
+ else
26
+ echo "relay-to-app: POST to ${APP_RELAY_URL} failed; deferring for retry" >&2
27
+ exit 75 # EX_TEMPFAIL
28
+ fi
@@ -0,0 +1,50 @@
1
+ # Builds the self-hosted mail image (Postfix + OpenDKIM) and pushes it to your registry. Kamal
2
+ # accessories pull a prebuilt image rather than building one, so this runs separately from your
3
+ # deploy: on changes to deploy/mail/**, or on demand. After it publishes, roll it out with
4
+ # `kamal accessory reboot mail`.
5
+ #
6
+ # To use: rename to build-mail-image.yml and adjust the registry/paths for your setup. As written it
7
+ # publishes to GHCR under your org (ghcr.io/<owner>/protege-mail) using the built-in GITHUB_TOKEN.
8
+ name: Build mail image
9
+
10
+ on:
11
+ push:
12
+ branches: [main]
13
+ paths:
14
+ - 'deploy/mail/**'
15
+ - '.github/workflows/build-mail-image.yml'
16
+ workflow_dispatch:
17
+
18
+ concurrency:
19
+ group: build-mail-image
20
+ cancel-in-progress: true
21
+
22
+ jobs:
23
+ build:
24
+ runs-on: ubuntu-latest
25
+ permissions:
26
+ contents: read
27
+ packages: write
28
+ steps:
29
+ - name: Checkout
30
+ uses: actions/checkout@v4
31
+
32
+ - name: Set up Docker Buildx
33
+ uses: docker/setup-buildx-action@v3
34
+
35
+ - name: Log in to GHCR
36
+ uses: docker/login-action@v3
37
+ with:
38
+ registry: ghcr.io
39
+ username: ${{ github.actor }}
40
+ password: ${{ secrets.GITHUB_TOKEN }}
41
+
42
+ - name: Build and push
43
+ uses: docker/build-push-action@v6
44
+ with:
45
+ context: deploy/mail
46
+ platforms: linux/amd64
47
+ push: true
48
+ tags: |
49
+ ghcr.io/${{ github.repository_owner }}/protege-mail:latest
50
+ ghcr.io/${{ github.repository_owner }}/protege-mail:${{ github.sha }}
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/named_base'
4
+ require_relative '../extension_naming'
5
+
6
+ module Protege
7
+ module Generators
8
+ # Scaffolds an inference provider — the adapter between Protege and an LLM backend — into the host
9
+ # app's providers directory (+config.providers_path+, default +app/providers+). A provider
10
+ # subclasses +Protege::Provider+, declares its +protege_id+, and implements +generate+ (hash in,
11
+ # hash out). It is auto-discovered; activate it by pointing +config.provider_id+ at its id and
12
+ # putting its credentials under that key in +config.providers+.
13
+ #
14
+ # The +Provider+ suffix is applied for you, and the +protege_id+ is derived from the name.
15
+ #
16
+ # @example
17
+ # bin/rails g protege:provider my_llm
18
+ class ProviderGenerator < Rails::Generators::NamedBase
19
+ include ExtensionNaming
20
+
21
+ source_root File.expand_path('templates', __dir__)
22
+
23
+ desc 'Scaffold a Protege::Provider subclass in the configured providers path (default app/providers).'
24
+
25
+ # Render the provider scaffold into +config.providers_path+.
26
+ #
27
+ # @return [void]
28
+ def create_provider
29
+ template 'provider.rb.tt',
30
+ File.join(Protege.configuration.providers_path, "#{extension_file_name('Provider')}.rb")
31
+ end
32
+
33
+ private
34
+
35
+ # The symbolic +protege_id+ for this provider — the file name with the +provider+ suffix stripped
36
+ # (e.g. +my_llm_provider+ → +:my_llm+). This is the id the host references from +config.provider_id+.
37
+ #
38
+ # @return [String] the snake_cased id
39
+ def protege_id
40
+ extension_file_name('Provider').sub(/_provider\z/, '')
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # <%= extension_class_name('Provider') %> — an inference provider: the adapter between Protege and an LLM backend.
4
+ #
5
+ # Subclassing +Protege::Provider+ registers it automatically. Activate it by pointing the engine at its
6
+ # id and giving it credentials in the Protege initializer:
7
+ #
8
+ # config.provider_id = :<%= protege_id %>
9
+ # config.providers = { <%= protege_id %>: { api_key: ENV['<%= protege_id.upcase %>_API_KEY'], model: '...' } }
10
+ #
11
+ # The harness drives one method: +#generate+. It takes an OpenAI-compatible request Hash
12
+ # (+{ messages:, tools: }+) and returns a response Hash (+{ content:, tool_calls:, finish_reason: }+),
13
+ # raising a +Protege::Error+ on transport/auth/parse failure. Read this provider's own settings from
14
+ # its config slice via +options+ below. See +Protege::OpenRouterProvider+ for a complete reference
15
+ # implementation (streaming, error mapping, tool-call normalization).
16
+ class <%= extension_class_name('Provider') %> < Protege::Provider
17
+ protege_id :<%= protege_id %>
18
+
19
+ # Run a blocking, non-streaming completion.
20
+ #
21
+ # @param request [Hash] the request hash — +:messages+ and +:tools+ in OpenAI chat-completions shape
22
+ # @return [Hash] the response hash: +{ content:, tool_calls:, finish_reason: }+
23
+ # @raise [Protege::Error] on a transport, auth, or parse failure
24
+ def generate(request)
25
+ # TODO: call your backend with `request` (merge in `model` and `settings`) and map the reply into
26
+ # the { content:, tool_calls:, finish_reason: } shape Protege expects.
27
+ raise NotImplementedError, '<%= extension_class_name('Provider') %>#generate is not implemented yet'
28
+ end
29
+
30
+ # Optional: override to stream tokens as they arrive. The default (from +Protege::Provider+) calls
31
+ # +generate+ and yields its whole text as one chunk, which is fine until you need real streaming.
32
+ #
33
+ # @param request [Hash] the request hash (see {#generate})
34
+ # @yieldparam chunk [Hash] a +{ type: :text, content: String }+ token chunk
35
+ # @return [Hash] the final assembled response hash
36
+ # def generate_stream(request, &block)
37
+ # super
38
+ # end
39
+
40
+ private
41
+
42
+ # This provider's slice of +config.providers+, keyed by its own +protege_id+ (+:<%= protege_id %>+).
43
+ # Empty when the host hasn't configured it. Read credentials/model/sampling from here — never ENV
44
+ # directly, so all configuration flows through the initializer.
45
+ #
46
+ # @return [Hash] this provider's configured options (or +{}+)
47
+ def options
48
+ Protege.configuration.provider_options(self.class.id)
49
+ end
50
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/named_base'
4
+ require_relative '../extension_naming'
5
+
6
+ module Protege
7
+ module Generators
8
+ # Scaffolds a Protege resolver — one link in a persona's context-building chain — into the host
9
+ # app's resolvers directory (+config.resolvers_path+, default +app/resolvers+). A resolver
10
+ # subclasses +Protege::Resolver+ and implements +resolve+, contributing zero or more
11
+ # +Protege::ModelMessage+s to the prompt the harness sends the model.
12
+ #
13
+ # Unlike tools, resolvers are NOT auto-discovered: add the class to a persona's chain
14
+ # (+chain.use MyResolver+) for it to run. The +Resolver+ suffix is applied for you.
15
+ #
16
+ # @example
17
+ # bin/rails g protege:resolver thread_summary
18
+ class ResolverGenerator < Rails::Generators::NamedBase
19
+ include ExtensionNaming
20
+
21
+ source_root File.expand_path('templates', __dir__)
22
+
23
+ desc 'Scaffold a Protege::Resolver subclass in the configured resolvers path (default app/resolvers).'
24
+
25
+ # Render the resolver scaffold into +config.resolvers_path+.
26
+ #
27
+ # @return [void]
28
+ def create_resolver
29
+ template 'resolver.rb.tt',
30
+ File.join(Protege.configuration.resolvers_path, "#{extension_file_name('Resolver')}.rb")
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # <%= extension_class_name('Resolver') %> — contributes context to the prompt before the model runs.
4
+ #
5
+ # A resolver is one link in a persona's chain. Subclass +Protege::Resolver+, implement +#resolve+, and
6
+ # add it to a chain to activate it (resolvers are NOT auto-discovered):
7
+ #
8
+ # # in app/personas/your_persona.rb
9
+ # message_resolvers { |chain| chain.use <%= extension_class_name('Resolver') %> } # reply runs
10
+ # responsibility_resolvers { |chain| chain.use <%= extension_class_name('Resolver') %> } # scheduled runs
11
+ #
12
+ # +#resolve+ returns one +Protege::ModelMessage+, an Array of them, or +nil+ to contribute nothing.
13
+ # Build messages with the +message+ helper — never construct +ModelMessage+ by hand.
14
+ class <%= extension_class_name('Resolver') %> < Protege::Resolver
15
+ # Contribute this resolver's slice of context.
16
+ #
17
+ # The +context+ tells you which kind of run this is (read it, never mutate it — it is frozen):
18
+ # - reply run: +context.message+ is the inbound +Protege::Message+ (and +context.message.email_thread+)
19
+ # - scheduled run: +context.responsibility+ is the +Protege::Responsibility+; +context.message+ is nil
20
+ # Both always expose +context.persona+, +context.config+, and +context.logger+.
21
+ #
22
+ # Pick a +role+ for what you contribute: +:system+ for standing instructions/facts, +:user+ or
23
+ # +:assistant+ for conversation turns, +:tool+ for a replayed tool result.
24
+ #
25
+ # @param context [Protege::Orchestrator::Context] the frozen run context
26
+ # @return [Protege::ModelMessage, Array<Protege::ModelMessage>, nil] the contribution, or nil for none
27
+ def resolve(context:)
28
+ # TODO: build and return your context. Return nil when there is nothing to add.
29
+ message(role: :system, content: "TODO: context for #{context.persona.name}")
30
+ end
31
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # <%= extension_class_name('Tool') %> — a capability the agent can call mid-conversation.
4
+ #
5
+ # Subclassing +Protege::Tool+ registers this tool automatically (no wiring): the harness publishes it
6
+ # in the model's tool catalog and routes matching tool calls to +#use+. The tool's id is derived from
7
+ # this class name with the +Tool+ suffix stripped and snake_cased — here +:<%= extension_file_name('Tool').sub(/_tool\z/, '') %>+.
8
+ #
9
+ # Fill in the three things the model relies on:
10
+ # 1. +description+ — WHEN to reach for this tool and WHAT it does, written for the model to read.
11
+ # 2. +input_schema+ — the JSON Schema for the arguments; the model must produce arguments matching it.
12
+ # 3. +#use+ — the behaviour, returning a +Protege::Result+.
13
+ class <%= extension_class_name('Tool') %> < Protege::Tool
14
+ # Shown to the model as this tool's contract. Be concrete about when it should (and should not) be
15
+ # used — this is the model's only guidance for choosing it.
16
+ description <<~DESC.strip
17
+ TODO: Describe what this tool does and when the agent should call it. Written for the model.
18
+ DESC
19
+
20
+ # The JSON Schema the model's arguments must satisfy. Keys here arrive as symbol keyword arguments to
21
+ # +#use+. Keep it tight — +additionalProperties: false+ and an explicit +required+ list steer the
22
+ # model toward valid calls.
23
+ input_schema(
24
+ type: 'object',
25
+ additionalProperties: false,
26
+ required: %w[example],
27
+ properties: {
28
+ example: {
29
+ type: 'string',
30
+ description: 'TODO: describe this argument for the model.'
31
+ }
32
+ }
33
+ )
34
+
35
+ # Run the tool. Called by the harness with the run +context+ and the model's parsed arguments as
36
+ # keyword arguments matching {input_schema}.
37
+ #
38
+ # Return a +Protege::Result+: +success(**data)+ with any data to hand back to the model, or
39
+ # +failure(reason:)+ with a short explanation it can act on. A raised error is caught and reported to
40
+ # the model as a failure, so raise freely for the genuinely exceptional.
41
+ #
42
+ # @param context [Protege::Orchestrator::Context] the run context — +context.message+ on a reply run
43
+ # (and its +email_thread+), +context.responsibility+ on a scheduled run, plus +persona+/+logger+
44
+ # @param example [String] the example argument declared in {input_schema} (rename to your own)
45
+ # @return [Protege::Result] the outcome handed back to the model
46
+ def use(context:, example:)
47
+ # TODO: implement. `context` gives you the persona and the current message/thread or responsibility.
48
+ success(echo: example)
49
+ end
50
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/named_base'
4
+ require_relative '../extension_naming'
5
+
6
+ module Protege
7
+ module Generators
8
+ # Scaffolds a Protege tool — a capability the agent can invoke mid-conversation — into the host
9
+ # app's tools directory (+config.tools_path+, default +app/tools+). A tool subclasses
10
+ # +Protege::Tool+, declares its +description+ and JSON +input_schema+ for the model, and implements
11
+ # +use+; the harness auto-discovers it (no manual registration) and publishes it in the LLM's tool
12
+ # catalog.
13
+ #
14
+ # The +Tool+ suffix is applied for you, so all of +send_email+, +SendEmail+, and +SendEmailTool+
15
+ # produce class +SendEmailTool+ in +send_email_tool.rb+.
16
+ #
17
+ # @example
18
+ # bin/rails g protege:tool send_email
19
+ class ToolGenerator < Rails::Generators::NamedBase
20
+ include ExtensionNaming
21
+
22
+ source_root File.expand_path('templates', __dir__)
23
+
24
+ desc 'Scaffold a Protege::Tool subclass in the configured tools path (default app/tools).'
25
+
26
+ # Render the tool scaffold into +config.tools_path+.
27
+ #
28
+ # @return [void]
29
+ def create_tool
30
+ template 'tool.rb.tt', File.join(Protege.configuration.tools_path, "#{extension_file_name('Tool')}.rb")
31
+ end
32
+ end
33
+ end
34
+ end