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,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Create the protege_traces table — an opt-in, durable snapshot of every inference turn kept for later
4
+ # fine-tuning. Deliberately standalone and minimal: no foreign keys, no references, and no identity
5
+ # columns (persona/provider). A trace records only what affects training — the +request+ as sent, the
6
+ # +response+ it produced, and the inference settings (+model+ + +settings+) that shaped it — plus a
7
+ # human review verdict (+label+/+annotation+). +correlation_id+ + +turn_index+ group the turns of one
8
+ # run and form a unique key so a retried run re-snapshots in place rather than duplicating training rows.
9
+ class CreateProtegeTraces < ActiveRecord::Migration[7.2]
10
+ def change
11
+ create_table :protege_traces do |t|
12
+ t.string :correlation_id
13
+ t.integer :turn_index
14
+
15
+ t.string :model, index: true
16
+ t.text :settings
17
+
18
+ t.text :request
19
+ t.text :response
20
+
21
+ t.string :label, null: false, default: 'unlabeled', index: true
22
+ t.text :annotation
23
+ t.datetime :reviewed_at, index: true
24
+
25
+ t.timestamps
26
+ end
27
+
28
+ add_index :protege_traces, %i[correlation_id turn_index], unique: true, name: 'index_protege_traces_on_run_turn'
29
+ end
30
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Replace the persona +active+ boolean with an +archived_at+ timestamp — the idiomatic soft-delete shape,
4
+ # which also records *when* a persona was archived. The model keeps +active?+/+scope :active+ as derived
5
+ # readers over +archived_at+, so routing/scheduler/status code is unchanged.
6
+ class ReplacePersonaActiveWithArchivedAt < ActiveRecord::Migration[8.1]
7
+ # Add archived_at, mark already-deactivated personas as archived-now, then drop active.
8
+ #
9
+ # @return [void]
10
+ def up
11
+ add_column :protege_personas, :archived_at, :datetime
12
+ execute "UPDATE protege_personas SET archived_at = CURRENT_TIMESTAMP WHERE active = #{connection.quoted_false}"
13
+ remove_column :protege_personas, :active
14
+ end
15
+
16
+ # Restore the active boolean, deactivating any archived personas, then drop archived_at.
17
+ #
18
+ # @return [void]
19
+ def down
20
+ add_column :protege_personas, :active, :boolean, null: false, default: true
21
+ execute "UPDATE protege_personas SET active = #{connection.quoted_false} WHERE archived_at IS NOT NULL"
22
+ remove_column :protege_personas, :archived_at
23
+ end
24
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Add +disabled_tool_ids+ to personas — the operator's subtractive tool-scoping override. A persona's
4
+ # code-declared +tools+ grant is the ceiling of what it *may* use; this column names the ids the operator
5
+ # has switched *off* within that grant, so scoping narrows and never widens. Stored as a JSON array in a
6
+ # text column (SQLite has no native JSON type), matching +Message+'s recipient-list convention, and
7
+ # defaults to an empty array so an unconfigured persona keeps its full grant.
8
+ class AddDisabledToolIdsToPersonas < ActiveRecord::Migration[8.1]
9
+ # Add the JSON-array text column, non-null with an empty-array default.
10
+ #
11
+ # @return [void]
12
+ def up
13
+ add_column :protege_personas, :disabled_tool_ids, :text, null: false, default: '[]'
14
+ end
15
+
16
+ # Drop the column.
17
+ #
18
+ # @return [void]
19
+ def down
20
+ remove_column :protege_personas, :disabled_tool_ids
21
+ end
22
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Generators
5
+ # Shared naming helpers for the extension scaffold generators (+tool+, +resolver+, +hook+,
6
+ # +provider+). Host apps name Protege extensions by a type suffix — +SendEmailTool+,
7
+ # +ThreadHistoryResolver+, +EventLoggerHook+, +OpenRouterProvider+ — so these helpers derive the
8
+ # final class and file names from whatever the developer typed, idempotently applying the suffix.
9
+ #
10
+ # The suffix is applied whether or not it was supplied, so all three of +send_email+, +SendEmail+,
11
+ # and +SendEmailTool+ resolve to class +SendEmailTool+ / file +send_email_tool.rb+. Pass an empty
12
+ # suffix (as the persona generator does) to use the name verbatim.
13
+ module ExtensionNaming
14
+ private
15
+
16
+ # The extension's class name with +suffix+ guaranteed exactly once.
17
+ #
18
+ # @param suffix [String] the type suffix to enforce (e.g. +"Tool"+), or +""+ to use as-is
19
+ # @return [String] the camelized class name ending in +suffix+
20
+ def extension_class_name(suffix)
21
+ return class_name if suffix.to_s.empty?
22
+
23
+ "#{class_name.sub(/#{suffix}\z/i, '')}#{suffix}"
24
+ end
25
+
26
+ # The extension's file name (no extension) with the snake_cased +suffix+ guaranteed exactly once.
27
+ #
28
+ # @param suffix [String] the type suffix to enforce (e.g. +"Tool"+), or +""+ to use as-is
29
+ # @return [String] the snake_cased file basename ending in the underscored +suffix+
30
+ def extension_file_name(suffix)
31
+ return file_name if suffix.to_s.empty?
32
+
33
+ underscored = suffix.underscore
34
+ "#{file_name.sub(/_?#{underscored}\z/i, '')}_#{underscored}"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,33 @@
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 hook — an event subscriber that reacts to the agent's run lifecycle — into the
9
+ # host app's hooks directory (+config.hooks_path+, default +app/hooks+). A hook subclasses
10
+ # +Protege::Hook+ and declares +on(SomeEvent) { |event| ... }+ handlers; the engine wires them onto
11
+ # the event bus at boot (auto-discovered, no manual registration). Handlers run isolated, so an
12
+ # error in one can never break inference.
13
+ #
14
+ # The +Hook+ suffix is applied for you.
15
+ #
16
+ # @example
17
+ # bin/rails g protege:hook audit_log
18
+ class HookGenerator < Rails::Generators::NamedBase
19
+ include ExtensionNaming
20
+
21
+ source_root File.expand_path('templates', __dir__)
22
+
23
+ desc 'Scaffold a Protege::Hook subclass in the configured hooks path (default app/hooks).'
24
+
25
+ # Render the hook scaffold into +config.hooks_path+.
26
+ #
27
+ # @return [void]
28
+ def create_hook
29
+ template 'hook.rb.tt', File.join(Protege.configuration.hooks_path, "#{extension_file_name('Hook')}.rb")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # <%= extension_class_name('Hook') %> — reacts to events across the agent's run lifecycle.
4
+ #
5
+ # Subclassing +Protege::Hook+ wires these handlers onto the event bus at boot (auto-discovered, no
6
+ # registration). Each +on+ block receives a typed, frozen +Protege::Event+ and runs isolated — a raised
7
+ # error is swallowed and logged, never breaking the run. Read event fields with nil-safe named readers
8
+ # (+event.persona+, +event.tool_call+, +event.result+, +event.error+, +event.correlation_id+).
9
+ #
10
+ # Declare a handler with +on(EventClass) { |event| ... }+ (pass several classes to share one block).
11
+ # Available events (all +Protege::+ constants), in fire order:
12
+ # InferenceStartedEvent, InferenceGeneratedEvent, InferenceCompletedEvent, InferenceFailedEvent,
13
+ # ToolCallsReceivedEvent, ToolCallStartedEvent, ToolCallCompletedEvent, ToolCallFailedEvent,
14
+ # InferenceMaxTurnsReachedEvent, InferenceChunkEvent,
15
+ # LoopRunEnqueuedEvent, LoopRunStartedEvent, LoopRunCompletedEvent, LoopRunFailedEvent
16
+ #
17
+ # Add +stateful+ (uncomment below) to reuse ONE instance per +correlation_id+ across a run, so handlers
18
+ # can accumulate state in instance variables; the default is a fresh instance per event.
19
+ class <%= extension_class_name('Hook') %> < Protege::Hook
20
+ # stateful
21
+
22
+ # TODO: replace with the events you care about.
23
+ on InferenceStartedEvent do |event|
24
+ log "inference started for persona=#{event.persona&.name} (#{event.correlation_id})"
25
+ end
26
+
27
+ private
28
+
29
+ # Write a line to the configured Protege logger.
30
+ #
31
+ # @param line [String] the message to log
32
+ # @return [void]
33
+ def log(line)
34
+ Protege.configuration.logger.info("[<%= extension_class_name('Hook') %>] #{line}")
35
+ end
36
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/base'
4
+ require_relative '../persona/persona_generator'
5
+
6
+ module Protege
7
+ module Generators
8
+ # Bootstraps Protege into a host Rails app — the one command to go from +gem 'protege'+ to a
9
+ # runnable dashboard. It installs the Rails frameworks Protege depends on (Active Storage for
10
+ # attachments, Action Mailbox for inbound mail), writes a fully-documented initializer, mounts the
11
+ # engine, wires the Loop's recurring tick, and scaffolds a starter persona. The steps it cannot do
12
+ # safely for you — wrapping the mount in your own authentication, running migrations, setting
13
+ # credentials — are printed as next steps.
14
+ #
15
+ # @example
16
+ # bin/rails g protege:install
17
+ class InstallGenerator < Rails::Generators::Base
18
+ source_root File.expand_path('templates', __dir__)
19
+
20
+ desc 'Install Protege into the host app: initializer, engine mount, recurring tick, and a starter persona.'
21
+
22
+ # The recurring job entry that drives the Loop scheduler — one tick a minute fires due
23
+ # responsibilities. Written at column zero; {#indented_tick} nests it under an environment key.
24
+ RECURRING_TICK = <<~YAML
25
+ protege_responsibility_tick:
26
+ class: Protege::ResponsibilityTickJob
27
+ schedule: every minute
28
+ YAML
29
+
30
+ # The recurring.yml environments the tick must run in.
31
+ ENVIRONMENTS = %w[production development].freeze
32
+
33
+ # Install the Rails frameworks Protege builds on: Active Storage (attachments) and Action Mailbox
34
+ # (inbound mail — this also creates +app/mailboxes/application_mailbox.rb+). Their migrations are
35
+ # copied here; Protege's own migrations load straight from the gem.
36
+ #
37
+ # @return [void]
38
+ def install_dependencies
39
+ rails_command 'active_storage:install'
40
+ rails_command 'action_mailbox:install'
41
+ end
42
+
43
+ # Write the fully-documented Protege initializer with smart, ENV-backed defaults.
44
+ #
45
+ # @return [void]
46
+ def create_initializer
47
+ template 'initializer.rb.tt', 'config/initializers/protege.rb'
48
+ end
49
+
50
+ # Mount the engine in the host's routes, with a loud reminder to wrap it in authentication — the
51
+ # engine ships none, so an unwrapped mount exposes the dashboard to everyone.
52
+ #
53
+ # @return [void]
54
+ def mount_engine
55
+ route <<~RUBY.chomp
56
+ # TODO: Protege ships no authentication — wrap this mount in your app's auth
57
+ # (e.g. `authenticate :user do ... end`) before exposing the dashboard.
58
+ mount Protege::Engine => '/protege'
59
+ RUBY
60
+ end
61
+
62
+ # Wire the Loop's recurring tick into +config/recurring.yml+. Creates the file when absent;
63
+ # ensures the tick runs in both +production+ and +development+, injecting it under an existing
64
+ # environment key or appending a fresh block when that environment is absent (a fresh Rails app
65
+ # ships only a +production:+ block, so +development:+ is added). Idempotent — a second run is a
66
+ # no-op once the tick is present.
67
+ #
68
+ # @return [void]
69
+ def add_recurring_tick
70
+ path = 'config/recurring.yml'
71
+
72
+ if !file_exists?(path)
73
+ create_file path, ENVIRONMENTS.map { |env| "#{env}:\n#{indented_tick}" }.join("\n")
74
+ elsif !recurring_already_wired?(path)
75
+ ENVIRONMENTS.each { |env| add_tick_for(env, path) }
76
+ end
77
+ end
78
+
79
+ # Scaffold a starter +Agent+ persona by delegating to the persona generator (single source of
80
+ # truth for the persona template).
81
+ #
82
+ # @return [void]
83
+ def create_starter_persona
84
+ invoke Protege::Generators::PersonaGenerator, ['Agent']
85
+ end
86
+
87
+ # Print the wiring the generator can't safely automate.
88
+ #
89
+ # @return [void]
90
+ def print_next_steps
91
+ say "\n Protege installed.", :green
92
+ say <<~STEPS
93
+
94
+ Next steps:
95
+
96
+ 1. Run the migrations (Active Storage, Action Mailbox, and Protege's own):
97
+ bin/rails db:migrate
98
+
99
+ 2. WRAP THE ENGINE MOUNT in your authentication in config/routes.rb — the engine ships no
100
+ auth of its own. An unwrapped mount exposes the dashboard to anyone.
101
+
102
+ 3. Set your provider credentials (the defaults read OpenRouter from ENV):
103
+ OPENROUTER_API_KEY=sk-... (and OPENROUTER_MODEL to pick a model)
104
+
105
+ 4. Let extensions register in development by loading them eagerly — add to
106
+ config/environments/development.rb:
107
+ config.eager_load = true
108
+ (tools/providers/hooks are discovered as loaded subclasses).
109
+
110
+ 5. Create your first persona + email domain (dashboard, or a db/seeds.rb bootstrap) so
111
+ inbound mail has somewhere to route. Then boot and open /protege.
112
+ STEPS
113
+ end
114
+
115
+ private
116
+
117
+ # Whether a path exists under the destination root.
118
+ #
119
+ # @param path [String] the app-relative path
120
+ # @return [Boolean]
121
+ def file_exists?(path)
122
+ File.exist?(File.join(destination_root, path))
123
+ end
124
+
125
+ # The contents of a destination-relative file.
126
+ #
127
+ # @param path [String] the app-relative path
128
+ # @return [String] the file body
129
+ def read_destination(path)
130
+ File.read(File.join(destination_root, path))
131
+ end
132
+
133
+ # Add the tick for one environment: nest it under the env key when that key exists, or append a
134
+ # fresh +<env>:+ block when it doesn't (a fresh Rails app ships +production:+ but no +development:+).
135
+ #
136
+ # @param env [String] the environment name (+production+ / +development+)
137
+ # @param path [String] the recurring.yml path
138
+ # @return [void]
139
+ def add_tick_for(env, path)
140
+ env_key = /^#{env}:[ \t]*\n/
141
+
142
+ if read_destination(path).match?(env_key)
143
+ # gsub_file, not inject_into_file: the latter de-dupes by content and would skip the second
144
+ # environment (the tick block is identical), leaving development un-wired.
145
+ gsub_file path, env_key do |match|
146
+ "#{match}#{indented_tick}"
147
+ end
148
+ else
149
+ append_to_file path, "\n#{env}:\n#{indented_tick}"
150
+ end
151
+ end
152
+
153
+ # Whether the tick is already wired (keeps the step idempotent).
154
+ #
155
+ # @param path [String] the recurring.yml path
156
+ # @return [Boolean]
157
+ def recurring_already_wired?(path)
158
+ read_destination(path).include?('protege_responsibility_tick')
159
+ end
160
+
161
+ # The tick entry indented two spaces so it nests under a +recurring.yml+ environment key. Blank
162
+ # lines are left untouched.
163
+ #
164
+ # @return [String] the indented YAML block
165
+ def indented_tick
166
+ RECURRING_TICK.gsub(/^(?=.)/, ' ')
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Protege engine configuration. Set once at boot; treated as read-only thereafter (the Orchestrator
4
+ # snapshots a frozen copy per delivery). Every option is shown below — active ones are set to sensible,
5
+ # ENV-backed defaults, and optional ones are commented with their engine default so the full surface is
6
+ # visible at a glance.
7
+ Protege.configure do |config|
8
+ # ── Console / dashboard ─────────────────────────────────────────────────────
9
+
10
+ # Address representing the dashboard user in local conversations; replies to it skip SMTP delivery.
11
+ # (default: "console@protege.local")
12
+ # config.console_address = 'console@protege.local'
13
+
14
+ # Brand shown in the dashboard nav header. (default: "🥚 Protege")
15
+ # config.nav_title = '🥚 Protege'
16
+
17
+ # ── Inference ─────────────────────────────────────────────────────────────────
18
+
19
+ # Maximum tool-calling rounds before the harness returns the last response. (default: 8)
20
+ # config.max_tool_turns = 8
21
+
22
+ # Symbolic id of the provider extension to run inference through; must match a registered provider's
23
+ # +protege_id+ (here the built-in OpenRouter's :openrouter). (default: nil)
24
+ config.provider_id = :openrouter
25
+
26
+ # Per-provider options, keyed by provider id — model, sampling, and credentials all live here. The
27
+ # built-in OpenRouter provider reads its +:openrouter+ slice: +model+/+api_key+/+base_url+ are
28
+ # required (they raise on first use if missing), +temperature+/+max_output_tokens+ optional. Add a key
29
+ # for any custom provider and read it back via +Protege.configuration.provider_options(:your_id)+.
30
+ config.providers = {
31
+ openrouter: {
32
+ model: ENV.fetch('OPENROUTER_MODEL', 'anthropic/claude-sonnet-4-5'),
33
+ api_key: ENV.fetch('OPENROUTER_API_KEY', nil),
34
+ base_url: ENV.fetch('OPENROUTER_BASE_URL', 'https://openrouter.ai/api/v1')
35
+ # temperature: 0.7,
36
+ # max_output_tokens: 4096
37
+ }
38
+ }
39
+
40
+ # ── Tracing ───────────────────────────────────────────────────────────────────
41
+
42
+ # Capture every inference turn (request + response + settings) into a durable Protege::Trace for
43
+ # review and fine-tuning, browsable under /protege/traces. Off by default — nothing is recorded until
44
+ # you opt in; multimodal turns store attachment bytes inline, so rows can be large.
45
+ # (default: { enabled: false })
46
+ # config.tracing = { enabled: true }
47
+
48
+ # ── Inbound access control ──────────────────────────────────────────────────
49
+
50
+ # Global ceiling on which senders may reach ANY persona — the committed layer of the access guardrail.
51
+ # Per-persona rules (managed in the dashboard) only narrow this further, never widen it. Patterns are
52
+ # an exact address or a single "*" wildcard. Defaults to permit-all.
53
+ # config.inbound_access = Protege::Gateway.build_access_policy(allow: ['*@your-company.com'])
54
+ # config.inbound_access = Protege::Gateway.build_access_policy(deny: ['*@spam.example'])
55
+
56
+ # ── Attachments ─────────────────────────────────────────────────────────────
57
+
58
+ # Inbound mail breaching any limit is bounced; the send_email tool refuses to attach a breaching set.
59
+ # Defaults: enabled, 10 MB/attachment, 10 attachments/message, 25 MB total.
60
+ # config.attachment_policy = Protege::Gateway.build_attachment_policy(max_bytes: 5 * 1024 * 1024)
61
+ # config.attachment_policy = Protege::Gateway.build_attachment_policy(enabled: false)
62
+
63
+ # ── Web tools ───────────────────────────────────────────────────────────────
64
+
65
+ # User-Agent the built-in web_search / web_fetch tools send. Defaults to a generic, unbranded browser
66
+ # string.
67
+ # config.http_user_agent = ENV.fetch('HTTP_USER_AGENT', Protege::Configuration::DEFAULT_HTTP_USER_AGENT)
68
+
69
+ # ── Extension scaffold paths ──────────────────────────────────────────────────
70
+
71
+ # Where the `bin/rails g protege:{tool,resolver,hook,persona,provider}` generators write. Defaults to
72
+ # the conventional app/ directories; override if you group extensions elsewhere (must stay under an
73
+ # autoloaded path).
74
+ # config.tools_path = 'app/tools'
75
+ # config.resolvers_path = 'app/resolvers'
76
+ # config.hooks_path = 'app/hooks'
77
+ # config.personas_path = 'app/personas'
78
+ # config.providers_path = 'app/providers'
79
+
80
+ # ── Logging ─────────────────────────────────────────────────────────────────
81
+
82
+ # Logger for engine output; defaults to Rails.logger. Override to route to a dedicated target:
83
+ # config.logger = Logger.new(Rails.root.join('log/protege.log'))
84
+ end
@@ -0,0 +1,35 @@
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 persona — the identity Protege binds an inbox to — into the host app's personas
9
+ # directory (+config.personas_path+, default +app/personas+). A persona is an STI subclass of
10
+ # +Protege::Persona+ (an ActiveRecord model); subclassing registers the subtype, and each persona
11
+ # declares its display name and the resolver chains that assemble context for its reply and
12
+ # scheduled runs.
13
+ #
14
+ # The +Persona+ suffix is applied for you, so all of +support+, +Support+, and +SupportPersona+
15
+ # produce class +SupportPersona+ in +support_persona.rb+.
16
+ #
17
+ # @example
18
+ # bin/rails g protege:persona support
19
+ class PersonaGenerator < Rails::Generators::NamedBase
20
+ include ExtensionNaming
21
+
22
+ source_root File.expand_path('templates', __dir__)
23
+
24
+ desc 'Scaffold a Protege::Persona subclass in the configured personas path (default app/personas).'
25
+
26
+ # Render the persona scaffold into +config.personas_path+.
27
+ #
28
+ # @return [void]
29
+ def create_persona
30
+ template 'persona.rb.tt',
31
+ File.join(Protege.configuration.personas_path, "#{extension_file_name('Persona')}.rb")
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # <%= extension_class_name('Persona') %> — a persona: the identity Protege binds an inbox to.
4
+ #
5
+ # Subclassing +Protege::Persona+ (an STI ActiveRecord model) registers this subtype. Create the row in
6
+ # the dashboard (or a seed) to give it a +name+, +email_address+, and +instructions+ (its editable
7
+ # system prompt); inbound mail to that address routes here, and outbound replies are sent as it.
8
+ #
9
+ # The two chains below assemble the context sent to the model. Engine built-in resolvers are named by
10
+ # their flat +Protege::+ constant; host-app resolvers (see <%= Protege.configuration.resolvers_path %>/) by their own top-level constant.
11
+ class <%= extension_class_name('Persona') %> < Protege::Persona
12
+ # Human-friendly label shown for this persona in the dashboard, logs, and tooling.
13
+ self.display_name = '<%= extension_class_name('Persona').titleize %>'
14
+
15
+ # Context assembled before every reply to inbound mail. +Protege::LoadTextResolver+ supplies this
16
+ # persona's dashboard-editable +instructions+ as the system prompt; +Protege::ThreadHistoryResolver+
17
+ # replays the conversation so far. Insert your own resolvers between them (e.g. to inject records,
18
+ # summaries, or retrieved facts).
19
+ message_resolvers do |chain|
20
+ chain.use(Protege::LoadTextResolver, role: :system) { |ctx| ctx.persona.instructions }
21
+ chain.use Protege::ThreadHistoryResolver
22
+ end
23
+
24
+ # Context assembled for a scheduled run (the Loop layer). There is no inbound thread, so the chain is
25
+ # just identity: the persona's +instructions+ as the system prompt and the responsibility's own
26
+ # prompt as the opening user message. Add task-specific resolvers here if a responsibility needs more.
27
+ responsibility_resolvers do |chain|
28
+ chain.use(Protege::LoadTextResolver, role: :system) { |ctx| ctx.persona.instructions }
29
+ chain.use(Protege::LoadTextResolver, role: :user) { |ctx| ctx.responsibility.instructions }
30
+ end
31
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/base'
4
+
5
+ module Protege
6
+ module Generators
7
+ # Vendors the self-hosted Postfix + OpenDKIM mail-server deploy files into the host application.
8
+ #
9
+ # Self-hosting mail needs a Docker image that runs Postfix (MTA) and OpenDKIM (DKIM) for the agent
10
+ # domain. Those build files ship inside the gem; this generator copies them into the host's
11
+ # +deploy/mail/+ so the developer can build, publish, and run the image (e.g. as a Kamal accessory).
12
+ # It also drops adaptable host-wiring references (a Kamal accessory snippet, an example CI workflow,
13
+ # and a deploy guide), then prints the next steps.
14
+ #
15
+ # Files are copied **verbatim** (never ERB-templated): +main.cf+/+KeyTable+/+SigningTable+ carry a
16
+ # +__MAIL_DOMAIN__+ placeholder and the scripts use shell +${VAR}+ interpolation — all substituted
17
+ # at container runtime by +entrypoint.sh+, not at generate time.
18
+ #
19
+ # @example
20
+ # bin/rails g protege:postfix
21
+ class PostfixGenerator < Rails::Generators::Base
22
+ source_root File.expand_path('templates', __dir__)
23
+
24
+ desc 'Copy the self-hosted Postfix + OpenDKIM mail-server deploy files into deploy/mail/.'
25
+
26
+ # Copy the container build files + in-repo references (README, MAIL.md, Kamal snippet) verbatim.
27
+ #
28
+ # @return [void]
29
+ def copy_mail_server_files
30
+ directory 'deploy/mail', 'deploy/mail'
31
+ end
32
+
33
+ # Copy the example CI workflow that builds and publishes the image.
34
+ #
35
+ # @return [void]
36
+ def copy_ci_workflow
37
+ copy_file 'github/workflows/build-mail-image.example.yml',
38
+ '.github/workflows/build-mail-image.example.yml'
39
+ end
40
+
41
+ # Print the host-side wiring the generator can't do for you (deployment-specific).
42
+ #
43
+ # @return [void]
44
+ def print_next_steps
45
+ say "\n Self-hosted mail files written to deploy/mail/.", :green
46
+ say <<~STEPS
47
+
48
+ Next steps (see deploy/mail/MAIL.md for the full guide):
49
+
50
+ 1. Build + publish the image:
51
+ docker build --platform linux/amd64 -t YOUR_REGISTRY/protege-mail:latest deploy/mail
52
+ docker push YOUR_REGISTRY/protege-mail:latest
53
+ (or rename .github/workflows/build-mail-image.example.yml to build it in CI)
54
+
55
+ 2. Run it as your MTA — merge deploy/mail/kamal-accessory.example.yml into config/deploy.yml.
56
+ The only secret is RAILS_INBOUND_EMAIL_PASSWORD; there is NO DKIM key secret — the app
57
+ pushes each domain's key to the MTA when you add it (and via the console "Sync now" button).
58
+ Keep the protege_mail_provision volume so the pushed keys survive restarts.
59
+
60
+ 3. Point the app at it (config/environments/production.rb + deploy.yml app env):
61
+ config.action_mailbox.ingress = :relay
62
+ config.action_mailer.smtp_settings = { address: ENV.fetch("MAIL_RELAY_HOST", "protege-mail"),
63
+ port: 25, authentication: nil, enable_starttls_auto: false }
64
+ Set MAIL_RELAY_HOST: protege-mail in the app service's env — the app pushes provisioning
65
+ there, and its presence puts the console in self-hosted mode.
66
+
67
+ 4. Set up PTR/reverse DNS, open inbound TCP 25, request an outbound-25 unblock if needed,
68
+ then add each agent domain in the dashboard (Domains → New) and publish the SPF/DKIM/DMARC
69
+ + MX records it shows.
70
+ STEPS
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,43 @@
1
+ # Self-hosted MTA for Protege: Postfix (SMTP send + receive) + OpenDKIM (sign + verify) + OpenDMARC and
2
+ # policyd-spf (inbound SPF/DKIM/DMARC enforcement).
3
+ #
4
+ # Runs as the Kamal `mail` accessory (see workspace/config/deploy.yml). It owns port 25 on the VPS:
5
+ # - inbound: checks SPF (policyd-spf) + DKIM (OpenDKIM) + DMARC (OpenDMARC), rejecting spoofed/failing
6
+ # mail at SMTP, then pipes each surviving message to the app's relay ingress.
7
+ # - outbound: accepts mail from the app container (Kamal network), OpenDKIM-signs it, delivers to
8
+ # the recipient's MX directly (no relay provider). Our own outbound skips the inbound checks.
9
+ #
10
+ # Build + push (Kamal accessories pull a prebuilt image, they do not build one):
11
+ # docker build --platform linux/amd64 -t ghcr.io/sebscholl/protege-mail:latest workspace/deploy/mail
12
+ # docker push ghcr.io/sebscholl/protege-mail:latest
13
+ FROM debian:bookworm-slim
14
+
15
+ # postfix: MTA. opendkim/opendkim-tools: DKIM milter. opendmarc: DMARC milter.
16
+ # postfix-policyd-spf-python: SPF policy service. rsyslog: Postfix logs via syslog; we tail it to
17
+ # stdout so `kamal accessory logs mail` shows mail.log. curl/ca-certificates: inbound relay POST + TLS.
18
+ RUN apt-get update \
19
+ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
20
+ postfix opendkim opendkim-tools opendmarc postfix-policyd-spf-python \
21
+ rsyslog curl ca-certificates openssl \
22
+ && rm -rf /var/lib/apt/lists/*
23
+
24
+ # Postfix + OpenDKIM + OpenDMARC + policyd-spf configuration. main.cf drives Postfix; opendkim.conf +
25
+ # the tables drive signing/verifying; opendmarc.conf drives DMARC enforcement; policyd-spf.conf drives
26
+ # SPF. The actionmailbox pipe and the policyd-spf spawn service are appended to the stock master.cf at
27
+ # runtime (postconf -M) so we keep the distro's standard services. The entrypoint fills in the runtime
28
+ # bits (MAIL_DOMAIN, the DMARC reject toggle) first, then provision.py writes the per-domain DKIM keys +
29
+ # tables from the manifest the app pushes (KeyTable/SigningTable ship as generated-at-runtime stubs).
30
+ # python3 for provision.py is already present (pulled in by postfix-policyd-spf-python).
31
+ COPY main.cf /etc/postfix/main.cf
32
+ COPY opendkim.conf /etc/opendkim.conf
33
+ COPY KeyTable SigningTable TrustedHosts /etc/opendkim/
34
+ COPY opendmarc.conf /etc/opendmarc.conf
35
+ COPY opendmarc.ignore.hosts /etc/opendmarc/ignore.hosts
36
+ COPY policyd-spf.conf /etc/postfix-policyd-spf-python/policyd-spf.conf
37
+ COPY relay-to-app.sh /usr/local/bin/relay-to-app.sh
38
+ COPY provision.py /usr/local/bin/provision.py
39
+ COPY entrypoint.sh /usr/local/bin/entrypoint.sh
40
+ RUN chmod +x /usr/local/bin/relay-to-app.sh /usr/local/bin/provision.py /usr/local/bin/entrypoint.sh
41
+
42
+ EXPOSE 25
43
+ ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
@@ -0,0 +1,3 @@
1
+ # generated at runtime by provision.py — do not edit
2
+ # One line per domain, written from the manifest the Rails app pushes:
3
+ # <selector>._domainkey.<domain> <domain>:<selector>:/var/lib/protege-mail/keys/<domain>/<selector>.private