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.
- checksums.yaml +7 -0
- data/README.md +24 -0
- data/Rakefile +12 -0
- data/app/assets/builds/protege.css +1128 -0
- data/app/assets/javascripts/protege/attachment_picker.js +163 -0
- data/app/assets/javascripts/protege/copy.js +80 -0
- data/app/assets/javascripts/protege/forms.js +28 -0
- data/app/assets/javascripts/protege/keys.js +212 -0
- data/app/assets/javascripts/protege/resize.js +149 -0
- data/app/assets/javascripts/protege/theme.js +25 -0
- data/app/assets/stylesheets/protege/application.css +15 -0
- data/app/assets/tailwind/application.css +137 -0
- data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
- data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
- data/app/controllers/protege/access_rules_controller.rb +72 -0
- data/app/controllers/protege/application_controller.rb +18 -0
- data/app/controllers/protege/archives_controller.rb +52 -0
- data/app/controllers/protege/email_domains_controller.rb +72 -0
- data/app/controllers/protege/home_controller.rb +15 -0
- data/app/controllers/protege/messages_controller.rb +46 -0
- data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
- data/app/controllers/protege/personas_controller.rb +118 -0
- data/app/controllers/protege/replies_controller.rb +64 -0
- data/app/controllers/protege/responsibilities_controller.rb +82 -0
- data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
- data/app/controllers/protege/threads_controller.rb +75 -0
- data/app/controllers/protege/traces/searches_controller.rb +19 -0
- data/app/controllers/protege/traces_controller.rb +52 -0
- data/app/helpers/protege/application_helper.rb +30 -0
- data/app/helpers/protege/components/badges_helper.rb +59 -0
- data/app/helpers/protege/components/buttons_helper.rb +141 -0
- data/app/helpers/protege/components/cards_helper.rb +48 -0
- data/app/helpers/protege/components/components_helper.rb +30 -0
- data/app/helpers/protege/components/dialogs_helper.rb +36 -0
- data/app/helpers/protege/components/forms_helper.rb +223 -0
- data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
- data/app/helpers/protege/components/icons_helper.rb +41 -0
- data/app/helpers/protege/components/introspection_helper.rb +162 -0
- data/app/helpers/protege/components/layout_helper.rb +169 -0
- data/app/helpers/protege/components/menus_helper.rb +68 -0
- data/app/helpers/protege/components/pagination_helper.rb +100 -0
- data/app/helpers/protege/components/sidebar_helper.rb +211 -0
- data/app/helpers/protege/components/tables_helper.rb +59 -0
- data/app/helpers/protege/components/typography_helper.rb +64 -0
- data/app/helpers/protege/email_domains_helper.rb +22 -0
- data/app/helpers/protege/messages_helper.rb +152 -0
- data/app/helpers/protege/personas_helper.rb +90 -0
- data/app/helpers/protege/responsibilities_helper.rb +63 -0
- data/app/helpers/protege/threads_helper.rb +118 -0
- data/app/helpers/protege/trace_helper.rb +22 -0
- data/app/hooks/protege/event_logger_hook.rb +109 -0
- data/app/jobs/protege/application_job.rb +41 -0
- data/app/jobs/protege/console_inference_job.rb +57 -0
- data/app/jobs/protege/inference_job.rb +60 -0
- data/app/jobs/protege/mail_provisioning_job.rb +21 -0
- data/app/jobs/protege/responsibility_job.rb +70 -0
- data/app/jobs/protege/responsibility_tick_job.rb +19 -0
- data/app/mailboxes/protege/agent_mailbox.rb +123 -0
- data/app/mailers/protege/application_mailer.rb +65 -0
- data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
- data/app/models/concerns/protege/broadcastable_message.rb +47 -0
- data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
- data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
- data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
- data/app/models/concerns/protege/tool_scoped.rb +88 -0
- data/app/models/protege/access_rule.rb +50 -0
- data/app/models/protege/application_record.rb +18 -0
- data/app/models/protege/current.rb +23 -0
- data/app/models/protege/email_domain.rb +136 -0
- data/app/models/protege/email_thread.rb +81 -0
- data/app/models/protege/message.rb +401 -0
- data/app/models/protege/persona.rb +229 -0
- data/app/models/protege/responsibility.rb +98 -0
- data/app/models/protege/responsibility_run.rb +68 -0
- data/app/models/protege/tool_use.rb +115 -0
- data/app/models/protege/trace.rb +71 -0
- data/app/providers/protege/open_router_provider.rb +317 -0
- data/app/resolvers/README.md +255 -0
- data/app/resolvers/protege/load_file_resolver.rb +86 -0
- data/app/resolvers/protege/load_record_resolver.rb +80 -0
- data/app/resolvers/protege/load_text_resolver.rb +40 -0
- data/app/resolvers/protege/thread_history_resolver.rb +116 -0
- data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
- data/app/services/protege/message_search.rb +114 -0
- data/app/services/protege/trace_search.rb +102 -0
- data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
- data/app/tools/protege/create_file_tool.rb +109 -0
- data/app/tools/protege/read_attachment_tool.rb +101 -0
- data/app/tools/protege/search_emails_tool.rb +129 -0
- data/app/tools/protege/send_email_tool.rb +240 -0
- data/app/tools/protege/web_fetch_tool.rb +224 -0
- data/app/tools/protege/web_search_tool.rb +131 -0
- data/app/views/layouts/protege/application.html.slim +19 -0
- data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
- data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
- data/app/views/protege/email_domains/_form.html.slim +10 -0
- data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
- data/app/views/protege/email_domains/index.html.slim +6 -0
- data/app/views/protege/email_domains/new.html.slim +7 -0
- data/app/views/protege/email_domains/show.html.slim +36 -0
- data/app/views/protege/home/show.html.slim +26 -0
- data/app/views/protege/messages/_message.html.slim +19 -0
- data/app/views/protege/messages/_sidebar.html.slim +13 -0
- data/app/views/protege/messages/index.html.slim +20 -0
- data/app/views/protege/personas/_access_rule.html.slim +7 -0
- data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
- data/app/views/protege/personas/_access_rules.html.slim +19 -0
- data/app/views/protege/personas/_actions.html.slim +15 -0
- data/app/views/protege/personas/_form.html.slim +29 -0
- data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
- data/app/views/protege/personas/_sidebar.html.slim +11 -0
- data/app/views/protege/personas/edit.html.slim +7 -0
- data/app/views/protege/personas/index.html.slim +6 -0
- data/app/views/protege/personas/new.html.slim +7 -0
- data/app/views/protege/personas/show.html.slim +37 -0
- data/app/views/protege/responsibilities/_form.html.slim +21 -0
- data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
- data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
- data/app/views/protege/responsibilities/edit.html.slim +7 -0
- data/app/views/protege/responsibilities/index.html.slim +6 -0
- data/app/views/protege/responsibilities/new.html.slim +7 -0
- data/app/views/protege/responsibilities/show.html.slim +22 -0
- data/app/views/protege/responsibility_runs/show.html.slim +13 -0
- data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
- data/app/views/protege/shared/_header.html.slim +14 -0
- data/app/views/protege/shared/_hotkeys.html.slim +43 -0
- data/app/views/protege/threads/_composer_footer.html.slim +15 -0
- data/app/views/protege/threads/_introspection.html.slim +7 -0
- data/app/views/protege/threads/_reasoning.html.slim +4 -0
- data/app/views/protege/threads/_reply_form.html.slim +7 -0
- data/app/views/protege/threads/_run_marker.html.slim +3 -0
- data/app/views/protege/threads/_sidebar.html.slim +15 -0
- data/app/views/protege/threads/_thread.html.slim +6 -0
- data/app/views/protege/threads/_tool_call.html.slim +8 -0
- data/app/views/protege/threads/index.html.slim +6 -0
- data/app/views/protege/threads/new.html.slim +15 -0
- data/app/views/protege/threads/show.html.slim +25 -0
- data/app/views/protege/traces/_sidebar.html.slim +15 -0
- data/app/views/protege/traces/index.html.slim +11 -0
- data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
- data/app/views/protege/traces/searches/show.html.slim +22 -0
- data/app/views/protege/traces/show.html.slim +28 -0
- data/config/routes.rb +33 -0
- data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
- data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
- data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
- data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
- data/lib/generators/protege/extension_naming.rb +38 -0
- data/lib/generators/protege/hook/hook_generator.rb +33 -0
- data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
- data/lib/generators/protege/install/install_generator.rb +170 -0
- data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
- data/lib/generators/protege/persona/persona_generator.rb +35 -0
- data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
- data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
- data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
- data/lib/generators/protege/provider/provider_generator.rb +44 -0
- data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
- data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
- data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
- data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
- data/lib/generators/protege/tool/tool_generator.rb +34 -0
- data/lib/protege/configuration.rb +171 -0
- data/lib/protege/engine.rb +103 -0
- data/lib/protege/errors/bad_request_error.rb +8 -0
- data/lib/protege/errors/contract_violation_error.rb +8 -0
- data/lib/protege/errors/error.rb +30 -0
- data/lib/protege/errors/generic_error.rb +7 -0
- data/lib/protege/errors/internal_error.rb +8 -0
- data/lib/protege/errors/invalid_model_error.rb +8 -0
- data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
- data/lib/protege/errors/rate_limited_error.rb +8 -0
- data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
- data/lib/protege/errors/response_parse_failed_error.rb +8 -0
- data/lib/protege/errors/timeout_error.rb +8 -0
- data/lib/protege/errors/tool_not_available_error.rb +12 -0
- data/lib/protege/errors/tool_not_found_error.rb +9 -0
- data/lib/protege/errors/unauthorized_error.rb +8 -0
- data/lib/protege/errors/unavailable_error.rb +8 -0
- data/lib/protege/errors/unsupported_capability_error.rb +8 -0
- data/lib/protege/events/event.rb +98 -0
- data/lib/protege/events/inference_chunk_event.rb +17 -0
- data/lib/protege/events/inference_completed_event.rb +12 -0
- data/lib/protege/events/inference_failed_event.rb +12 -0
- data/lib/protege/events/inference_generated_event.rb +19 -0
- data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
- data/lib/protege/events/inference_started_event.rb +11 -0
- data/lib/protege/events/loop_run_completed_event.rb +11 -0
- data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
- data/lib/protege/events/loop_run_failed_event.rb +12 -0
- data/lib/protege/events/loop_run_started_event.rb +11 -0
- data/lib/protege/events/tool_call_completed_event.rb +12 -0
- data/lib/protege/events/tool_call_failed_event.rb +12 -0
- data/lib/protege/events/tool_call_started_event.rb +11 -0
- data/lib/protege/events/tool_calls_received_event.rb +11 -0
- data/lib/protege/extensions/hook.rb +13 -0
- data/lib/protege/extensions/hook_mixin.rb +90 -0
- data/lib/protege/extensions/provider.rb +12 -0
- data/lib/protege/extensions/provider_mixin.rb +124 -0
- data/lib/protege/extensions/resolver.rb +12 -0
- data/lib/protege/extensions/resolver_mixin.rb +58 -0
- data/lib/protege/extensions/tool.rb +12 -0
- data/lib/protege/extensions/tool_mixin.rb +217 -0
- data/lib/protege/gateway/access_control.rb +66 -0
- data/lib/protege/gateway/access_policy.rb +94 -0
- data/lib/protege/gateway/attachment_policy.rb +115 -0
- data/lib/protege/gateway/mail/address.rb +145 -0
- data/lib/protege/gateway/mail/attachment.rb +17 -0
- data/lib/protege/gateway/mail/message_id.rb +104 -0
- data/lib/protege/gateway/mail/outbound.rb +181 -0
- data/lib/protege/gateway/mail/references.rb +107 -0
- data/lib/protege/gateway.rb +398 -0
- data/lib/protege/inference/provider/request.rb +110 -0
- data/lib/protege/inference/provider/response.rb +50 -0
- data/lib/protege/inference/provider/tool_call.rb +52 -0
- data/lib/protege/inference.rb +121 -0
- data/lib/protege/loop/schedule.rb +66 -0
- data/lib/protege/loop/scheduler.rb +42 -0
- data/lib/protege/loop.rb +37 -0
- data/lib/protege/orchestrator/context.rb +65 -0
- data/lib/protege/orchestrator/harness.rb +305 -0
- data/lib/protege/orchestrator/reply_context.rb +21 -0
- data/lib/protege/orchestrator/reply_harness.rb +68 -0
- data/lib/protege/orchestrator/resolver_chain.rb +93 -0
- data/lib/protege/orchestrator/responsibility_context.rb +22 -0
- data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
- data/lib/protege/orchestrator.rb +37 -0
- data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
- data/lib/protege/subscribers/tracing.rb +65 -0
- data/lib/protege/types/document_part.rb +13 -0
- data/lib/protege/types/image_part.rb +11 -0
- data/lib/protege/types/model_message.rb +40 -0
- data/lib/protege/types/result.rb +113 -0
- data/lib/protege/types/stored_file.rb +127 -0
- data/lib/protege/types/text_part.rb +8 -0
- data/lib/protege/types/tool_result.rb +16 -0
- data/lib/protege/version.rb +6 -0
- data/lib/protege.rb +47 -0
- data/lib/tasks/protege_tasks.rake +16 -0
- 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"]
|