msgctl 0.1.0a1__py3-none-any.whl
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.
- msg/__init__.py +2 -0
- msg/admin/__init__.py +0 -0
- msg/admin/backup_retirement.py +56 -0
- msg/admin/backups.py +341 -0
- msg/admin/custodial_check.py +77 -0
- msg/admin/diagnostics.py +750 -0
- msg/admin/market.py +91 -0
- msg/admin/market_check.py +341 -0
- msg/admin/money.py +322 -0
- msg/admin/preflight.py +142 -0
- msg/admin/recovery_replay.py +281 -0
- msg/admin/restore_database.py +37 -0
- msg/admin/root.py +347 -0
- msg/admin/rotation.py +197 -0
- msg/admin/token_delivery_check.py +54 -0
- msg/admin/upgrade_check.py +58 -0
- msg/application.py +163 -0
- msg/bootstrap.py +305 -0
- msg/cli.py +537 -0
- msg/client.py +725 -0
- msg/client_certificates.py +98 -0
- msg/client_content.py +43 -0
- msg/client_custodial.py +206 -0
- msg/client_market.py +104 -0
- msg/client_recovery.py +186 -0
- msg/client_secrets.py +58 -0
- msg/client_tokens.py +99 -0
- msg/client_upgrade.py +188 -0
- msg/config.py +318 -0
- msg/constants.py +11 -0
- msg/core/__init__.py +0 -0
- msg/core/batching.py +23 -0
- msg/core/codec.py +253 -0
- msg/core/contracts.py +128 -0
- msg/core/cursors.py +68 -0
- msg/core/email_address.py +33 -0
- msg/core/errors.py +22 -0
- msg/core/events.py +6 -0
- msg/core/execution_ports.py +20 -0
- msg/core/executor.py +193 -0
- msg/core/models.py +517 -0
- msg/core/packet.py +71 -0
- msg/core/permissions.py +14 -0
- msg/core/query.py +44 -0
- msg/core/read_query.py +60 -0
- msg/core/registry.py +173 -0
- msg/core/requests.py +58 -0
- msg/core/schema_policy.py +28 -0
- msg/core/schemas.py +30 -0
- msg/core/tags.py +21 -0
- msg/core/template_dsl.py +94 -0
- msg/core/text_patch.py +283 -0
- msg/core/tool_execution.py +28 -0
- msg/core/transfer.py +183 -0
- msg/daemon.py +255 -0
- msg/data/__init__.py +0 -0
- msg/data/bootstrap.json +72 -0
- msg/data/favicon.png +0 -0
- msg/data/logo-dark.svg +5 -0
- msg/data/logo.svg +5 -0
- msg/data/recovery-checkpoint.example.json +1 -0
- msg/data/recovery-checkpoint.schema.json +118 -0
- msg/data/shortcodes.json +1 -0
- msg/data/system/AGENTS.md +6 -0
- msg/data/system/rules/_index.md +17 -0
- msg/data/system/rules/auth.md +6 -0
- msg/data/system/rules/files.md +6 -0
- msg/data/system/rules/identity.md +6 -0
- msg/data/system/rules/protocol.md +6 -0
- msg/data/system/rules/read-write.md +6 -0
- msg/data/system/rules/recovery.md +6 -0
- msg/data/system/rules/security.md +10 -0
- msg/data/system/rules/topics.md +6 -0
- msg/extensions/__init__.py +1 -0
- msg/extensions/hosting.py +259 -0
- msg/extensions/keystore.py +96 -0
- msg/extensions/repositories.py +769 -0
- msg/extensions/rss.py +67 -0
- msg/extensions/ssh.py +264 -0
- msg/extensions/ssh_git.py +300 -0
- msg/extensions/tools.py +150 -0
- msg/hosting_runtime.py +138 -0
- msg/market/__init__.py +1 -0
- msg/market/arbitration.py +481 -0
- msg/market/delivery.py +338 -0
- msg/market/delivery_notifications.py +110 -0
- msg/market/delivery_targets.py +76 -0
- msg/market/email.py +100 -0
- msg/market/escrow.py +345 -0
- msg/market/orders.py +218 -0
- msg/market/policy.py +147 -0
- msg/market/rationale.py +91 -0
- msg/market/references.py +19 -0
- msg/market/targets.py +157 -0
- msg/plugins/__init__.py +28 -0
- msg/plugins/achievements.py +316 -0
- msg/plugins/batch.py +32 -0
- msg/plugins/bounty.py +366 -0
- msg/plugins/collaboration.py +261 -0
- msg/plugins/common.py +217 -0
- msg/plugins/communication.py +853 -0
- msg/plugins/content.py +855 -0
- msg/plugins/custodial_lifecycle.py +224 -0
- msg/plugins/delivery.py +230 -0
- msg/plugins/discovery.py +1266 -0
- msg/plugins/discussion.py +162 -0
- msg/plugins/extensions.py +10 -0
- msg/plugins/following.py +78 -0
- msg/plugins/hosting_capacity.py +52 -0
- msg/plugins/identity.py +1654 -0
- msg/plugins/money.py +221 -0
- msg/plugins/offers.py +183 -0
- msg/plugins/orders.py +273 -0
- msg/plugins/recovery.py +376 -0
- msg/plugins/schemas.py +5 -0
- msg/plugins/sharing.py +300 -0
- msg/plugins/store.py +249 -0
- msg/plugins/system.py +75 -0
- msg/plugins/transfer.py +249 -0
- msg/py.typed +0 -0
- msg/security/__init__.py +0 -0
- msg/security/age_keys.py +112 -0
- msg/security/authentication.py +141 -0
- msg/security/authorization.py +300 -0
- msg/security/backup_retirement.py +135 -0
- msg/security/capabilities.py +157 -0
- msg/security/certificates.py +183 -0
- msg/security/crypto.py +102 -0
- msg/security/custodial_migration.py +292 -0
- msg/security/custody_history.py +146 -0
- msg/security/network.py +81 -0
- msg/security/policy.py +67 -0
- msg/security/quarantine.py +19 -0
- msg/security/root_files.py +67 -0
- msg/security/rotation_journal.py +60 -0
- msg/security/sealed_box.py +70 -0
- msg/security/sharing_policy.py +29 -0
- msg/security/token_delivery.py +93 -0
- msg/security/vault.py +186 -0
- msg/storage/__init__.py +0 -0
- msg/storage/capacity.py +100 -0
- msg/storage/custodial_migration.py +22 -0
- msg/storage/git.py +435 -0
- msg/storage/ledger_migration.py +307 -0
- msg/storage/market_migration.py +49 -0
- msg/storage/postgres.py +663 -0
- msg/storage/query.py +39 -0
- msg/storage/read_only.py +126 -0
- msg/storage/session.py +403 -0
- msg/storage/sqlite.py +360 -0
- msg/storage/topic_event_migration.py +26 -0
- msg/storage/valkey_bus.py +45 -0
- msg/transports/__init__.py +0 -0
- msg/transports/client.py +183 -0
- msg/transports/dictionary.py +387 -0
- msg/transports/graphql.py +75 -0
- msg/transports/http.py +72 -0
- msg/transports/http_routes.py +1577 -0
- msg/transports/mcp.py +127 -0
- msg/transports/packet.py +42 -0
- msg/transports/read_tree_path.py +65 -0
- msg/transports/stdio.py +36 -0
- msg/transports/url_safety.py +123 -0
- msg/tui.py +368 -0
- msg/workers/__init__.py +1 -0
- msg/workers/effects.py +406 -0
- msg/workers/leases.py +19 -0
- msg/workers/mail.py +63 -0
- msg/workers/maintenance.py +310 -0
- msg/workers/sandbox.py +74 -0
- msg/workers/sandbox_child.py +189 -0
- msg/workers/webhook.py +160 -0
- msgctl-0.1.0a1.dist-info/METADATA +96 -0
- msgctl-0.1.0a1.dist-info/RECORD +176 -0
- msgctl-0.1.0a1.dist-info/WHEEL +4 -0
- msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
msg/workers/effects.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Durable external effects with leases, current authority and honest uncertainty.
|
|
2
|
+
|
|
3
|
+
Claiming a job is transactional; executing a network request is not. An expired
|
|
4
|
+
running lease is uncertain, never silently put back on the queue. The injected
|
|
5
|
+
runner is a test/installation boundary, not an alternative authentication path.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
from datetime import timedelta
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from msg.constants import ROOT_SUBJECT
|
|
17
|
+
from msg.core.codec import decode, wire
|
|
18
|
+
from msg.core.errors import Failure, require
|
|
19
|
+
from msg.security.quarantine import active as quarantine_active
|
|
20
|
+
from msg.core.models import (
|
|
21
|
+
CapabilityGrant, EffectJob, EmailSettings, ExecutionContext, Principal, ResourceRef,
|
|
22
|
+
)
|
|
23
|
+
from msg.core.requests import request_for
|
|
24
|
+
from msg.core.tool_execution import ToolResult, ToolRunner
|
|
25
|
+
from msg.plugins.common import check_access, create_resource
|
|
26
|
+
from msg.security.policy import scope_subset
|
|
27
|
+
from msg.security.network import intersect_policy
|
|
28
|
+
from msg.workers.leases import current_attempt
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def current_principal(app, original: Principal, tx) -> Principal:
|
|
32
|
+
"""Intersect the captured credential with its *current* authority ceiling."""
|
|
33
|
+
require(original.method not in {'anonymous', 'local'}, 'worker_identity_required')
|
|
34
|
+
require(original.actor != ROOT_SUBJECT and original.subject != ROOT_SUBJECT, 'local_only')
|
|
35
|
+
credential = await tx.credential(original.credential_id)
|
|
36
|
+
now = app.clock()
|
|
37
|
+
require(credential.subject_id == original.actor, 'credential_subject_mismatch')
|
|
38
|
+
require(credential.revoked_at is None, 'credential_revoked')
|
|
39
|
+
require(credential.not_before <= now and (credential.expires_at is None or now < credential.expires_at),
|
|
40
|
+
'credential_expired')
|
|
41
|
+
for subject_id in {original.actor, original.subject}:
|
|
42
|
+
subject = await tx.subject(subject_id)
|
|
43
|
+
require(not subject.local_only, 'local_only')
|
|
44
|
+
ceilings = []
|
|
45
|
+
for captured in original.ceiling:
|
|
46
|
+
for live in credential.ceiling:
|
|
47
|
+
if (captured.capability, captured.version) != (live.capability, live.version):
|
|
48
|
+
continue
|
|
49
|
+
operations = captured.operations & live.operations
|
|
50
|
+
if not operations:
|
|
51
|
+
continue
|
|
52
|
+
if await scope_subset(captured.scope, live.scope, tx):
|
|
53
|
+
scope = captured.scope
|
|
54
|
+
elif await scope_subset(live.scope, captured.scope, tx):
|
|
55
|
+
scope = live.scope
|
|
56
|
+
else:
|
|
57
|
+
continue
|
|
58
|
+
constraints = dict(captured.constraints)
|
|
59
|
+
possible = True
|
|
60
|
+
for name, value in live.constraints.items():
|
|
61
|
+
if name not in constraints:
|
|
62
|
+
constraints[name] = value
|
|
63
|
+
elif name in {'hosts', 'methods', 'ports', 'schemes'}:
|
|
64
|
+
combined = set(value) & set(constraints[name])
|
|
65
|
+
if not combined:
|
|
66
|
+
possible = False
|
|
67
|
+
break
|
|
68
|
+
constraints[name] = sorted(combined)
|
|
69
|
+
elif name in {'timeout_ms', 'max_response_bytes', 'max_redirects'}:
|
|
70
|
+
constraints[name] = min(value, constraints[name])
|
|
71
|
+
elif value != constraints[name]:
|
|
72
|
+
possible = False
|
|
73
|
+
break
|
|
74
|
+
if possible:
|
|
75
|
+
ceilings.append(CapabilityGrant(capability=captured.capability, version=captured.version,
|
|
76
|
+
scope=scope, operations=operations, constraints=constraints))
|
|
77
|
+
require(ceilings, 'credential_ceiling')
|
|
78
|
+
principal = replace(original, ceiling=tuple(ceilings))
|
|
79
|
+
# Revocation, expiry, issuer rights and authority sources are all live checks.
|
|
80
|
+
await app.authorizer.grants(principal, tx)
|
|
81
|
+
return principal
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def worker_context(app, job, principal):
|
|
85
|
+
return ExecutionContext(request_id=job.arguments.get('request_id', job.id), principal=principal,
|
|
86
|
+
entry='worker', now=app.clock(), deadline_monotonic=time.monotonic() + 60)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def effect_request(app, job, principal):
|
|
90
|
+
return request_for(job.operation, {}, subject=principal.subject, service=app.settings.service_url,
|
|
91
|
+
expires_at=app.clock()+timedelta(seconds=180), request_id=job.arguments.get('request_id', job.id))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class EffectWorker:
|
|
95
|
+
def __init__(self, app, *, tool_runner: ToolRunner | None = None, mail_sender=None, webhook_sender=None,
|
|
96
|
+
lease_seconds=120):
|
|
97
|
+
self.app = app
|
|
98
|
+
if tool_runner is None:
|
|
99
|
+
from msg.workers.sandbox import BubblewrapRunner
|
|
100
|
+
tool_runner = BubblewrapRunner(app)
|
|
101
|
+
if mail_sender is None:
|
|
102
|
+
from msg.workers.mail import SmtpSender
|
|
103
|
+
mail_sender = SmtpSender(app.settings.server.mail)
|
|
104
|
+
self.tool_runner, self.mail_sender = tool_runner, mail_sender
|
|
105
|
+
if webhook_sender is None:
|
|
106
|
+
from msg.workers.webhook import WebhookSender
|
|
107
|
+
webhook_sender = WebhookSender()
|
|
108
|
+
self.webhook_sender = webhook_sender
|
|
109
|
+
self.lease_seconds = lease_seconds
|
|
110
|
+
|
|
111
|
+
async def _claim(self):
|
|
112
|
+
async with self.app.metadata.transaction(write=True) as tx:
|
|
113
|
+
if quarantine_active(tx):
|
|
114
|
+
return None, False
|
|
115
|
+
# Orphaned external work cannot be assumed not to have executed.
|
|
116
|
+
for (raw_id,) in tx.execute("SELECT id FROM jobs WHERE state='running' ORDER BY next_at,id"):
|
|
117
|
+
job = await tx.job(raw_id)
|
|
118
|
+
if job.lease_until is None or job.lease_until <= self.app.clock():
|
|
119
|
+
await tx.save_job(replace(job, state='uncertain', lease_until=None))
|
|
120
|
+
tx.set_setting('job_status:' + job.id, {'code': 'expired_execution_lease'})
|
|
121
|
+
return job, False
|
|
122
|
+
row = tx.one("SELECT id FROM jobs WHERE state='pending' AND next_at<=? ORDER BY next_at,id LIMIT 1",
|
|
123
|
+
(wire(self.app.clock()),))
|
|
124
|
+
if row is None:
|
|
125
|
+
return None, False
|
|
126
|
+
job = await tx.job(row[0])
|
|
127
|
+
job = replace(job, state='running', attempts=job.attempts+1,
|
|
128
|
+
lease_until=self.app.clock()+timedelta(seconds=self.lease_seconds))
|
|
129
|
+
await tx.save_job(job)
|
|
130
|
+
return job, True
|
|
131
|
+
|
|
132
|
+
async def _finish(self, job, state, code, *, status=None):
|
|
133
|
+
async with self.app.metadata.transaction(write=True) as tx:
|
|
134
|
+
current = await current_attempt(self.app, tx, job)
|
|
135
|
+
if current is None:
|
|
136
|
+
return
|
|
137
|
+
await tx.save_job(replace(current, state=state, lease_until=None))
|
|
138
|
+
tx.set_setting('job_status:' + job.id,
|
|
139
|
+
{'code': code} if status is None else status)
|
|
140
|
+
|
|
141
|
+
async def _retry(self, job, retry_code, exhausted_code):
|
|
142
|
+
async with self.app.metadata.transaction(write=True) as tx:
|
|
143
|
+
current = await current_attempt(self.app, tx, job)
|
|
144
|
+
if current is None:
|
|
145
|
+
return
|
|
146
|
+
exhausted = current.attempts >= 8
|
|
147
|
+
next_at = (current.next_attempt_at if exhausted else
|
|
148
|
+
self.app.clock() + timedelta(seconds=min(3600, 30 * 2**(current.attempts - 1))))
|
|
149
|
+
await tx.save_job(replace(current, state='failed' if exhausted else 'pending',
|
|
150
|
+
lease_until=None, next_attempt_at=next_at))
|
|
151
|
+
tx.set_setting('job_status:' + job.id,
|
|
152
|
+
{'code': exhausted_code if exhausted else retry_code})
|
|
153
|
+
|
|
154
|
+
async def _retry_mail(self, job, code):
|
|
155
|
+
await self._retry(job, code, code)
|
|
156
|
+
|
|
157
|
+
async def _retry_webhook(self, job):
|
|
158
|
+
await self._retry(job, 'webhook_retry_scheduled', 'webhook_attempts_exhausted')
|
|
159
|
+
|
|
160
|
+
async def _tool(self, job, directory):
|
|
161
|
+
from msg.extensions.tools import read_tool, tool_policies
|
|
162
|
+
from msg.core.models import NetworkPolicy
|
|
163
|
+
require(job.operation == 'tool.run', 'unknown_operation')
|
|
164
|
+
async with self.app.metadata.transaction(write=False) as tx:
|
|
165
|
+
principal = await current_principal(self.app, job.principal, tx)
|
|
166
|
+
context, request = worker_context(self.app, job, principal), effect_request(self.app, job, principal)
|
|
167
|
+
ref = decode(ResourceRef, job.arguments['tool'])
|
|
168
|
+
await check_access(self.app, context, request, tx, ref.id, 'tool_use')
|
|
169
|
+
tool = await read_tool(self.app, tx, ref.id, ref.revision)
|
|
170
|
+
args = dict(job.arguments['input'])
|
|
171
|
+
live = await tool_policies(self.app, principal, tool, args, tx)
|
|
172
|
+
captured = tuple(decode(NetworkPolicy, p) for p in job.arguments['policies'])
|
|
173
|
+
candidates = []
|
|
174
|
+
for left in live:
|
|
175
|
+
for right in captured:
|
|
176
|
+
try:
|
|
177
|
+
restrictions = {name: getattr(right, name) for name in
|
|
178
|
+
('schemes', 'ports', 'methods', 'timeout_ms', 'max_response_bytes', 'max_redirects')}
|
|
179
|
+
if right.hosts:
|
|
180
|
+
restrictions['hosts'] = right.hosts
|
|
181
|
+
policy = intersect_policy(left, restrictions)
|
|
182
|
+
candidates.append(replace(policy, allow_private=left.allow_private and right.allow_private))
|
|
183
|
+
except Failure as exc:
|
|
184
|
+
if exc.code != 'network_policy_empty':
|
|
185
|
+
raise
|
|
186
|
+
require(candidates, 'network_policy_denied')
|
|
187
|
+
# Never send an internal content path or database reference to the
|
|
188
|
+
# sandbox. Only a bounded, explicitly readable request body is copied.
|
|
189
|
+
if 'body_ref' in args:
|
|
190
|
+
body_ref = decode(ResourceRef, args.pop('body_ref'))
|
|
191
|
+
await check_access(self.app, context, request, tx, body_ref.id, 'read')
|
|
192
|
+
rev = await tx.revision(body_ref)
|
|
193
|
+
input_path = directory/'request-body'
|
|
194
|
+
with input_path.open('xb') as output:
|
|
195
|
+
async for piece in self.app.contents.read(rev.content):
|
|
196
|
+
await asyncio.to_thread(output.write, piece)
|
|
197
|
+
args['_body_path'] = str(input_path)
|
|
198
|
+
result = await self.tool_runner(tool, args, tuple(candidates), directory)
|
|
199
|
+
try:
|
|
200
|
+
require(isinstance(result, ToolResult), 'invalid_tool_result')
|
|
201
|
+
path = Path(result.path)
|
|
202
|
+
require(path.parent.resolve() == directory.resolve() and path.is_file() and not path.is_symlink(),
|
|
203
|
+
'invalid_tool_output_path')
|
|
204
|
+
require(path.stat().st_size <= max(p.max_response_bytes for p in candidates), 'tool_response_too_large')
|
|
205
|
+
require(len(result.media_type) <= 255 and '\r' not in result.media_type and '\n' not in result.media_type,
|
|
206
|
+
'invalid_media_type')
|
|
207
|
+
async def pieces():
|
|
208
|
+
with path.open('rb') as source:
|
|
209
|
+
while chunk := await asyncio.to_thread(source.read, 65536):
|
|
210
|
+
yield chunk
|
|
211
|
+
blob = await self.app.contents.put(pieces(), result.media_type)
|
|
212
|
+
await self.app.contents.pin(blob, 'job:'+job.id)
|
|
213
|
+
async with self.app.metadata.transaction(write=True) as tx:
|
|
214
|
+
current = await current_attempt(self.app, tx, job)
|
|
215
|
+
if current is None:
|
|
216
|
+
return
|
|
217
|
+
principal = await current_principal(self.app, job.principal, tx)
|
|
218
|
+
context, request = worker_context(self.app, job, principal), effect_request(self.app, job, principal)
|
|
219
|
+
await check_access(self.app, context, request, tx, ref.id, 'tool_use')
|
|
220
|
+
await tool_policies(self.app, principal, tool, job.arguments['input'], tx)
|
|
221
|
+
parent = tx.one("SELECT id FROM resources WHERE parent=? AND name='files'", (principal.subject,))
|
|
222
|
+
require(parent is not None, 'output_parent_missing')
|
|
223
|
+
resource = await create_resource(self.app, context, request, tx, parent=parent[0], type='file',
|
|
224
|
+
name='tool-'+job.id, body=blob, media_type=blob.media_type, mode=0o600)
|
|
225
|
+
output = ResourceRef(id=resource.id, revision=resource.revision)
|
|
226
|
+
# A controlled authorization fact, not user-editable file metadata.
|
|
227
|
+
tx.set_setting('tool_output:'+resource.id, {'subject': principal.subject, 'tool_id': ref.id, 'job_id': job.id})
|
|
228
|
+
await tx.save_job(replace(current, state='done', result=output, lease_until=None))
|
|
229
|
+
tx.set_setting('job_status:'+job.id, {'code':'ok', 'size':blob.size, 'digest':blob.digest,
|
|
230
|
+
'media_type':blob.media_type, 'metadata':result.metadata})
|
|
231
|
+
except Failure as exc:
|
|
232
|
+
# The external request already returned. A revoked credential or a
|
|
233
|
+
# failed local commit does not prove that the external action failed.
|
|
234
|
+
raise Failure('external_uncertain') from exc
|
|
235
|
+
|
|
236
|
+
async def _mail(self, job):
|
|
237
|
+
from msg.core.codec import loads, parse_time
|
|
238
|
+
# Disabled means not even connecting, regardless of historical jobs.
|
|
239
|
+
require(self.app.settings.server.mail is not None and
|
|
240
|
+
self.app.settings.server.mail.enabled, 'mail_disabled')
|
|
241
|
+
if job.arguments.get('order_notification'):
|
|
242
|
+
from msg.market.delivery_notifications import project_notification
|
|
243
|
+
async with self.app.metadata.transaction(write=False) as tx:
|
|
244
|
+
principal = await current_principal(self.app, job.principal, tx)
|
|
245
|
+
projected = await project_notification(self.app, tx, job, principal)
|
|
246
|
+
state = await self.mail_sender.send(projected)
|
|
247
|
+
require(state in {'sent', 'uncertain'}, 'invalid_delivery_result')
|
|
248
|
+
await self._finish(job, 'done' if state == 'sent' else 'uncertain', state)
|
|
249
|
+
return
|
|
250
|
+
async with self.app.metadata.transaction(write=False) as tx:
|
|
251
|
+
await current_principal(self.app, job.principal, tx)
|
|
252
|
+
subject = job.arguments['recipient_subject']
|
|
253
|
+
row = tx.one('SELECT body FROM emails WHERE subject=?', (subject,))
|
|
254
|
+
require(row is not None, 'email_not_set')
|
|
255
|
+
email = decode(EmailSettings, loads(row[0]))
|
|
256
|
+
require(email.subject_id == subject and email.address == job.arguments['recipient'],
|
|
257
|
+
'email_changed')
|
|
258
|
+
if job.arguments.get('verification'):
|
|
259
|
+
challenge = tx.one('SELECT expires FROM email_challenges WHERE subject=?', (subject,))
|
|
260
|
+
require(challenge is not None and parse_time(challenge[0]) > self.app.clock(), 'email_challenge_expired')
|
|
261
|
+
else:
|
|
262
|
+
require(email.verified_at is not None and job.operation in email.enabled_events, 'notification_disabled')
|
|
263
|
+
state = await self.mail_sender.send(job)
|
|
264
|
+
require(state in {'sent', 'uncertain'}, 'invalid_delivery_result')
|
|
265
|
+
await self._finish(job, 'done' if state == 'sent' else 'uncertain', state)
|
|
266
|
+
|
|
267
|
+
async def _market_mail(self, job):
|
|
268
|
+
from msg.market.targets import mail_enabled, render_notification
|
|
269
|
+
from msg.market.email import render_verification
|
|
270
|
+
require(mail_enabled(self.app), 'mail_disabled')
|
|
271
|
+
async with self.app.metadata.transaction(write=False) as tx:
|
|
272
|
+
await current_principal(self.app,job.principal,tx)
|
|
273
|
+
outgoing=await (render_verification(self.app,tx,job) if job.kind=='market_email_verify'
|
|
274
|
+
else render_notification(self.app,tx,job))
|
|
275
|
+
state=await self.mail_sender.send(outgoing)
|
|
276
|
+
require(state in {'sent','uncertain'},'invalid_delivery_result')
|
|
277
|
+
await self._finish(job,'done' if state=='sent' else 'uncertain',state)
|
|
278
|
+
|
|
279
|
+
async def _webhook(self, job):
|
|
280
|
+
from msg.core.codec import canonical,decode,loads
|
|
281
|
+
from msg.core.models import Event
|
|
282
|
+
from msg.plugins.communication import WEBHOOK_DOMAIN_EVENTS,_webhook_subscription_key
|
|
283
|
+
from msg.workers.webhook import open_secret, validate_endpoint
|
|
284
|
+
require(job.operation in {'communication.send','communication.webhook_subscribe'},
|
|
285
|
+
'invalid_webhook_event')
|
|
286
|
+
async with self.app.metadata.transaction(write=False) as tx:
|
|
287
|
+
principal=await current_principal(self.app,job.principal,tx)
|
|
288
|
+
recipient=job.arguments['recipient_subject']
|
|
289
|
+
subject=await tx.subject(recipient)
|
|
290
|
+
require(not subject.local_only,'local_only')
|
|
291
|
+
row=tx.one('''SELECT url,nonce,ciphertext,enabled,generation FROM webhook_endpoints
|
|
292
|
+
WHERE subject=?''',(recipient,))
|
|
293
|
+
require(row is not None and row[3]==1 and
|
|
294
|
+
row[4]==job.arguments['endpoint_generation'],'webhook_disabled')
|
|
295
|
+
if job.operation=='communication.send':
|
|
296
|
+
message=tx.one('SELECT sender,recipient,event_id FROM messages WHERE id=?',
|
|
297
|
+
(job.arguments['message_id'],))
|
|
298
|
+
require(message is not None and message==(principal.subject,recipient,job.event_id),
|
|
299
|
+
'webhook_event_missing')
|
|
300
|
+
category='inbox.reference'
|
|
301
|
+
reference={}
|
|
302
|
+
else:
|
|
303
|
+
require(principal.subject==recipient and principal.actor==recipient and
|
|
304
|
+
principal.method=='signature','webhook_subscription_owner')
|
|
305
|
+
rid=job.arguments['resource_id']
|
|
306
|
+
scope_id=job.arguments['scope_id']
|
|
307
|
+
category=job.arguments['category']
|
|
308
|
+
subscription=tx.setting(_webhook_subscription_key(recipient,scope_id))
|
|
309
|
+
require(subscription is not None and subscription.get('enabled') and
|
|
310
|
+
subscription.get('generation')==job.arguments['subscription_generation'] and
|
|
311
|
+
subscription.get('endpoint_generation')==row[4] and
|
|
312
|
+
category in subscription.get('events',()) and
|
|
313
|
+
subscription['principal']['credential_id']==principal.credential_id,
|
|
314
|
+
'webhook_subscription_disabled')
|
|
315
|
+
event_row=tx.one('SELECT body FROM events WHERE id=?',(job.event_id,))
|
|
316
|
+
require(event_row is not None,'webhook_event_missing')
|
|
317
|
+
event=decode(Event,loads(event_row[0]))
|
|
318
|
+
require(WEBHOOK_DOMAIN_EVENTS.get(event.type)==category and
|
|
319
|
+
any(ref.id==rid for ref in event.resources),'invalid_webhook_event')
|
|
320
|
+
scope=await tx.resource(scope_id)
|
|
321
|
+
resource=await tx.resource(rid)
|
|
322
|
+
require(scope.owner==recipient and scope.state=='active' and
|
|
323
|
+
resource.state=='active' and
|
|
324
|
+
(scope.id==resource.id or resource.parent==scope.id),
|
|
325
|
+
'webhook_scope_changed')
|
|
326
|
+
require(await self.app.authorizer.has(principal,'webhook.domain',
|
|
327
|
+
'communication.webhook_subscribe@1',scope.id,tx),'capability_required')
|
|
328
|
+
context=worker_context(self.app,job,principal)
|
|
329
|
+
request=effect_request(self.app,job,principal)
|
|
330
|
+
await check_access(self.app,context,request,tx,scope.id,'read')
|
|
331
|
+
await check_access(self.app,context,request,tx,resource.id,'read')
|
|
332
|
+
reference={'resource_id':rid}
|
|
333
|
+
url=row[0]
|
|
334
|
+
validate_endpoint(url)
|
|
335
|
+
secret=open_secret(self.app,recipient,row[1],row[2])
|
|
336
|
+
timestamp=str(int(self.app.clock().timestamp()))
|
|
337
|
+
# Inbox carries no resource. Domain Event carries only a resource ID
|
|
338
|
+
# after the current subscription, endpoint and ACL checks above.
|
|
339
|
+
body=canonical({'event_id':job.event_id,'delivery_id':job.id,
|
|
340
|
+
'timestamp':timestamp,'subject_id':recipient,'type':category,**reference})
|
|
341
|
+
state=await self.webhook_sender.send(url,secret,body,timestamp=timestamp,
|
|
342
|
+
event_id=job.event_id,delivery_id=job.id)
|
|
343
|
+
require(state in {'delivered','retry','failed'},'invalid_delivery_result')
|
|
344
|
+
if state=='retry':
|
|
345
|
+
await self._retry_webhook(job)
|
|
346
|
+
else:
|
|
347
|
+
await self._finish(job,'done' if state=='delivered' else 'failed',state)
|
|
348
|
+
|
|
349
|
+
async def run_once(self):
|
|
350
|
+
if self.app.executor.recovery_drill_active():
|
|
351
|
+
return False
|
|
352
|
+
if 'orders' in self.app.settings.server.plugins:
|
|
353
|
+
from msg.market.escrow import resolve_due
|
|
354
|
+
if await resolve_due(self.app):
|
|
355
|
+
return True
|
|
356
|
+
from msg.market.arbitration import resolve_cases
|
|
357
|
+
if await resolve_cases(self.app):
|
|
358
|
+
return True
|
|
359
|
+
job, execute = await self._claim()
|
|
360
|
+
if job is None:
|
|
361
|
+
return False
|
|
362
|
+
if not execute:
|
|
363
|
+
return True
|
|
364
|
+
try:
|
|
365
|
+
if job.kind == 'tool':
|
|
366
|
+
self.app.settings.server.staging_dir.mkdir(parents=True, exist_ok=True)
|
|
367
|
+
with tempfile.TemporaryDirectory(prefix='effect-', dir=self.app.settings.server.staging_dir) as temp:
|
|
368
|
+
await self._tool(job, Path(temp))
|
|
369
|
+
elif job.kind in {'market_mail','market_email_verify'}:
|
|
370
|
+
await self._market_mail(job)
|
|
371
|
+
elif job.kind == 'mail':
|
|
372
|
+
await self._mail(job)
|
|
373
|
+
elif job.kind == 'webhook':
|
|
374
|
+
await self._webhook(job)
|
|
375
|
+
elif job.kind == 'git.push':
|
|
376
|
+
from msg.extensions.repositories import execute_push
|
|
377
|
+
await execute_push(self.app,job)
|
|
378
|
+
elif job.kind == 'maintenance':
|
|
379
|
+
from msg.workers.maintenance import run_maintenance
|
|
380
|
+
result=await run_maintenance(self.app,job.arguments['action'],principal=job.principal)
|
|
381
|
+
await self._finish(job,'done','ok',status=result)
|
|
382
|
+
elif job.kind == 'gc.resource':
|
|
383
|
+
# Purge permission was consumed by the committed tombstone. This
|
|
384
|
+
# idempotent cleanup cannot change another resource's retention.
|
|
385
|
+
from msg.workers.maintenance import _collect
|
|
386
|
+
async with self.app.metadata.transaction(write=True) as tx:
|
|
387
|
+
resource=await tx.resource(job.arguments['id'])
|
|
388
|
+
require(resource.state=='purged','purge_not_committed')
|
|
389
|
+
await _collect(self.app,tx)
|
|
390
|
+
await self._finish(job,'done','cleanup_requested')
|
|
391
|
+
else:
|
|
392
|
+
raise Failure('unknown_effect_kind')
|
|
393
|
+
except Failure as exc:
|
|
394
|
+
# Known pre-execution rejection is a definite failure. External calls
|
|
395
|
+
# surface uncertain explicitly rather than inventing an exactly-once promise.
|
|
396
|
+
if job.kind in {'mail','market_mail','market_email_verify'} and exc.retryable and exc.code=='mail_connection_failed':
|
|
397
|
+
await self._retry_mail(job,exc.code)
|
|
398
|
+
return True
|
|
399
|
+
await self._finish(job, 'uncertain' if exc.code in {'external_uncertain','job_lease_lost'} else 'failed', exc.code)
|
|
400
|
+
except asyncio.CancelledError:
|
|
401
|
+
# Leave running with a lease: process cancellation may race an external
|
|
402
|
+
# commit. The next worker marks the expired lease uncertain.
|
|
403
|
+
raise
|
|
404
|
+
except Exception:
|
|
405
|
+
await self._finish(job, 'uncertain', 'external_uncertain')
|
|
406
|
+
return True
|
msg/workers/leases.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""One transaction-local fence for publishing any durable effect's result."""
|
|
2
|
+
from dataclasses import replace
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
async def current_attempt(app, tx, job):
|
|
6
|
+
"""Return the live attempt, or persist uncertainty without resending work.
|
|
7
|
+
|
|
8
|
+
Call within the same write transaction as the protected state/ref update.
|
|
9
|
+
A stale callback must not overwrite another attempt's status, even when no
|
|
10
|
+
separate worker has swept the expired lease yet.
|
|
11
|
+
"""
|
|
12
|
+
current = await tx.job(job.id)
|
|
13
|
+
if current.state != 'running' or current.attempts != job.attempts:
|
|
14
|
+
return None
|
|
15
|
+
if current.lease_until is None or current.lease_until <= app.clock():
|
|
16
|
+
await tx.save_job(replace(current, state='uncertain', lease_until=None))
|
|
17
|
+
tx.set_setting('job_status:' + job.id, {'code': 'expired_execution_lease'})
|
|
18
|
+
return None
|
|
19
|
+
return current
|
msg/workers/mail.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Optional TLS SMTP projection. SMTP acknowledgement is not exactly-once delivery."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import asyncio
|
|
4
|
+
from email.message import EmailMessage
|
|
5
|
+
import smtplib
|
|
6
|
+
import ssl
|
|
7
|
+
from msg.core.codec import loads
|
|
8
|
+
from msg.core.errors import Failure, require
|
|
9
|
+
from msg.core.email_address import validate_address
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SmtpSender:
|
|
13
|
+
def __init__(self, config):
|
|
14
|
+
self.config = config
|
|
15
|
+
|
|
16
|
+
async def send(self, job):
|
|
17
|
+
require(self.config is not None and self.config.enabled, 'mail_disabled')
|
|
18
|
+
return await asyncio.to_thread(self._send, job)
|
|
19
|
+
|
|
20
|
+
def _send(self, job):
|
|
21
|
+
config = self.config
|
|
22
|
+
# Revalidate at the external boundary, including jobs queued by old code.
|
|
23
|
+
# Invalid targets fail before opening a connection or reading credentials.
|
|
24
|
+
recipient = validate_address(job.arguments['recipient'])
|
|
25
|
+
message = EmailMessage()
|
|
26
|
+
message['From'] = config.sender
|
|
27
|
+
message['To'] = recipient
|
|
28
|
+
message['Subject'] = job.arguments['subject']
|
|
29
|
+
# A stable Message-ID is useful for tracing; not a remote dedup guarantee.
|
|
30
|
+
message['Message-ID'] = '<'+job.id+'@msgd.local>'
|
|
31
|
+
message.set_content(job.arguments['text'])
|
|
32
|
+
sending = False
|
|
33
|
+
connection = None
|
|
34
|
+
try:
|
|
35
|
+
context = ssl.create_default_context()
|
|
36
|
+
if config.tls == 'tls':
|
|
37
|
+
connection = smtplib.SMTP_SSL(config.host, config.port, timeout=20, context=context)
|
|
38
|
+
else:
|
|
39
|
+
connection = smtplib.SMTP(config.host, config.port, timeout=20)
|
|
40
|
+
connection.ehlo()
|
|
41
|
+
connection.starttls(context=context)
|
|
42
|
+
connection.ehlo()
|
|
43
|
+
if config.credential_file:
|
|
44
|
+
credentials = loads(config.credential_file.read_bytes())
|
|
45
|
+
require(set(credentials) == {'username', 'password'}, 'invalid_smtp_credentials')
|
|
46
|
+
connection.login(credentials['username'], credentials['password'])
|
|
47
|
+
# Once send_message starts, an exception can happen after the receiver
|
|
48
|
+
# accepted DATA. Do not automatically retry this ambiguous boundary.
|
|
49
|
+
sending = True
|
|
50
|
+
refused = connection.send_message(message, to_addrs=[recipient.addr_spec])
|
|
51
|
+
require(not refused, 'mail_recipient_refused')
|
|
52
|
+
return 'sent'
|
|
53
|
+
except (smtplib.SMTPException, OSError, Failure) as exc:
|
|
54
|
+
if sending:
|
|
55
|
+
return 'uncertain'
|
|
56
|
+
raise Failure('mail_connection_failed', retryable=True) from exc
|
|
57
|
+
finally:
|
|
58
|
+
if connection:
|
|
59
|
+
# QUIT failure cannot undo a successful DATA acknowledgement.
|
|
60
|
+
try:
|
|
61
|
+
connection.close()
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|