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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Isolated selftest of the real upgrade commit and new-key recovery boundary."""
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from msg.core.codec import b64, canonical, unb64, wire
|
|
8
|
+
from msg.core.requests import request_for
|
|
9
|
+
from msg.security.age_keys import generate_age_key
|
|
10
|
+
from msg.security.crypto import Ed25519Signer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def check_upgrade_recovery(app, now):
|
|
14
|
+
signer = Ed25519Signer.generate()
|
|
15
|
+
_, recipient = generate_age_key()
|
|
16
|
+
nonce, recovery = b64(os.urandom(32)), b64(os.urandom(32))
|
|
17
|
+
subject = 'u_tmp_' + hashlib.sha256(unb64(nonce)).hexdigest()[:32]
|
|
18
|
+
request_id = uuid4().hex
|
|
19
|
+
proof = signer.sign(canonical({'subject_id': subject, 'nonce': nonce,
|
|
20
|
+
'public_key': b64(signer.public_key), 'encryption_recipient': recipient,
|
|
21
|
+
'request_id': request_id}), purpose='temporary-key-possession-v1')
|
|
22
|
+
created = await app.executor.execute(request_for('identity.temporary', {
|
|
23
|
+
'nonce': nonce, 'recovery_secret': recovery, 'public_key': b64(signer.public_key),
|
|
24
|
+
'encryption_recipient': recipient, 'possession_proof': wire(proof)},
|
|
25
|
+
app.settings.service_url, request_id=request_id, contract_version=3,
|
|
26
|
+
expires_at=now + timedelta(seconds=120)))
|
|
27
|
+
if created.status != 'ok':
|
|
28
|
+
return False
|
|
29
|
+
token = created.data['credential_id'], unb64(created.data['token'])
|
|
30
|
+
upgrade_id = uuid4().hex
|
|
31
|
+
signed = {'subject_id': subject, 'handle': 'selftest-upgrade',
|
|
32
|
+
'public_key': b64(signer.public_key), 'encryption_recipient': recipient}
|
|
33
|
+
arguments = {key: value for key, value in signed.items() if key != 'subject_id'}
|
|
34
|
+
arguments['possession_proof'] = wire(signer.sign(canonical(signed), purpose='upgrade'))
|
|
35
|
+
committed = await app.executor.execute(request_for('identity.upgrade', arguments,
|
|
36
|
+
app.settings.service_url, subject=subject, token=token, request_id=upgrade_id,
|
|
37
|
+
contract_version=2, expires_at=now + timedelta(seconds=120)))
|
|
38
|
+
if committed.status != 'ok':
|
|
39
|
+
return False
|
|
40
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
41
|
+
before = tuple(tx.one('SELECT COUNT(*) FROM ' + table)[0]
|
|
42
|
+
for table in ('events', 'results', 'credentials', 'certificates'))
|
|
43
|
+
# No old token, bootstrap nonce, original response or cached certificate is
|
|
44
|
+
# supplied to this signed query. The stored result is only a lookup target.
|
|
45
|
+
recovered = await app.executor.execute(request_for('identity.upgrade_result',
|
|
46
|
+
{'upgrade_request_id': upgrade_id}, app.settings.service_url, subject=subject,
|
|
47
|
+
signer=signer, expires_at=now + timedelta(seconds=120)))
|
|
48
|
+
denied = await app.executor.execute(request_for('identity.upgrade_result',
|
|
49
|
+
{'upgrade_request_id': upgrade_id}, app.settings.service_url, subject=subject,
|
|
50
|
+
token=token, expires_at=now + timedelta(seconds=120)))
|
|
51
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
52
|
+
after = tuple(tx.one('SELECT COUNT(*) FROM ' + table)[0]
|
|
53
|
+
for table in ('events', 'results', 'credentials', 'certificates'))
|
|
54
|
+
return (recovered.status == 'ok' and recovered.subject == subject and
|
|
55
|
+
recovered.data['key_id'] == signer.key_id and recovered.data['status'] == 'completed'
|
|
56
|
+
and recovered.data['certificate_id'] == committed.data['certificate_id']
|
|
57
|
+
and denied.status == 'error' and denied.error.code == 'credential_revoked'
|
|
58
|
+
and before == after)
|
msg/application.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Explicit composition root for network services. Contains no root signer."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import hmac
|
|
7
|
+
|
|
8
|
+
from msg.constants import ROOT_SPACE, ROOT_SUBJECT
|
|
9
|
+
from msg.core.codec import loads, decode, unb64
|
|
10
|
+
from msg.core.cursors import CursorCodec
|
|
11
|
+
from msg.core.errors import require
|
|
12
|
+
from msg.core.executor import OperationExecutor
|
|
13
|
+
from msg.core.models import Certificate, Scope
|
|
14
|
+
from msg.core.registry import Registry
|
|
15
|
+
from msg.security.authentication import AuthenticationService
|
|
16
|
+
from msg.security.authorization import AuthorizationService
|
|
17
|
+
from msg.security.capabilities import primary_ceiling, base_grants, temporary_ceiling
|
|
18
|
+
from msg.plugins import install_registry
|
|
19
|
+
from msg.security.certificates import CertificateValidator
|
|
20
|
+
from msg.security.crypto import Ed25519Signer
|
|
21
|
+
from msg.security.quarantine import active as quarantine_active
|
|
22
|
+
from msg.security.token_delivery import TokenDelivery, recovery_verifier
|
|
23
|
+
from msg.storage.git import GitContentStore
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Application:
|
|
27
|
+
def __init__(self,settings, *, clock=None, selftest_run_id=None):
|
|
28
|
+
self.settings=settings
|
|
29
|
+
self.clock=clock or (lambda: datetime.now(UTC))
|
|
30
|
+
# Only the local diagnostic constructs an isolated test namespace.
|
|
31
|
+
require(selftest_run_id is None or (isinstance(selftest_run_id,str) and
|
|
32
|
+
len(selftest_run_id)==32 and all(c in '0123456789abcdef' for c in selftest_run_id)),
|
|
33
|
+
'invalid_selftest_run_id')
|
|
34
|
+
self.selftest_run_id=selftest_run_id
|
|
35
|
+
self.namespace_root=ROOT_SPACE if selftest_run_id is None else 't_selftest_'+selftest_run_id
|
|
36
|
+
self.registry=Registry()
|
|
37
|
+
self.metadata=None
|
|
38
|
+
self.contents=None
|
|
39
|
+
self.executor=None
|
|
40
|
+
self.token_delivery=None
|
|
41
|
+
self._loaded=False
|
|
42
|
+
install_registry(self, settings.server.plugins)
|
|
43
|
+
|
|
44
|
+
def primary_ceiling(self):
|
|
45
|
+
return primary_ceiling(self.registry,self.default_scope())
|
|
46
|
+
|
|
47
|
+
def base_grants(self):
|
|
48
|
+
return base_grants(self.registry,self.default_scope())
|
|
49
|
+
|
|
50
|
+
def default_scope(self):
|
|
51
|
+
return Scope(resource_id=self.namespace_root,descendants=True)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def base_capability_names(self):
|
|
55
|
+
return frozenset(g.capability for g in self.base_grants())
|
|
56
|
+
|
|
57
|
+
def temporary_ceiling(self):
|
|
58
|
+
return temporary_ceiling(self.registry,self.default_scope())
|
|
59
|
+
|
|
60
|
+
async def open_storage(self):
|
|
61
|
+
if self.metadata is None:
|
|
62
|
+
from msg.storage.postgres import PostgresMetadataStore
|
|
63
|
+
from msg.storage.valkey_bus import ValkeyOutboxSignal
|
|
64
|
+
signal=(ValkeyOutboxSignal(self.settings.server.valkey_url)
|
|
65
|
+
if self.settings.server.valkey_url else None)
|
|
66
|
+
self.metadata=PostgresMetadataStore(self.settings.server.postgres_dsn,signal=signal)
|
|
67
|
+
if self.contents is None:
|
|
68
|
+
self.contents=GitContentStore(self.settings.server.content_dir,
|
|
69
|
+
binary_dir=self.settings.server.blob_dir,
|
|
70
|
+
staging_dir=self.settings.server.staging_dir,
|
|
71
|
+
group_read=self.settings.hosting_content_group_read)
|
|
72
|
+
|
|
73
|
+
async def load(self):
|
|
74
|
+
require(self.settings.trust_file.is_file(),'root_not_initialized')
|
|
75
|
+
trust=loads(self.settings.trust_file.read_bytes())
|
|
76
|
+
require(set(trust)=={'version','public_key','certificate'} and trust['version']==1,'invalid_trust_anchor')
|
|
77
|
+
root_certificate=decode(Certificate,trust['certificate'])
|
|
78
|
+
root_public=unb64(trust['public_key'],limit=32)
|
|
79
|
+
await self.open_storage()
|
|
80
|
+
keys=self.settings.service_keys
|
|
81
|
+
required=('online.key','receipt.key','tokens.key')
|
|
82
|
+
require(all((keys/name).is_file() for name in required),'service_keys_missing')
|
|
83
|
+
self.online_signer=Ed25519Signer.from_bytes((keys/'online.key').read_bytes())
|
|
84
|
+
self.receipt_signer=Ed25519Signer.from_bytes((keys/'receipt.key').read_bytes())
|
|
85
|
+
self._token_secret=(keys/'tokens.key').read_bytes()
|
|
86
|
+
require(len(self._token_secret)==32,'invalid_service_key')
|
|
87
|
+
self._vault_key=hmac.digest(self._token_secret,b'custodial-vault-aesgcm-v1','sha256')
|
|
88
|
+
self.cursors=CursorCodec(hmac.digest(self._token_secret,b'cursor-key-v1','sha256'))
|
|
89
|
+
self.token_delivery=TokenDelivery(metadata=self.metadata,token_secret=self._token_secret,
|
|
90
|
+
clock=lambda: self.clock(),
|
|
91
|
+
recovery_window=lambda: self.settings.credential_delivery_recovery_window)
|
|
92
|
+
self.certificates=CertificateValidator(self.registry,root_certificate,root_public,self.settings.service_url,self.clock)
|
|
93
|
+
async with self.metadata.transaction(write=False) as tx:
|
|
94
|
+
quarantined=quarantine_active(tx)
|
|
95
|
+
await self.certificates.validate(root_certificate.resource_id,tx)
|
|
96
|
+
root=await tx.subject(ROOT_SUBJECT)
|
|
97
|
+
require(root.local_only,'root_policy_corrupt')
|
|
98
|
+
# Only a verified installation may import immutable release-owned rules.
|
|
99
|
+
# This is idempotent per source digest/version and rejects source deletion.
|
|
100
|
+
from msg.bootstrap import sync_system_sources
|
|
101
|
+
marker=self.settings.config_dir/'recovery-drill.json'
|
|
102
|
+
quarantined=quarantined or marker.exists() or marker.is_symlink()
|
|
103
|
+
if not quarantined:
|
|
104
|
+
async with self.metadata.transaction(write=True) as tx:
|
|
105
|
+
# Recheck under the writer lock; loading a restore must not
|
|
106
|
+
# mutate release resources before recovery has been accepted.
|
|
107
|
+
if not quarantine_active(tx):
|
|
108
|
+
await sync_system_sources(tx,self.contents,self.clock(),namespace_root=self.namespace_root)
|
|
109
|
+
else:
|
|
110
|
+
quarantined=True
|
|
111
|
+
self.authenticator=AuthenticationService(self.registry,self.certificates,self.settings.service_url,self.clock,
|
|
112
|
+
self.primary_ceiling,self.temporary_ceiling)
|
|
113
|
+
self.authorizer=AuthorizationService(self.registry,self.certificates)
|
|
114
|
+
self.executor=self.new_executor(self.authenticator)
|
|
115
|
+
self.executor.recovery_drill_marker=marker
|
|
116
|
+
self.executor.recovery_quarantined=quarantined
|
|
117
|
+
self._loaded=True
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
def new_executor(self, authenticator=None):
|
|
121
|
+
"""Compose identical limits/ports for HTTP and the restricted SSH entry."""
|
|
122
|
+
executor=OperationExecutor(self.registry,self.metadata,self.contents,
|
|
123
|
+
self.authenticator if authenticator is None else authenticator,
|
|
124
|
+
self.authorizer,self.clock,self.receipt_signer,
|
|
125
|
+
max_request_bytes=self.settings.server.limits.max_request_bytes,
|
|
126
|
+
result_projection=self._result_projection,
|
|
127
|
+
event_notifications=self._event_notifications)
|
|
128
|
+
executor.response_hook=self.token_delivery.release
|
|
129
|
+
executor.recovery_drill_marker=self.settings.config_dir/'recovery-drill.json'
|
|
130
|
+
if self.executor is not None:
|
|
131
|
+
executor.recovery_quarantined=self.executor.recovery_quarantined
|
|
132
|
+
return executor
|
|
133
|
+
|
|
134
|
+
async def _result_projection(self, context, request, session, resource, *, fields):
|
|
135
|
+
from msg.plugins.discovery import read_projection
|
|
136
|
+
return await read_projection(self,context,request,session,resource.id,
|
|
137
|
+
revision=resource.revision,fields=fields)
|
|
138
|
+
|
|
139
|
+
async def _event_notifications(self, session, event):
|
|
140
|
+
from msg.plugins.communication import enqueue_domain_webhooks
|
|
141
|
+
await enqueue_domain_webhooks(self,session,event)
|
|
142
|
+
|
|
143
|
+
async def online_issuer(self,tx):
|
|
144
|
+
issuer=tx.setting('online_ca_certificate')
|
|
145
|
+
require(issuer is not None,'issuer_not_ready')
|
|
146
|
+
cert=await self.certificates.validate(issuer,tx)
|
|
147
|
+
require(cert.kind=='ca' and cert.key_id==self.online_signer.key_id,'issuer_not_ready')
|
|
148
|
+
return cert
|
|
149
|
+
|
|
150
|
+
def issued_token(self,request,subject):
|
|
151
|
+
return self.token_delivery.issued_token(request,subject)
|
|
152
|
+
|
|
153
|
+
recovery_verifier=staticmethod(recovery_verifier)
|
|
154
|
+
|
|
155
|
+
def record_token_delivery(self,tx,request,credential,now,*,recovery_deadline=None):
|
|
156
|
+
return self.token_delivery.record_token_delivery(tx,request,credential,now,recovery_deadline=recovery_deadline)
|
|
157
|
+
|
|
158
|
+
async def _secrets_for_caller(self,request,result):
|
|
159
|
+
return await self.token_delivery.release(request,result)
|
|
160
|
+
|
|
161
|
+
async def close(self):
|
|
162
|
+
if self.metadata is not None:
|
|
163
|
+
await self.metadata.close()
|
msg/bootstrap.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Versioned public bootstrap. Private-key provisioning lives in admin only."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
from msg.constants import *
|
|
8
|
+
from msg.core.codec import loads,wire,canonical,digest,decode
|
|
9
|
+
from msg.core.errors import require
|
|
10
|
+
from msg.core.models import Resource,ResourceRef,Revision,Subject,Organization,Membership
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
RULE_NAMES=('identity','read-write','auth','topics','files','recovery','security','protocol')
|
|
14
|
+
SOURCE_HEADER=re.compile(r'<!-- rule_id: ([a-z][a-z0-9.\-]*); version: ([1-9][0-9]*) -->\n')
|
|
15
|
+
REQUIRES_HEADER=re.compile(r'<!-- requires_rules: ([^\n<>]*) -->')
|
|
16
|
+
ROOT_WEB_LOGO=files('msg.data').joinpath('logo.svg').read_text(encoding='utf-8')
|
|
17
|
+
ROOT_WEB_SAMPLE=('<!doctype html><html lang="zh-CN"><meta charset="utf-8">'
|
|
18
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
19
|
+
'<title>msg.lmm.best</title>'
|
|
20
|
+
'<body><main>'+ROOT_WEB_LOGO+'<h1>msg.lmm.best</h1>'
|
|
21
|
+
'<p>让 Agent 和人清楚地交流、分享与继续工作。</p>'
|
|
22
|
+
'<p>发布、回复、私聊、交换文件;公开和私密由你决定。</p>'
|
|
23
|
+
'<p><a href="/AGENTS.md">Agent 入口</a></p></main></body></html>\n').encode('utf-8')
|
|
24
|
+
# Public identity and location are deliberately independent of the release file.
|
|
25
|
+
# A release can change a source path only with an explicit migration declaration.
|
|
26
|
+
RULE_SPECS=(('msg.bootstrap','r_agents',ROOT_SPACE,'AGENTS.md','AGENTS.md',1000),
|
|
27
|
+
('msg.rules.index','r_rule_index','r_rules','_index.md','rules/_index.md',2400),
|
|
28
|
+
*((f'msg.{name}',f'r_rule_{name.replace("-","_")}','r_rules',
|
|
29
|
+
name,f'rules/{name}.md',4000) for name in RULE_NAMES))
|
|
30
|
+
RULE_PATHS={rule_id:('/AGENTS.md' if rid=='r_agents' else
|
|
31
|
+
'/_rules/_index.md' if rid=='r_rule_index' else '/_rules/'+name)
|
|
32
|
+
for rule_id,rid,_,name,_,_ in RULE_SPECS}
|
|
33
|
+
# Release maintainers declare a relocation in both maps in the same change.
|
|
34
|
+
SOURCE_PATH_OVERRIDES={}
|
|
35
|
+
SOURCE_MIGRATIONS={}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def system_source_root():
|
|
39
|
+
packaged=files('msg.data').joinpath('system')
|
|
40
|
+
require(packaged.is_dir(),'system_source_missing')
|
|
41
|
+
return packaged
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def sync_system_sources(tx,contents,now, *, source_root=None,namespace_root=ROOT_SPACE,
|
|
45
|
+
source_paths=None,migrations=None):
|
|
46
|
+
"""Sync each release-owned document independently; never delete a source implicitly."""
|
|
47
|
+
root=Path(source_root or system_source_root())
|
|
48
|
+
source_paths=dict(SOURCE_PATH_OVERRIDES if source_paths is None else source_paths)
|
|
49
|
+
migrations=dict(SOURCE_MIGRATIONS if migrations is None else migrations)
|
|
50
|
+
require(set(source_paths)<=RULE_PATHS.keys() and set(migrations)<=RULE_PATHS.keys(),
|
|
51
|
+
'unknown_rule_id')
|
|
52
|
+
specs=[]
|
|
53
|
+
for rule_id,rid,parent,name,default_path,max_bytes in RULE_SPECS:
|
|
54
|
+
relative=source_paths.get(rule_id,default_path)
|
|
55
|
+
require(isinstance(relative,str) and relative.endswith('.md') and
|
|
56
|
+
relative==Path(relative).as_posix() and
|
|
57
|
+
not Path(relative).is_absolute() and '..' not in Path(relative).parts,
|
|
58
|
+
'system_source_invalid_path',rule_id)
|
|
59
|
+
specs.append((relative,rid,namespace_root if parent==ROOT_SPACE else parent,
|
|
60
|
+
name,rule_id,max_bytes))
|
|
61
|
+
require(len({s[0] for s in specs})==len(specs),'duplicate_system_source_path')
|
|
62
|
+
expected={s[0] for s in specs}
|
|
63
|
+
actual={p.relative_to(root).as_posix() for p in root.rglob('*.md')}
|
|
64
|
+
for relative in sorted(actual):
|
|
65
|
+
candidate=root/relative
|
|
66
|
+
require(not candidate.is_symlink() and candidate.resolve().is_relative_to(root.resolve()),
|
|
67
|
+
'system_source_invalid_path',relative)
|
|
68
|
+
observed={}
|
|
69
|
+
for relative in sorted(actual):
|
|
70
|
+
match=SOURCE_HEADER.match((root/relative).read_text(encoding='utf-8'))
|
|
71
|
+
require(match is not None,'system_source_invalid_header',relative)
|
|
72
|
+
rule_id=match.group(1)
|
|
73
|
+
require(rule_id in RULE_PATHS,'unknown_rule_id',relative)
|
|
74
|
+
require(rule_id not in observed,'duplicate_rule_id',rule_id)
|
|
75
|
+
observed[rule_id]=relative
|
|
76
|
+
require(not expected-actual,'system_source_deleted_requires_migration',
|
|
77
|
+
details={'paths':sorted(expected-actual)})
|
|
78
|
+
require(not actual-expected,'system_source_inventory_mismatch',
|
|
79
|
+
details={'unknown':sorted(actual-expected)})
|
|
80
|
+
prepared=[]
|
|
81
|
+
rule_ids=set()
|
|
82
|
+
for relative,rid,parent,name,expected_rule_id,max_bytes in specs:
|
|
83
|
+
path=root/relative
|
|
84
|
+
require(path.is_file() and not path.is_symlink(),'system_source_missing',relative)
|
|
85
|
+
raw=path.read_bytes()
|
|
86
|
+
require(len(raw)<=max_bytes,'system_source_too_large',relative)
|
|
87
|
+
try:
|
|
88
|
+
source_text=raw.decode('utf-8')
|
|
89
|
+
except UnicodeError as exc:
|
|
90
|
+
raise ValueError('system_source_invalid_utf8') from exc
|
|
91
|
+
header=SOURCE_HEADER.match(source_text)
|
|
92
|
+
require(header is not None,'system_source_invalid_header',relative)
|
|
93
|
+
rule_id,version=header.group(1),int(header.group(2))
|
|
94
|
+
require(rule_id in RULE_PATHS and rule_id==expected_rule_id,'unknown_rule_id',relative)
|
|
95
|
+
require(rule_id not in rule_ids,'duplicate_rule_id',rule_id)
|
|
96
|
+
rule_ids.add(rule_id)
|
|
97
|
+
note_match=re.search(r'<!-- change_note: ([^\n<>]{1,240}) -->',source_text)
|
|
98
|
+
change_note=note_match.group(1) if note_match else None
|
|
99
|
+
required=REQUIRES_HEADER.findall(source_text)
|
|
100
|
+
require(len(required)==source_text.count('<!-- requires_rules:'),
|
|
101
|
+
'dangling_requires_rules',rule_id)
|
|
102
|
+
require(len(required)<=1,'duplicate_requires_rules',rule_id)
|
|
103
|
+
dependencies=tuple(part.strip() for part in required[0].split(',')) if required else ()
|
|
104
|
+
require(all(dep in RULE_PATHS and dep!=rule_id for dep in dependencies) and
|
|
105
|
+
len(set(dependencies))==len(dependencies),'dangling_requires_rules',rule_id)
|
|
106
|
+
prepared.append((relative,rid,parent,name,rule_id,version,raw,change_note))
|
|
107
|
+
require(rule_ids==RULE_PATHS.keys(),'system_source_inventory_mismatch')
|
|
108
|
+
for rule_id,move in migrations.items():
|
|
109
|
+
require(isinstance(move,(list,tuple)) and len(move)==2 and
|
|
110
|
+
all(isinstance(p,str) for p in move) and
|
|
111
|
+
move[1]==source_paths.get(rule_id,next(s[4] for s in RULE_SPECS if s[0]==rule_id)),
|
|
112
|
+
'system_source_invalid_migration',rule_id)
|
|
113
|
+
for rid,name,mode in (('r_rules','_rules','0555'),('t_wiki','wiki','1777')):
|
|
114
|
+
await seed_resource(tx,contents,dict(id=rid,type='topic',name=name,parent=namespace_root,
|
|
115
|
+
owner=ROOT_SUBJECT,group=PUBLIC_GROUP,mode=mode),now)
|
|
116
|
+
tx.execute('''INSERT INTO topic_settings (topic,membership_policy) VALUES (?,?)
|
|
117
|
+
ON CONFLICT(topic) DO NOTHING''',(rid,'open'),write=True)
|
|
118
|
+
tx.execute('''INSERT INTO topic_memberships
|
|
119
|
+
(topic,subject,role,status,joined_at,invited_by) VALUES (?,?,?,?,?,?)
|
|
120
|
+
ON CONFLICT(topic,subject) DO NOTHING''',
|
|
121
|
+
(rid,ROOT_SUBJECT,'admin','active',wire(now),None),write=True)
|
|
122
|
+
desired=set()
|
|
123
|
+
for relative,rid,parent,name,rule_id,version,raw,change_note in prepared:
|
|
124
|
+
source_path='docs/system/'+relative
|
|
125
|
+
desired.add(source_path)
|
|
126
|
+
resource=await seed_resource(tx,contents,
|
|
127
|
+
dict(id=rid,type='file',name=name,parent=parent,
|
|
128
|
+
owner=ROOT_SUBJECT,group=PUBLIC_GROUP,mode='0444'),now)
|
|
129
|
+
source_digest=digest(raw)
|
|
130
|
+
old=tx.one('''SELECT source_path,rule_id,source_version,source_digest,revision_id
|
|
131
|
+
FROM system_sources WHERE resource_id=?''',(rid,))
|
|
132
|
+
if old is not None:
|
|
133
|
+
require(old[1]==rule_id,'system_source_identity_drift')
|
|
134
|
+
if old[0]!=source_path:
|
|
135
|
+
require(migrations.get(rule_id)==(old[0].removeprefix('docs/system/'),relative),
|
|
136
|
+
'system_source_migration_required',rule_id)
|
|
137
|
+
require(resource.revision==old[4],'system_source_pointer_drift')
|
|
138
|
+
if old[3]==source_digest:
|
|
139
|
+
require(old[2]==version,'system_source_version_drift')
|
|
140
|
+
if old[0]!=source_path:
|
|
141
|
+
tx.execute('UPDATE system_sources SET source_path=? WHERE resource_id=?',
|
|
142
|
+
(source_path,rid),write=True)
|
|
143
|
+
continue
|
|
144
|
+
require(version>old[2],'system_source_version_required')
|
|
145
|
+
blob=await contents.put_bytes(raw,'text/markdown')
|
|
146
|
+
revision_id='v_rel_'+digest((rid,source_digest,version))[7:39]
|
|
147
|
+
revision=Revision(format_version=1,id=revision_id,resource_id=rid,
|
|
148
|
+
parents=(resource.revision,) if resource.revision else (),content=blob,relations=(),
|
|
149
|
+
actor=ROOT_SUBJECT,subject=ROOT_SUBJECT,author=ROOT_SUBJECT,
|
|
150
|
+
created_at=now,manifest_digest='',source_kind='release',source_version=version,
|
|
151
|
+
source_digest=source_digest,change_note=change_note)
|
|
152
|
+
revision=replace(revision,manifest_digest=digest({k:v for k,v in wire(revision).items()
|
|
153
|
+
if k not in {'signature','manifest_digest'}}))
|
|
154
|
+
await contents.pin(blob,revision_id)
|
|
155
|
+
await contents.commit_revision(parent,revision)
|
|
156
|
+
await tx.append_revision(revision)
|
|
157
|
+
resource=replace(resource,generation=resource.generation+1,revision=revision_id,
|
|
158
|
+
modified_at=now,modified_by=ROOT_SUBJECT)
|
|
159
|
+
await tx.replace(resource,resource.generation-1)
|
|
160
|
+
if old is None:
|
|
161
|
+
tx.execute('''INSERT INTO system_sources
|
|
162
|
+
(resource_id,source_path,rule_id,source_kind,source_version,source_digest,revision_id)
|
|
163
|
+
VALUES (?,?,?,?,?,?,?)''',
|
|
164
|
+
(rid,source_path,rule_id,'release',version,source_digest,revision_id),write=True)
|
|
165
|
+
else:
|
|
166
|
+
tx.execute('''UPDATE system_sources SET source_path=?,source_kind='release',source_version=?,
|
|
167
|
+
source_digest=?,revision_id=? WHERE resource_id=?''',
|
|
168
|
+
(source_path,version,source_digest,revision_id,rid),write=True)
|
|
169
|
+
missing={path for (path,) in tx.rows('SELECT source_path FROM system_sources')} - desired
|
|
170
|
+
require(not missing,'system_source_deleted_requires_migration',details={'paths':sorted(missing)})
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def manifest():
|
|
174
|
+
return loads(files('msg.data').joinpath('bootstrap.json').read_bytes())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def feature_manifest(definition=None):
|
|
178
|
+
"""Validate the release inventory; a row is not proof that a feature works."""
|
|
179
|
+
definition=manifest() if definition is None else definition
|
|
180
|
+
resources={row['id'] for row in definition['resources']}
|
|
181
|
+
features=definition.get('features',())
|
|
182
|
+
require(isinstance(features,list) and bool(features),'feature_manifest_invalid')
|
|
183
|
+
seen=set()
|
|
184
|
+
fields={'feature_id','enabled_by_default','default_config','sample_resource',
|
|
185
|
+
'doctor_check','selftest_case'}
|
|
186
|
+
for feature in features:
|
|
187
|
+
require(isinstance(feature,dict) and set(feature)==fields,'feature_manifest_invalid')
|
|
188
|
+
feature_id=feature['feature_id']
|
|
189
|
+
require(isinstance(feature_id,str) and
|
|
190
|
+
re.fullmatch(r'[a-z][a-z0-9_]*',feature_id) is not None and
|
|
191
|
+
feature_id not in seen,'feature_manifest_invalid')
|
|
192
|
+
seen.add(feature_id)
|
|
193
|
+
require(type(feature['enabled_by_default']) is bool and
|
|
194
|
+
isinstance(feature['default_config'],dict),'feature_manifest_invalid')
|
|
195
|
+
require(feature['sample_resource'] is None or
|
|
196
|
+
feature['sample_resource'] in resources,'feature_manifest_invalid')
|
|
197
|
+
require(all(feature[name] is None or
|
|
198
|
+
(isinstance(feature[name],str) and bool(feature[name]))
|
|
199
|
+
for name in ('doctor_check','selftest_case')),'feature_manifest_invalid')
|
|
200
|
+
return tuple(features)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def seed_resource(tx,contents,data,now,body=None,media_type='text/markdown'):
|
|
204
|
+
mode=data['mode']
|
|
205
|
+
resource=Resource(**dict(data,mode=int(mode,8) if isinstance(mode,str) else mode,
|
|
206
|
+
type_version=1,generation=0,revision=None,state='active',created_at=now,created_by=ROOT_SUBJECT,
|
|
207
|
+
modified_at=now,modified_by=ROOT_SUBJECT))
|
|
208
|
+
found=tx.one('SELECT body FROM resources WHERE id=?',(resource.id,))
|
|
209
|
+
if found is not None:
|
|
210
|
+
previous=decode(Resource,loads(found[0]))
|
|
211
|
+
legacy_tools_name=(resource.id=='t_tools' and previous.name=='_tools' and
|
|
212
|
+
resource.name=='tools')
|
|
213
|
+
fields=('type','parent','owner','group','mode') if legacy_tools_name else (
|
|
214
|
+
'type','name','parent','owner','group','mode')
|
|
215
|
+
require(all(getattr(previous,n)==getattr(resource,n) for n in fields),
|
|
216
|
+
'bootstrap_drift',resource.id)
|
|
217
|
+
return previous
|
|
218
|
+
await tx.insert(resource)
|
|
219
|
+
if body is not None:
|
|
220
|
+
blob=await contents.put_bytes(body.encode() if isinstance(body,str) else body,media_type)
|
|
221
|
+
revision_id='v_boot_'+digest((resource.id,blob.digest))[7:39]
|
|
222
|
+
rev=Revision(format_version=1,id=revision_id,resource_id=resource.id,parents=(),content=blob,relations=(),
|
|
223
|
+
actor=ROOT_SUBJECT,subject=ROOT_SUBJECT,author=ROOT_SUBJECT,created_at=now,manifest_digest='')
|
|
224
|
+
rev=replace(rev,manifest_digest=digest({k:v for k,v in wire(rev).items() if k not in {'signature','manifest_digest'}}))
|
|
225
|
+
await contents.pin(blob,revision_id)
|
|
226
|
+
await contents.commit_revision(resource.parent or resource.id,rev)
|
|
227
|
+
await tx.append_revision(rev)
|
|
228
|
+
resource=replace(resource,generation=1,revision=rev.id)
|
|
229
|
+
await tx.replace(resource,0)
|
|
230
|
+
return resource
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def bootstrap(store,contents,registry,now, *, selftest_run_id=None):
|
|
234
|
+
definition=manifest()
|
|
235
|
+
feature_manifest(definition)
|
|
236
|
+
namespace_root='t_selftest_'+selftest_run_id if selftest_run_id is not None else ROOT_SPACE
|
|
237
|
+
async with store.transaction(write=True) as tx:
|
|
238
|
+
for data in definition['resources']:
|
|
239
|
+
if selftest_run_id is not None and data['id']!='r_root' and data['parent']==ROOT_SPACE:
|
|
240
|
+
data=dict(data,parent=namespace_root)
|
|
241
|
+
registry.resource_type(data['type'],1)
|
|
242
|
+
body=None
|
|
243
|
+
if data['type']=='tool':
|
|
244
|
+
from msg.extensions.tools import descriptor
|
|
245
|
+
body=canonical(descriptor(data['name'])).decode()
|
|
246
|
+
elif data['id']=='r_msg_entry_skill':
|
|
247
|
+
body=MSG_ENTRY_SKILL
|
|
248
|
+
await seed_resource(tx,contents,data,now,body,
|
|
249
|
+
'application/json' if data['type']=='tool' else 'text/markdown')
|
|
250
|
+
if selftest_run_id is not None and data['id']=='r_root':
|
|
251
|
+
await seed_resource(tx,contents,dict(id='t_selftest',type='topic',name='_test',
|
|
252
|
+
parent=ROOT_SPACE,owner=ROOT_SUBJECT,group=ADMINS_GROUP,mode='0711'),now)
|
|
253
|
+
await seed_resource(tx,contents,dict(id=namespace_root,type='topic',name=selftest_run_id,
|
|
254
|
+
parent='t_selftest',owner=ROOT_SUBJECT,group=ADMINS_GROUP,mode='0711'),now)
|
|
255
|
+
if selftest_run_id is None:
|
|
256
|
+
# Stable bootstrap IDs make repeated starts idempotent. The hosting
|
|
257
|
+
# read path uses these ordinary resources, not a special-case page.
|
|
258
|
+
sample_blob_digest=digest(ROOT_WEB_SAMPLE)
|
|
259
|
+
sample_revision='v_boot_'+digest(('f_root_web_index',sample_blob_digest))[7:39]
|
|
260
|
+
manifest_body=canonical({'format_version':1,'deployment':'t_root_web_deploy',
|
|
261
|
+
'entries':{'index.html':{'id':'f_root_web_index','revision':sample_revision}}})
|
|
262
|
+
await seed_resource(tx,contents,dict(id='w_root_web',type='website',name='web',parent='u_root',
|
|
263
|
+
owner=ROOT_SUBJECT,group=PUBLIC_GROUP,mode='0755'),now,manifest_body,'application/json')
|
|
264
|
+
await seed_resource(tx,contents,dict(id='t_root_web_deploy',type='topic',name='deploy-bootstrap',
|
|
265
|
+
parent='w_root_web',owner=ROOT_SUBJECT,group=PUBLIC_GROUP,mode='0755'),now)
|
|
266
|
+
await seed_resource(tx,contents,dict(id='f_root_web_index',type='file',name='index.html',
|
|
267
|
+
parent='t_root_web_deploy',owner=ROOT_SUBJECT,group=PUBLIC_GROUP,mode='0644'),
|
|
268
|
+
now,ROOT_WEB_SAMPLE,'text/html')
|
|
269
|
+
for data in definition['resources']:
|
|
270
|
+
if data['type']!='topic':
|
|
271
|
+
continue
|
|
272
|
+
tx.execute('''INSERT INTO topic_settings (topic,membership_policy) VALUES (?,?)
|
|
273
|
+
ON CONFLICT(topic) DO NOTHING''',(data['id'],'open'),write=True)
|
|
274
|
+
tx.execute('''INSERT INTO topic_memberships
|
|
275
|
+
(topic,subject,role,status,joined_at,invited_by) VALUES (?,?,?,?,?,?)
|
|
276
|
+
ON CONFLICT(topic,subject) DO NOTHING''',
|
|
277
|
+
(data['id'],ROOT_SUBJECT,'admin','active',wire(now),None),write=True)
|
|
278
|
+
for id,kind,primary,local in ((ROOT_SUBJECT,'system',ADMINS_GROUP,True),(ONLINE_CA,'system',ADMINS_GROUP,False)):
|
|
279
|
+
if not tx.one('SELECT id FROM identities WHERE id=?',(id,)):
|
|
280
|
+
await tx.update_identity(Subject(resource_id=id,kind=kind,primary_group=primary,auth_version=0,local_only=local),-1)
|
|
281
|
+
for id,builtin in ((PUBLIC_GROUP,'public'),(ADMINS_GROUP,'admins')):
|
|
282
|
+
if not tx.one('SELECT id FROM identities WHERE id=?',(id,)):
|
|
283
|
+
await tx.update_identity(Organization(resource_id=id,membership_version=0,builtin=builtin),-1)
|
|
284
|
+
for id,policy in definition['policies'].items():
|
|
285
|
+
if tx.setting('policy:'+id) is None:
|
|
286
|
+
tx.set_setting('policy:'+id,policy)
|
|
287
|
+
for name,body in definition['templates'].items():
|
|
288
|
+
resource=await seed_resource(tx,contents,dict(id='tpl_'+name,type='template',name=name,parent='t_templates',
|
|
289
|
+
owner=ROOT_SUBJECT,group=PUBLIC_GROUP,mode='0644'),now,body,'application/msg-template')
|
|
290
|
+
tx.set_setting(f'template_version:{resource.id}:1',resource.revision)
|
|
291
|
+
for key,schema in registry._schemas.items():
|
|
292
|
+
await seed_resource(tx,contents,dict(id=key,type='file',name=key.removeprefix('schema:'),parent='t_schema',
|
|
293
|
+
owner=ROOT_SUBJECT,group=PUBLIC_GROUP,mode='0444'),now,canonical(schema),'application/json')
|
|
294
|
+
await sync_system_sources(tx,contents,now,namespace_root=namespace_root)
|
|
295
|
+
tx.set_setting('bootstrap',{'version':definition['version'],'digest':digest(definition)})
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
MSG_ENTRY_SKILL = '''# msg-entry
|
|
299
|
+
|
|
300
|
+
Read /AGENTS.md first, then the compact operation index at /-/d. Open only
|
|
301
|
+
the relevant namespace or operation detail at /-/d/<namespace>/<operation> and inspect
|
|
302
|
+
/-/schema when exact fields are needed. Ordinary paths are read-only.
|
|
303
|
+
Use the local `msg` client when available so it signs requests and preserves
|
|
304
|
+
request IDs. A skill explains how to call an operation; it grants no authority.
|
|
305
|
+
'''
|