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,182 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Provision the Protege mail accessory's OpenDKIM/Postfix config from a domain manifest.
|
|
3
|
+
|
|
4
|
+
The Rails app PUSHES the full domain manifest (every EmailDomain + its DKIM key) to this listener
|
|
5
|
+
whenever a domain changes — see Protege::Gateway.push_mail_provisioning. We rewrite the OpenDKIM key
|
|
6
|
+
tables and the Postfix transport/relay_domains from it, then reload. The same apply() runs once at
|
|
7
|
+
container boot from the cached manifest, so a restart self-heals without waiting for a push.
|
|
8
|
+
|
|
9
|
+
Auth reuses RAILS_INBOUND_EMAIL_PASSWORD (the same shared secret the inbound relay uses). The listener
|
|
10
|
+
binds the internal Kamal network only and is never published to the host — port 25 is the only exposed
|
|
11
|
+
port. This keeps the private keys on the internal hop, same trust boundary as the plaintext SMTP relay.
|
|
12
|
+
"""
|
|
13
|
+
import base64
|
|
14
|
+
import grp
|
|
15
|
+
import hmac
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import pwd
|
|
19
|
+
import signal
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
23
|
+
|
|
24
|
+
DATA_DIR = "/var/lib/protege-mail"
|
|
25
|
+
KEYS_DIR = os.path.join(DATA_DIR, "keys")
|
|
26
|
+
MANIFEST_PATH = os.path.join(DATA_DIR, "manifest.json")
|
|
27
|
+
KEY_TABLE = "/etc/opendkim/KeyTable"
|
|
28
|
+
SIGNING_TABLE = "/etc/opendkim/SigningTable"
|
|
29
|
+
TRANSPORT = "/etc/postfix/transport"
|
|
30
|
+
OPENDKIM_PID = "/run/opendkim/opendkim.pid"
|
|
31
|
+
HEADER = "# generated at runtime by provision.py — do not edit\n"
|
|
32
|
+
PORT = int(os.environ.get("MAIL_PROVISION_PORT", "8025"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _opendkim_ids():
|
|
36
|
+
"""The opendkim uid/gid so written keys/tables are readable by the daemon, or (None, None)."""
|
|
37
|
+
try:
|
|
38
|
+
return pwd.getpwnam("opendkim").pw_uid, grp.getgrnam("opendkim").gr_gid
|
|
39
|
+
except KeyError:
|
|
40
|
+
return None, None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def apply(manifest):
|
|
44
|
+
"""Rewrite key files + OpenDKIM/Postfix tables from the manifest, then reload (best-effort)."""
|
|
45
|
+
domains = manifest.get("domains", [])
|
|
46
|
+
uid, gid = _opendkim_ids()
|
|
47
|
+
os.makedirs(KEYS_DIR, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
key_lines, signing_lines, transport_lines, relay_domains = [], [], [], []
|
|
50
|
+
for entry in domains:
|
|
51
|
+
domain = entry["domain"]
|
|
52
|
+
selector = entry.get("selector") or "default"
|
|
53
|
+
pem = entry["private_key_pem"]
|
|
54
|
+
|
|
55
|
+
key_dir = os.path.join(KEYS_DIR, domain)
|
|
56
|
+
os.makedirs(key_dir, exist_ok=True)
|
|
57
|
+
key_path = os.path.join(key_dir, selector + ".private")
|
|
58
|
+
with open(key_path, "w") as handle:
|
|
59
|
+
handle.write(pem if pem.endswith("\n") else pem + "\n")
|
|
60
|
+
os.chmod(key_path, 0o600)
|
|
61
|
+
if uid is not None:
|
|
62
|
+
os.chown(key_path, uid, gid)
|
|
63
|
+
os.chown(key_dir, uid, gid)
|
|
64
|
+
|
|
65
|
+
key_lines.append("%s._domainkey.%s %s:%s:%s" % (selector, domain, domain, selector, key_path))
|
|
66
|
+
signing_lines.append("*@%s %s._domainkey.%s" % (domain, selector, domain))
|
|
67
|
+
transport_lines.append("%s actionmailbox:" % domain)
|
|
68
|
+
relay_domains.append(domain)
|
|
69
|
+
|
|
70
|
+
_write(KEY_TABLE, key_lines, uid, gid)
|
|
71
|
+
_write(SIGNING_TABLE, signing_lines, uid, gid)
|
|
72
|
+
_write(TRANSPORT, transport_lines, None, None)
|
|
73
|
+
|
|
74
|
+
_run(["postmap", TRANSPORT])
|
|
75
|
+
_run(["postconf", "-e", "relay_domains=" + " ".join(relay_domains)])
|
|
76
|
+
_reload()
|
|
77
|
+
_cache(manifest)
|
|
78
|
+
return len(domains)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _write(path, lines, uid, gid):
|
|
82
|
+
with open(path, "w") as handle:
|
|
83
|
+
handle.write(HEADER)
|
|
84
|
+
for line in lines:
|
|
85
|
+
handle.write(line + "\n")
|
|
86
|
+
if uid is not None:
|
|
87
|
+
try:
|
|
88
|
+
os.chown(path, uid, gid)
|
|
89
|
+
except OSError:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _run(cmd):
|
|
94
|
+
"""Run a command, swallowing failures — reloads are best-effort (a service may not be up yet)."""
|
|
95
|
+
try:
|
|
96
|
+
subprocess.run(cmd, check=True)
|
|
97
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _reload():
|
|
102
|
+
_run(["postfix", "reload"])
|
|
103
|
+
try:
|
|
104
|
+
with open(OPENDKIM_PID) as handle:
|
|
105
|
+
os.kill(int(handle.read().strip()), signal.SIGUSR1)
|
|
106
|
+
except (OSError, ValueError):
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _cache(manifest):
|
|
111
|
+
os.makedirs(DATA_DIR, exist_ok=True)
|
|
112
|
+
with open(MANIFEST_PATH, "w") as handle:
|
|
113
|
+
json.dump(manifest, handle)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _load_cached():
|
|
117
|
+
try:
|
|
118
|
+
with open(MANIFEST_PATH) as handle:
|
|
119
|
+
return json.load(handle)
|
|
120
|
+
except (OSError, ValueError):
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Handler(BaseHTTPRequestHandler):
|
|
125
|
+
def do_POST(self):
|
|
126
|
+
if self.path != "/provision":
|
|
127
|
+
return self._send(404, {"error": "not found"})
|
|
128
|
+
if not self._authorized():
|
|
129
|
+
self.send_response(401)
|
|
130
|
+
self.send_header("WWW-Authenticate", 'Basic realm="protege-mail"')
|
|
131
|
+
self.end_headers()
|
|
132
|
+
return
|
|
133
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
134
|
+
try:
|
|
135
|
+
manifest = json.loads(self.rfile.read(length) or b"{}")
|
|
136
|
+
count = apply(manifest)
|
|
137
|
+
except (ValueError, KeyError) as error:
|
|
138
|
+
return self._send(400, {"error": str(error)})
|
|
139
|
+
self._send(200, {"ok": True, "domains": count})
|
|
140
|
+
|
|
141
|
+
def _authorized(self):
|
|
142
|
+
expected = os.environ.get("RAILS_INBOUND_EMAIL_PASSWORD", "")
|
|
143
|
+
header = self.headers.get("Authorization", "")
|
|
144
|
+
if not expected or not header.startswith("Basic "):
|
|
145
|
+
return False
|
|
146
|
+
try:
|
|
147
|
+
user, _, password = base64.b64decode(header[6:]).decode().partition(":")
|
|
148
|
+
except (ValueError, UnicodeDecodeError):
|
|
149
|
+
return False
|
|
150
|
+
return user == "provision" and hmac.compare_digest(password, expected)
|
|
151
|
+
|
|
152
|
+
def _send(self, status, body):
|
|
153
|
+
payload = json.dumps(body).encode()
|
|
154
|
+
self.send_response(status)
|
|
155
|
+
self.send_header("Content-Type", "application/json")
|
|
156
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
157
|
+
self.end_headers()
|
|
158
|
+
self.wfile.write(payload)
|
|
159
|
+
|
|
160
|
+
def log_message(self, *args):
|
|
161
|
+
pass # keep the accessory log focused on mail
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def serve():
|
|
165
|
+
HTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main(argv):
|
|
169
|
+
command = argv[1] if len(argv) > 1 else "serve"
|
|
170
|
+
if command == "serve":
|
|
171
|
+
serve()
|
|
172
|
+
elif command == "apply":
|
|
173
|
+
manifest = _load_cached()
|
|
174
|
+
if manifest is not None:
|
|
175
|
+
apply(manifest)
|
|
176
|
+
else:
|
|
177
|
+
sys.stderr.write("usage: provision.py [serve|apply]\n")
|
|
178
|
+
sys.exit(2)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
main(sys.argv)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Postfix pipe transport: forward one inbound message to the Rails relay ingress.
|
|
3
|
+
#
|
|
4
|
+
# Postfix feeds the raw RFC822 message on stdin (see the actionmailbox service in master.cf). We POST it
|
|
5
|
+
# verbatim to Action Mailbox's relay endpoint with HTTP Basic auth (user "actionmailbox", password =
|
|
6
|
+
# the ingress password). On any failure we exit EX_TEMPFAIL (75) so Postfix keeps the message queued and
|
|
7
|
+
# retries, rather than bouncing mail the app briefly couldn't accept.
|
|
8
|
+
set -eu
|
|
9
|
+
|
|
10
|
+
# Postfix's pipe daemon scrubs the environment, so the entrypoint stashed the relay credentials in a
|
|
11
|
+
# file (readable only by this pipe user). Load them here.
|
|
12
|
+
if [ -r /etc/protege-mail/relay.env ]; then
|
|
13
|
+
. /etc/protege-mail/relay.env
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
: "${APP_RELAY_URL:?APP_RELAY_URL not set}"
|
|
17
|
+
: "${RAILS_INBOUND_EMAIL_PASSWORD:?RAILS_INBOUND_EMAIL_PASSWORD not set}"
|
|
18
|
+
|
|
19
|
+
if curl --fail --silent --show-error \
|
|
20
|
+
--user "actionmailbox:${RAILS_INBOUND_EMAIL_PASSWORD}" \
|
|
21
|
+
--header "Content-Type: message/rfc822" \
|
|
22
|
+
--data-binary @- \
|
|
23
|
+
"${APP_RELAY_URL}"; then
|
|
24
|
+
exit 0
|
|
25
|
+
else
|
|
26
|
+
echo "relay-to-app: POST to ${APP_RELAY_URL} failed; deferring for retry" >&2
|
|
27
|
+
exit 75 # EX_TEMPFAIL
|
|
28
|
+
fi
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Builds the self-hosted mail image (Postfix + OpenDKIM) and pushes it to your registry. Kamal
|
|
2
|
+
# accessories pull a prebuilt image rather than building one, so this runs separately from your
|
|
3
|
+
# deploy: on changes to deploy/mail/**, or on demand. After it publishes, roll it out with
|
|
4
|
+
# `kamal accessory reboot mail`.
|
|
5
|
+
#
|
|
6
|
+
# To use: rename to build-mail-image.yml and adjust the registry/paths for your setup. As written it
|
|
7
|
+
# publishes to GHCR under your org (ghcr.io/<owner>/protege-mail) using the built-in GITHUB_TOKEN.
|
|
8
|
+
name: Build mail image
|
|
9
|
+
|
|
10
|
+
on:
|
|
11
|
+
push:
|
|
12
|
+
branches: [main]
|
|
13
|
+
paths:
|
|
14
|
+
- 'deploy/mail/**'
|
|
15
|
+
- '.github/workflows/build-mail-image.yml'
|
|
16
|
+
workflow_dispatch:
|
|
17
|
+
|
|
18
|
+
concurrency:
|
|
19
|
+
group: build-mail-image
|
|
20
|
+
cancel-in-progress: true
|
|
21
|
+
|
|
22
|
+
jobs:
|
|
23
|
+
build:
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
permissions:
|
|
26
|
+
contents: read
|
|
27
|
+
packages: write
|
|
28
|
+
steps:
|
|
29
|
+
- name: Checkout
|
|
30
|
+
uses: actions/checkout@v4
|
|
31
|
+
|
|
32
|
+
- name: Set up Docker Buildx
|
|
33
|
+
uses: docker/setup-buildx-action@v3
|
|
34
|
+
|
|
35
|
+
- name: Log in to GHCR
|
|
36
|
+
uses: docker/login-action@v3
|
|
37
|
+
with:
|
|
38
|
+
registry: ghcr.io
|
|
39
|
+
username: ${{ github.actor }}
|
|
40
|
+
password: ${{ secrets.GITHUB_TOKEN }}
|
|
41
|
+
|
|
42
|
+
- name: Build and push
|
|
43
|
+
uses: docker/build-push-action@v6
|
|
44
|
+
with:
|
|
45
|
+
context: deploy/mail
|
|
46
|
+
platforms: linux/amd64
|
|
47
|
+
push: true
|
|
48
|
+
tags: |
|
|
49
|
+
ghcr.io/${{ github.repository_owner }}/protege-mail:latest
|
|
50
|
+
ghcr.io/${{ github.repository_owner }}/protege-mail:${{ github.sha }}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators/named_base'
|
|
4
|
+
require_relative '../extension_naming'
|
|
5
|
+
|
|
6
|
+
module Protege
|
|
7
|
+
module Generators
|
|
8
|
+
# Scaffolds an inference provider — the adapter between Protege and an LLM backend — into the host
|
|
9
|
+
# app's providers directory (+config.providers_path+, default +app/providers+). A provider
|
|
10
|
+
# subclasses +Protege::Provider+, declares its +protege_id+, and implements +generate+ (hash in,
|
|
11
|
+
# hash out). It is auto-discovered; activate it by pointing +config.provider_id+ at its id and
|
|
12
|
+
# putting its credentials under that key in +config.providers+.
|
|
13
|
+
#
|
|
14
|
+
# The +Provider+ suffix is applied for you, and the +protege_id+ is derived from the name.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# bin/rails g protege:provider my_llm
|
|
18
|
+
class ProviderGenerator < Rails::Generators::NamedBase
|
|
19
|
+
include ExtensionNaming
|
|
20
|
+
|
|
21
|
+
source_root File.expand_path('templates', __dir__)
|
|
22
|
+
|
|
23
|
+
desc 'Scaffold a Protege::Provider subclass in the configured providers path (default app/providers).'
|
|
24
|
+
|
|
25
|
+
# Render the provider scaffold into +config.providers_path+.
|
|
26
|
+
#
|
|
27
|
+
# @return [void]
|
|
28
|
+
def create_provider
|
|
29
|
+
template 'provider.rb.tt',
|
|
30
|
+
File.join(Protege.configuration.providers_path, "#{extension_file_name('Provider')}.rb")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# The symbolic +protege_id+ for this provider — the file name with the +provider+ suffix stripped
|
|
36
|
+
# (e.g. +my_llm_provider+ → +:my_llm+). This is the id the host references from +config.provider_id+.
|
|
37
|
+
#
|
|
38
|
+
# @return [String] the snake_cased id
|
|
39
|
+
def protege_id
|
|
40
|
+
extension_file_name('Provider').sub(/_provider\z/, '')
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# <%= extension_class_name('Provider') %> — an inference provider: the adapter between Protege and an LLM backend.
|
|
4
|
+
#
|
|
5
|
+
# Subclassing +Protege::Provider+ registers it automatically. Activate it by pointing the engine at its
|
|
6
|
+
# id and giving it credentials in the Protege initializer:
|
|
7
|
+
#
|
|
8
|
+
# config.provider_id = :<%= protege_id %>
|
|
9
|
+
# config.providers = { <%= protege_id %>: { api_key: ENV['<%= protege_id.upcase %>_API_KEY'], model: '...' } }
|
|
10
|
+
#
|
|
11
|
+
# The harness drives one method: +#generate+. It takes an OpenAI-compatible request Hash
|
|
12
|
+
# (+{ messages:, tools: }+) and returns a response Hash (+{ content:, tool_calls:, finish_reason: }+),
|
|
13
|
+
# raising a +Protege::Error+ on transport/auth/parse failure. Read this provider's own settings from
|
|
14
|
+
# its config slice via +options+ below. See +Protege::OpenRouterProvider+ for a complete reference
|
|
15
|
+
# implementation (streaming, error mapping, tool-call normalization).
|
|
16
|
+
class <%= extension_class_name('Provider') %> < Protege::Provider
|
|
17
|
+
protege_id :<%= protege_id %>
|
|
18
|
+
|
|
19
|
+
# Run a blocking, non-streaming completion.
|
|
20
|
+
#
|
|
21
|
+
# @param request [Hash] the request hash — +:messages+ and +:tools+ in OpenAI chat-completions shape
|
|
22
|
+
# @return [Hash] the response hash: +{ content:, tool_calls:, finish_reason: }+
|
|
23
|
+
# @raise [Protege::Error] on a transport, auth, or parse failure
|
|
24
|
+
def generate(request)
|
|
25
|
+
# TODO: call your backend with `request` (merge in `model` and `settings`) and map the reply into
|
|
26
|
+
# the { content:, tool_calls:, finish_reason: } shape Protege expects.
|
|
27
|
+
raise NotImplementedError, '<%= extension_class_name('Provider') %>#generate is not implemented yet'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Optional: override to stream tokens as they arrive. The default (from +Protege::Provider+) calls
|
|
31
|
+
# +generate+ and yields its whole text as one chunk, which is fine until you need real streaming.
|
|
32
|
+
#
|
|
33
|
+
# @param request [Hash] the request hash (see {#generate})
|
|
34
|
+
# @yieldparam chunk [Hash] a +{ type: :text, content: String }+ token chunk
|
|
35
|
+
# @return [Hash] the final assembled response hash
|
|
36
|
+
# def generate_stream(request, &block)
|
|
37
|
+
# super
|
|
38
|
+
# end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# This provider's slice of +config.providers+, keyed by its own +protege_id+ (+:<%= protege_id %>+).
|
|
43
|
+
# Empty when the host hasn't configured it. Read credentials/model/sampling from here — never ENV
|
|
44
|
+
# directly, so all configuration flows through the initializer.
|
|
45
|
+
#
|
|
46
|
+
# @return [Hash] this provider's configured options (or +{}+)
|
|
47
|
+
def options
|
|
48
|
+
Protege.configuration.provider_options(self.class.id)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators/named_base'
|
|
4
|
+
require_relative '../extension_naming'
|
|
5
|
+
|
|
6
|
+
module Protege
|
|
7
|
+
module Generators
|
|
8
|
+
# Scaffolds a Protege resolver — one link in a persona's context-building chain — into the host
|
|
9
|
+
# app's resolvers directory (+config.resolvers_path+, default +app/resolvers+). A resolver
|
|
10
|
+
# subclasses +Protege::Resolver+ and implements +resolve+, contributing zero or more
|
|
11
|
+
# +Protege::ModelMessage+s to the prompt the harness sends the model.
|
|
12
|
+
#
|
|
13
|
+
# Unlike tools, resolvers are NOT auto-discovered: add the class to a persona's chain
|
|
14
|
+
# (+chain.use MyResolver+) for it to run. The +Resolver+ suffix is applied for you.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# bin/rails g protege:resolver thread_summary
|
|
18
|
+
class ResolverGenerator < Rails::Generators::NamedBase
|
|
19
|
+
include ExtensionNaming
|
|
20
|
+
|
|
21
|
+
source_root File.expand_path('templates', __dir__)
|
|
22
|
+
|
|
23
|
+
desc 'Scaffold a Protege::Resolver subclass in the configured resolvers path (default app/resolvers).'
|
|
24
|
+
|
|
25
|
+
# Render the resolver scaffold into +config.resolvers_path+.
|
|
26
|
+
#
|
|
27
|
+
# @return [void]
|
|
28
|
+
def create_resolver
|
|
29
|
+
template 'resolver.rb.tt',
|
|
30
|
+
File.join(Protege.configuration.resolvers_path, "#{extension_file_name('Resolver')}.rb")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# <%= extension_class_name('Resolver') %> — contributes context to the prompt before the model runs.
|
|
4
|
+
#
|
|
5
|
+
# A resolver is one link in a persona's chain. Subclass +Protege::Resolver+, implement +#resolve+, and
|
|
6
|
+
# add it to a chain to activate it (resolvers are NOT auto-discovered):
|
|
7
|
+
#
|
|
8
|
+
# # in app/personas/your_persona.rb
|
|
9
|
+
# message_resolvers { |chain| chain.use <%= extension_class_name('Resolver') %> } # reply runs
|
|
10
|
+
# responsibility_resolvers { |chain| chain.use <%= extension_class_name('Resolver') %> } # scheduled runs
|
|
11
|
+
#
|
|
12
|
+
# +#resolve+ returns one +Protege::ModelMessage+, an Array of them, or +nil+ to contribute nothing.
|
|
13
|
+
# Build messages with the +message+ helper — never construct +ModelMessage+ by hand.
|
|
14
|
+
class <%= extension_class_name('Resolver') %> < Protege::Resolver
|
|
15
|
+
# Contribute this resolver's slice of context.
|
|
16
|
+
#
|
|
17
|
+
# The +context+ tells you which kind of run this is (read it, never mutate it — it is frozen):
|
|
18
|
+
# - reply run: +context.message+ is the inbound +Protege::Message+ (and +context.message.email_thread+)
|
|
19
|
+
# - scheduled run: +context.responsibility+ is the +Protege::Responsibility+; +context.message+ is nil
|
|
20
|
+
# Both always expose +context.persona+, +context.config+, and +context.logger+.
|
|
21
|
+
#
|
|
22
|
+
# Pick a +role+ for what you contribute: +:system+ for standing instructions/facts, +:user+ or
|
|
23
|
+
# +:assistant+ for conversation turns, +:tool+ for a replayed tool result.
|
|
24
|
+
#
|
|
25
|
+
# @param context [Protege::Orchestrator::Context] the frozen run context
|
|
26
|
+
# @return [Protege::ModelMessage, Array<Protege::ModelMessage>, nil] the contribution, or nil for none
|
|
27
|
+
def resolve(context:)
|
|
28
|
+
# TODO: build and return your context. Return nil when there is nothing to add.
|
|
29
|
+
message(role: :system, content: "TODO: context for #{context.persona.name}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# <%= extension_class_name('Tool') %> — a capability the agent can call mid-conversation.
|
|
4
|
+
#
|
|
5
|
+
# Subclassing +Protege::Tool+ registers this tool automatically (no wiring): the harness publishes it
|
|
6
|
+
# in the model's tool catalog and routes matching tool calls to +#use+. The tool's id is derived from
|
|
7
|
+
# this class name with the +Tool+ suffix stripped and snake_cased — here +:<%= extension_file_name('Tool').sub(/_tool\z/, '') %>+.
|
|
8
|
+
#
|
|
9
|
+
# Fill in the three things the model relies on:
|
|
10
|
+
# 1. +description+ — WHEN to reach for this tool and WHAT it does, written for the model to read.
|
|
11
|
+
# 2. +input_schema+ — the JSON Schema for the arguments; the model must produce arguments matching it.
|
|
12
|
+
# 3. +#use+ — the behaviour, returning a +Protege::Result+.
|
|
13
|
+
class <%= extension_class_name('Tool') %> < Protege::Tool
|
|
14
|
+
# Shown to the model as this tool's contract. Be concrete about when it should (and should not) be
|
|
15
|
+
# used — this is the model's only guidance for choosing it.
|
|
16
|
+
description <<~DESC.strip
|
|
17
|
+
TODO: Describe what this tool does and when the agent should call it. Written for the model.
|
|
18
|
+
DESC
|
|
19
|
+
|
|
20
|
+
# The JSON Schema the model's arguments must satisfy. Keys here arrive as symbol keyword arguments to
|
|
21
|
+
# +#use+. Keep it tight — +additionalProperties: false+ and an explicit +required+ list steer the
|
|
22
|
+
# model toward valid calls.
|
|
23
|
+
input_schema(
|
|
24
|
+
type: 'object',
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
required: %w[example],
|
|
27
|
+
properties: {
|
|
28
|
+
example: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'TODO: describe this argument for the model.'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Run the tool. Called by the harness with the run +context+ and the model's parsed arguments as
|
|
36
|
+
# keyword arguments matching {input_schema}.
|
|
37
|
+
#
|
|
38
|
+
# Return a +Protege::Result+: +success(**data)+ with any data to hand back to the model, or
|
|
39
|
+
# +failure(reason:)+ with a short explanation it can act on. A raised error is caught and reported to
|
|
40
|
+
# the model as a failure, so raise freely for the genuinely exceptional.
|
|
41
|
+
#
|
|
42
|
+
# @param context [Protege::Orchestrator::Context] the run context — +context.message+ on a reply run
|
|
43
|
+
# (and its +email_thread+), +context.responsibility+ on a scheduled run, plus +persona+/+logger+
|
|
44
|
+
# @param example [String] the example argument declared in {input_schema} (rename to your own)
|
|
45
|
+
# @return [Protege::Result] the outcome handed back to the model
|
|
46
|
+
def use(context:, example:)
|
|
47
|
+
# TODO: implement. `context` gives you the persona and the current message/thread or responsibility.
|
|
48
|
+
success(echo: example)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators/named_base'
|
|
4
|
+
require_relative '../extension_naming'
|
|
5
|
+
|
|
6
|
+
module Protege
|
|
7
|
+
module Generators
|
|
8
|
+
# Scaffolds a Protege tool — a capability the agent can invoke mid-conversation — into the host
|
|
9
|
+
# app's tools directory (+config.tools_path+, default +app/tools+). A tool subclasses
|
|
10
|
+
# +Protege::Tool+, declares its +description+ and JSON +input_schema+ for the model, and implements
|
|
11
|
+
# +use+; the harness auto-discovers it (no manual registration) and publishes it in the LLM's tool
|
|
12
|
+
# catalog.
|
|
13
|
+
#
|
|
14
|
+
# The +Tool+ suffix is applied for you, so all of +send_email+, +SendEmail+, and +SendEmailTool+
|
|
15
|
+
# produce class +SendEmailTool+ in +send_email_tool.rb+.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# bin/rails g protege:tool send_email
|
|
19
|
+
class ToolGenerator < Rails::Generators::NamedBase
|
|
20
|
+
include ExtensionNaming
|
|
21
|
+
|
|
22
|
+
source_root File.expand_path('templates', __dir__)
|
|
23
|
+
|
|
24
|
+
desc 'Scaffold a Protege::Tool subclass in the configured tools path (default app/tools).'
|
|
25
|
+
|
|
26
|
+
# Render the tool scaffold into +config.tools_path+.
|
|
27
|
+
#
|
|
28
|
+
# @return [void]
|
|
29
|
+
def create_tool
|
|
30
|
+
template 'tool.rb.tt', File.join(Protege.configuration.tools_path, "#{extension_file_name('Tool')}.rb")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|