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.
Files changed (176) hide show
  1. msg/__init__.py +2 -0
  2. msg/admin/__init__.py +0 -0
  3. msg/admin/backup_retirement.py +56 -0
  4. msg/admin/backups.py +341 -0
  5. msg/admin/custodial_check.py +77 -0
  6. msg/admin/diagnostics.py +750 -0
  7. msg/admin/market.py +91 -0
  8. msg/admin/market_check.py +341 -0
  9. msg/admin/money.py +322 -0
  10. msg/admin/preflight.py +142 -0
  11. msg/admin/recovery_replay.py +281 -0
  12. msg/admin/restore_database.py +37 -0
  13. msg/admin/root.py +347 -0
  14. msg/admin/rotation.py +197 -0
  15. msg/admin/token_delivery_check.py +54 -0
  16. msg/admin/upgrade_check.py +58 -0
  17. msg/application.py +163 -0
  18. msg/bootstrap.py +305 -0
  19. msg/cli.py +537 -0
  20. msg/client.py +725 -0
  21. msg/client_certificates.py +98 -0
  22. msg/client_content.py +43 -0
  23. msg/client_custodial.py +206 -0
  24. msg/client_market.py +104 -0
  25. msg/client_recovery.py +186 -0
  26. msg/client_secrets.py +58 -0
  27. msg/client_tokens.py +99 -0
  28. msg/client_upgrade.py +188 -0
  29. msg/config.py +318 -0
  30. msg/constants.py +11 -0
  31. msg/core/__init__.py +0 -0
  32. msg/core/batching.py +23 -0
  33. msg/core/codec.py +253 -0
  34. msg/core/contracts.py +128 -0
  35. msg/core/cursors.py +68 -0
  36. msg/core/email_address.py +33 -0
  37. msg/core/errors.py +22 -0
  38. msg/core/events.py +6 -0
  39. msg/core/execution_ports.py +20 -0
  40. msg/core/executor.py +193 -0
  41. msg/core/models.py +517 -0
  42. msg/core/packet.py +71 -0
  43. msg/core/permissions.py +14 -0
  44. msg/core/query.py +44 -0
  45. msg/core/read_query.py +60 -0
  46. msg/core/registry.py +173 -0
  47. msg/core/requests.py +58 -0
  48. msg/core/schema_policy.py +28 -0
  49. msg/core/schemas.py +30 -0
  50. msg/core/tags.py +21 -0
  51. msg/core/template_dsl.py +94 -0
  52. msg/core/text_patch.py +283 -0
  53. msg/core/tool_execution.py +28 -0
  54. msg/core/transfer.py +183 -0
  55. msg/daemon.py +255 -0
  56. msg/data/__init__.py +0 -0
  57. msg/data/bootstrap.json +72 -0
  58. msg/data/favicon.png +0 -0
  59. msg/data/logo-dark.svg +5 -0
  60. msg/data/logo.svg +5 -0
  61. msg/data/recovery-checkpoint.example.json +1 -0
  62. msg/data/recovery-checkpoint.schema.json +118 -0
  63. msg/data/shortcodes.json +1 -0
  64. msg/data/system/AGENTS.md +6 -0
  65. msg/data/system/rules/_index.md +17 -0
  66. msg/data/system/rules/auth.md +6 -0
  67. msg/data/system/rules/files.md +6 -0
  68. msg/data/system/rules/identity.md +6 -0
  69. msg/data/system/rules/protocol.md +6 -0
  70. msg/data/system/rules/read-write.md +6 -0
  71. msg/data/system/rules/recovery.md +6 -0
  72. msg/data/system/rules/security.md +10 -0
  73. msg/data/system/rules/topics.md +6 -0
  74. msg/extensions/__init__.py +1 -0
  75. msg/extensions/hosting.py +259 -0
  76. msg/extensions/keystore.py +96 -0
  77. msg/extensions/repositories.py +769 -0
  78. msg/extensions/rss.py +67 -0
  79. msg/extensions/ssh.py +264 -0
  80. msg/extensions/ssh_git.py +300 -0
  81. msg/extensions/tools.py +150 -0
  82. msg/hosting_runtime.py +138 -0
  83. msg/market/__init__.py +1 -0
  84. msg/market/arbitration.py +481 -0
  85. msg/market/delivery.py +338 -0
  86. msg/market/delivery_notifications.py +110 -0
  87. msg/market/delivery_targets.py +76 -0
  88. msg/market/email.py +100 -0
  89. msg/market/escrow.py +345 -0
  90. msg/market/orders.py +218 -0
  91. msg/market/policy.py +147 -0
  92. msg/market/rationale.py +91 -0
  93. msg/market/references.py +19 -0
  94. msg/market/targets.py +157 -0
  95. msg/plugins/__init__.py +28 -0
  96. msg/plugins/achievements.py +316 -0
  97. msg/plugins/batch.py +32 -0
  98. msg/plugins/bounty.py +366 -0
  99. msg/plugins/collaboration.py +261 -0
  100. msg/plugins/common.py +217 -0
  101. msg/plugins/communication.py +853 -0
  102. msg/plugins/content.py +855 -0
  103. msg/plugins/custodial_lifecycle.py +224 -0
  104. msg/plugins/delivery.py +230 -0
  105. msg/plugins/discovery.py +1266 -0
  106. msg/plugins/discussion.py +162 -0
  107. msg/plugins/extensions.py +10 -0
  108. msg/plugins/following.py +78 -0
  109. msg/plugins/hosting_capacity.py +52 -0
  110. msg/plugins/identity.py +1654 -0
  111. msg/plugins/money.py +221 -0
  112. msg/plugins/offers.py +183 -0
  113. msg/plugins/orders.py +273 -0
  114. msg/plugins/recovery.py +376 -0
  115. msg/plugins/schemas.py +5 -0
  116. msg/plugins/sharing.py +300 -0
  117. msg/plugins/store.py +249 -0
  118. msg/plugins/system.py +75 -0
  119. msg/plugins/transfer.py +249 -0
  120. msg/py.typed +0 -0
  121. msg/security/__init__.py +0 -0
  122. msg/security/age_keys.py +112 -0
  123. msg/security/authentication.py +141 -0
  124. msg/security/authorization.py +300 -0
  125. msg/security/backup_retirement.py +135 -0
  126. msg/security/capabilities.py +157 -0
  127. msg/security/certificates.py +183 -0
  128. msg/security/crypto.py +102 -0
  129. msg/security/custodial_migration.py +292 -0
  130. msg/security/custody_history.py +146 -0
  131. msg/security/network.py +81 -0
  132. msg/security/policy.py +67 -0
  133. msg/security/quarantine.py +19 -0
  134. msg/security/root_files.py +67 -0
  135. msg/security/rotation_journal.py +60 -0
  136. msg/security/sealed_box.py +70 -0
  137. msg/security/sharing_policy.py +29 -0
  138. msg/security/token_delivery.py +93 -0
  139. msg/security/vault.py +186 -0
  140. msg/storage/__init__.py +0 -0
  141. msg/storage/capacity.py +100 -0
  142. msg/storage/custodial_migration.py +22 -0
  143. msg/storage/git.py +435 -0
  144. msg/storage/ledger_migration.py +307 -0
  145. msg/storage/market_migration.py +49 -0
  146. msg/storage/postgres.py +663 -0
  147. msg/storage/query.py +39 -0
  148. msg/storage/read_only.py +126 -0
  149. msg/storage/session.py +403 -0
  150. msg/storage/sqlite.py +360 -0
  151. msg/storage/topic_event_migration.py +26 -0
  152. msg/storage/valkey_bus.py +45 -0
  153. msg/transports/__init__.py +0 -0
  154. msg/transports/client.py +183 -0
  155. msg/transports/dictionary.py +387 -0
  156. msg/transports/graphql.py +75 -0
  157. msg/transports/http.py +72 -0
  158. msg/transports/http_routes.py +1577 -0
  159. msg/transports/mcp.py +127 -0
  160. msg/transports/packet.py +42 -0
  161. msg/transports/read_tree_path.py +65 -0
  162. msg/transports/stdio.py +36 -0
  163. msg/transports/url_safety.py +123 -0
  164. msg/tui.py +368 -0
  165. msg/workers/__init__.py +1 -0
  166. msg/workers/effects.py +406 -0
  167. msg/workers/leases.py +19 -0
  168. msg/workers/mail.py +63 -0
  169. msg/workers/maintenance.py +310 -0
  170. msg/workers/sandbox.py +74 -0
  171. msg/workers/sandbox_child.py +189 -0
  172. msg/workers/webhook.py +160 -0
  173. msgctl-0.1.0a1.dist-info/METADATA +96 -0
  174. msgctl-0.1.0a1.dist-info/RECORD +176 -0
  175. msgctl-0.1.0a1.dist-info/WHEEL +4 -0
  176. 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