From 6ea463d6fa8b256cb07d0ede2d7795a5336eda4f Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 29 Sep 2026 05:00:55 +0800 Subject: [PATCH 1/4] test(client): verify a dependency-clean wheel and four signed transports Refs #158 #82 #83 #85. Add strict import, helper compatibility and server preflight regressions before changing implementation; retain all full-server CI gates. --- .github/workflows/client-boundary.yml | 72 +++++++++++++++ docs/CLIENT_BOUNDARY_PROGRESS.md | 21 +++++ scripts/check_client_install.py | 124 ++++++++++++++++++++++++++ tests/test_client_boundary.py | 123 +++++++++++++++++++++++++ 4 files changed, 340 insertions(+) create mode 100644 .github/workflows/client-boundary.yml create mode 100644 docs/CLIENT_BOUNDARY_PROGRESS.md create mode 100644 scripts/check_client_install.py create mode 100644 tests/test_client_boundary.py diff --git a/.github/workflows/client-boundary.yml b/.github/workflows/client-boundary.yml new file mode 100644 index 00000000..383239b6 --- /dev/null +++ b/.github/workflows/client-boundary.yml @@ -0,0 +1,72 @@ +name: Client boundary +on: + push: + branches: ['fix/client-boundary-*'] + pull_request: + paths: + - 'src/msg/**' + - 'pyproject.toml' + - 'tests/test_client_boundary.py' + - 'scripts/check_client_install.py' + - '.github/workflows/client-boundary.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: client-boundary-${{ github.ref }} + cancel-in-progress: true +jobs: + installed-client: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.15' + allow-prereleases: true + cache: pip + - name: Record tested source + run: | + mkdir -p artifacts + python - <<'PY' + import json, os, pathlib, subprocess + def git(*args): + return subprocess.check_output(['git', *args], text=True).strip() + pathlib.Path('artifacts/source.json').write_text(json.dumps({ + 'commit': git('rev-parse', 'HEAD'), 'tree': git('rev-parse', 'HEAD^{tree}'), + 'run_id': os.environ['GITHUB_RUN_ID'], 'event': os.environ['GITHUB_EVENT_NAME'], + }, indent=2) + '\n') + PY + - name: Install full development dependencies + run: python -m pip install -e '.[dev]' + - name: Verify helper ownership, exact proof context and fail-closed assembly + run: python -m pytest tests/test_client_boundary.py --junitxml=artifacts/client-boundary.xml + - name: Build the distributable wheel and source archive + if: always() + run: | + python -m build + python scripts/check_package_artifacts.py dist + - name: Install a clean client-only wheel outside the checkout + if: always() + run: | + python -m venv "$RUNNER_TEMP/msg-client-only" + client_python="$RUNNER_TEMP/msg-client-only/bin/python" + "$client_python" -m pip install dist/*.whl + "$client_python" -m pip check + mkdir -p "$RUNNER_TEMP/msg-client-outside" + cd "$RUNNER_TEMP/msg-client-outside" + "$RUNNER_TEMP/msg-client-only/bin/msg" --help > "$GITHUB_WORKSPACE/artifacts/client-help.txt" + "$RUNNER_TEMP/msg-client-only/bin/msgd" --help > "$GITHUB_WORKSPACE/artifacts/daemon-help.txt" + "$client_python" -I "$GITHUB_WORKSPACE/scripts/check_client_install.py" --minimal-install \ + > "$GITHUB_WORKSPACE/artifacts/client-install.json" + "$client_python" -m pip list --format=json > "$GITHUB_WORKSPACE/artifacts/client-packages.json" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: client-boundary + path: | + artifacts/ + dist/ diff --git a/docs/CLIENT_BOUNDARY_PROGRESS.md b/docs/CLIENT_BOUNDARY_PROGRESS.md new file mode 100644 index 00000000..11966c65 --- /dev/null +++ b/docs/CLIENT_BOUNDARY_PROGRESS.md @@ -0,0 +1,21 @@ +# Client dependency boundary progress — 2026-09-29 + +Refs #158/#82/#83/#85. Branch `fix/client-boundary-20260929` starts at tool-boundary head `f14fd1533f70983b046df4b4c813993e8a536d12` (#165). It does not write the #155 recovery, #156 executor or #166 metadata-session branches. + +## Regression-first work + +This commit adds seven boundary/compatibility/installation checks and a standalone fresh-interpreter client probe. It contains no implementation fix yet. The probe imports client/CLI/TUI modules under a strict server-import guard, creates/reloads protected client key files, and sends the same genuinely signed request through HTTP, path GET, GraphQL and MCP using an in-process mock HTTP endpoint. It never sends to a real service. + +The independent workflow builds the real wheel and installs it with normal dependencies into a fresh virtualenv outside the source checkout. It runs pip check, both command help entrypoints and the probe with explicit rejection of server-only packages. No PostgreSQL/Valkey service is configured for this workflow; full repository CI remains unchanged and mandatory. + +## Intended ownership and compatibility + +- Compact result serialization belongs to protocol codec; executor keeps only a compatibility import. +- Atomic protected local-file replacement has one narrowly named owner, not the Git/CAS backend. Existing permissions/fsync/replacement semantics remain unchanged. +- Custodial proof input/derivation and signed migration statements share one state-free owner; server-held vault and retirement verification stay server-side. Existing exports remain aliases, not copied security code. +- MCP version negotiation constants must not require importing the server implementation from the network client. +- Base installation expresses client dependencies; the server extra is the complete server install. The existing dev extra retains server dependencies and msg/msgd command names are unchanged. Documentation must explicitly migrate full server installation to the server extra; no automatic package installation. + +## Evidence still required + +Read the RED run before implementation. Then verify helper compatibility, four real client transport calls, a dependency-clean wheel environment, all existing credential journal/recovery and server tests, full exact-node-ID/conformance/build gates and current integration tree. No size, speed, Windows/Android field result or production readiness is claimed by this refactor. Keep #158 open until those facts are proved. diff --git a/scripts/check_client_install.py b/scripts/check_client_install.py new file mode 100644 index 00000000..f12ed662 --- /dev/null +++ b/scripts/check_client_install.py @@ -0,0 +1,124 @@ +"""Exercise real client imports and signed envelopes without a server runtime. + +Run with --minimal-install in a fresh wheel-only virtualenv, outside the source +checkout. Network traffic uses httpx.MockTransport, not a remote service. +""" +from __future__ import annotations + +import argparse +import asyncio +import importlib +import importlib.abc +import importlib.util +import json +from pathlib import Path +import stat +import sys +import tempfile + +SERVER_DEPENDENCIES = ('starlette', 'uvicorn', 'psycopg', 'valkey', 'aiohttp', 'dns', 'graphql') +FORBIDDEN = ('msg.application', 'msg.core.executor', 'msg.storage', 'msg.admin', + 'msg.workers', 'msg.security.vault', 'msg.security.custodial_migration', + 'msg.transports.http', 'msg.transports.graphql', 'msg.transports.mcp') + + +class ClientBoundary(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if any(fullname == name or fullname.startswith(name + '.') for name in FORBIDDEN): + raise RuntimeError('client imported a server implementation: ' + fullname) + return None + + +async def check(directory): + import httpx + from msg.client import ClientState + from msg.core.codec import canonical, loads, wire + from msg.core.requests import request_for, signing_bytes + from msg.security.crypto import Ed25519Signer, verify + from msg.transports.client import TRANSPORTS + from msg.transports.packet import decode_packet, path_packet + + for name in ('cli', 'tui', 'client_certificates', 'client_content', 'client_custodial', + 'client_market', 'client_recovery', 'client_secrets', 'client_tokens', 'client_upgrade'): + importlib.import_module('msg.' + name) + state = ClientState(directory, server='https://unit.invalid') + signer = Ed25519Signer.from_bytes(bytes(range(32))) + state.save_signer(signer) + state.ensure_encryption_key() + restored = ClientState(directory) + assert restored.signer.public_key == signer.public_key + assert restored.encryption_recipient == state.encryption_recipient + for path in (state.path, state.key_path, state.age_key_path): + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert stat.S_IMODE(directory.stat().st_mode) == 0o700 + request = request_for('content.read', {'id': 'r_test'}, state.server, + subject='u_test', signer=signer, request_id='client-boundary') + calls = [] + + def respond(incoming): + path = incoming.url.path + if path == '/_transports': + return httpx.Response(200, json={'version': 1, 'target_service': state.server, + 'operations': {'content.read': 'read'}, 'limits': { + 'max_request_bytes': 1048576, 'max_response_bytes': 1048576, + 'max_path_bytes': 8192, 'encodings': ['j', 'gz']}}) + if path.startswith('/-/g/'): + _, _, _, operation, encoding, encoded = path.split('/') + packet = path_packet(encoded, encoding, 1048576) + assert packet.operation == operation and incoming.method == 'GET' + kind = 'path_get' + else: + data = loads(incoming.content) + if path == '/_read/graphql': + data = data['variables']['packet'] + kind = 'graphql' + elif path == '/-/mcp': + assert incoming.headers['MCP-Protocol-Version'] == '2025-11-25' + data = data['params']['arguments']['packet'] + kind = 'mcp_http' + else: + assert path == '/-/p/content.read' + kind = 'http' + assert incoming.method == 'POST' + packet = decode_packet(data) + assert canonical(packet) == canonical(request) + verify(signer.public_key, signing_bytes(packet), packet.proof.signature, purpose='request') + calls.append(kind) + result = {'request_id': packet.request_id, 'operation': packet.operation, 'status': 'ok', + 'actor': 'u_test', 'subject': 'u_test', 'resources': [{'id': 'r_test'}], + 'data': {'content': 'signed client response'}} + if kind == 'graphql': + result = {'data': {'call': result}} + elif kind == 'mcp_http': + result = {'jsonrpc': '2.0', 'id': packet.request_id, + 'result': {'structuredContent': result}} + return httpx.Response(200, json=result) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http: + for name, transport in TRANSPORTS.items(): + result = await transport(state.server, http=http).call(request) + assert result.status == 'ok' and result.resources[0].id == 'r_test', (name, wire(result)) + assert result.data['content'] == 'signed client response' + assert sorted(calls) == sorted(TRANSPORTS) + assert not any(any(name == prefix or name.startswith(prefix + '.') for prefix in FORBIDDEN) + for name in sys.modules) + return calls + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--minimal-install', action='store_true') + args = parser.parse_args() + if args.minimal_install: + present = [name for name in SERVER_DEPENDENCIES if importlib.util.find_spec(name) is not None] + assert not present, 'server-only Python dependencies in client environment: ' + ', '.join(present) + sys.meta_path.insert(0, ClientBoundary()) + with tempfile.TemporaryDirectory(prefix='msg-client-install-') as folder: + calls = asyncio.run(check(Path(folder) / 'client')) + print(json.dumps({'status': 'ok', 'transports': sorted(calls), + 'server_implementation_imported': False, + 'minimal_install': args.minimal_install})) + + +if __name__ == '__main__': + main() diff --git a/tests/test_client_boundary.py b/tests/test_client_boundary.py new file mode 100644 index 00000000..02e46139 --- /dev/null +++ b/tests/test_client_boundary.py @@ -0,0 +1,123 @@ +"""Public helper ownership, unchanged bytes and supported client/server installs.""" +from __future__ import annotations + +import hashlib +import hmac +import importlib.util +from pathlib import Path +import subprocess +import sys +import tomllib + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +def test_result_serialization_has_one_protocol_owner(): + from msg.core.codec import result_wire + from msg.core.executor import result_wire as compatibility_export + from msg.core.models import OperationResult + from msg.cli import result_wire as client_export + + assert result_wire is compatibility_export is client_export + assert result_wire.__module__ == 'msg.core.codec' + result = OperationResult(request_id='r', operation='content.read', status='ok', actor=None, + subject=None, resources=(), data={'text': 'plain'}) + value = result_wire(result) + assert value['data'] == {'text': 'plain'} + assert 'replayed' not in value and 'prefer_cli' not in value + + +def test_atomic_file_owner_keeps_compatibility_and_failed_replace_protection(tmp_path, monkeypatch): + from msg.atomic_file import durable_write + from msg.storage.git import durable_write as compatibility_export + import msg.atomic_file as atomic + + assert durable_write is compatibility_export + path = tmp_path / 'state' / 'identity' + durable_write(path, b'first') + assert path.read_bytes() == b'first' and path.stat().st_mode & 0o777 == 0o600 + def fail_replace(*args): + raise OSError('simulated replace refusal') + monkeypatch.setattr(atomic.os, 'replace', fail_replace) + with pytest.raises(OSError, match='simulated replace refusal'): + durable_write(path, b'not committed') + assert path.read_bytes() == b'first' + assert list(path.parent.iterdir()) == [path] + + +def test_custodial_proof_and_statements_are_shared_without_vault_state(): + from msg.security.custodial_protocol import ( + UPGRADE_CONTEXT_FIELDS, _proof, client_upgrade_proof, stage_statement, ack_statement) + from msg.security.vault import client_upgrade_proof as old_proof + from msg.security.custodial_migration import stage_statement as old_stage, ack_statement as old_ack + from msg.core.codec import b64, canonical + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + + assert client_upgrade_proof is old_proof + assert stage_statement is old_stage and ack_statement is old_ack + assert client_upgrade_proof.__module__ == 'msg.security.custodial_protocol' + expected_fields = ('subject_id', 'challenge_id', 'request_id', 'handle', 'public_key', + 'encryption_recipient', 'server_public', 'nonce', 'expires_at') + assert UPGRADE_CONTEXT_FIELDS == expected_fields + challenge = {name: name + '-fixture' for name in expected_fields} + challenge['nonce'] = b64(bytes(range(32))) + challenge['not_signed'] = 'not part of this published proof context' + context = {name: challenge[name] for name in expected_fields} + shared = bytes(range(32, 64)) + key = HKDF(algorithm=hashes.SHA256(), length=32, salt=bytes(range(32)), + info=b'msg-custodial-upgrade-pop-v1').derive(shared) + expected = b64(hmac.digest(key, canonical(context), 'sha256')) + assert _proof(shared, challenge) == expected + assert stage_statement('u', {'choice': 'rewrap', 'migration_ack': 'omit'}, 'r') == { + 'domain': 'msg-custodial-decision-v1', 'subject_id': 'u', 'request_id': 'r', + 'decision': {'choice': 'rewrap'}} + assert ack_statement('u', 'c', 'd', {'id': 'old'}, {'id': 'new'}, 'p', 'r') == { + 'domain': 'msg-custodial-history-ack-v1', 'subject_id': 'u', 'challenge_id': 'c', + 'inventory_digest': 'd', 'source': {'id': 'old'}, 'target': {'id': 'new'}, + 'method': 'rewrap', 'plaintext_digest': 'p', 'request_id': 'r'} + + +def test_mcp_client_uses_neutral_version_declaration(): + from msg.transports.mcp_protocol import PROTOCOL_VERSION, SUPPORTED_VERSIONS + from msg.transports import mcp + assert PROTOCOL_VERSION == mcp.PROTOCOL_VERSION == '2025-11-25' + assert SUPPORTED_VERSIONS is mcp.SUPPORTED_VERSIONS + assert SUPPORTED_VERSIONS == frozenset({'2025-11-25', '2025-06-18', '2025-03-26'}) + + +def test_fresh_client_import_guard_covers_actual_four_transport_calls(): + completed = subprocess.run([sys.executable, str(ROOT / 'scripts/check_client_install.py')], + capture_output=True, text=True, timeout=30) + assert completed.returncode == 0, completed.stdout + completed.stderr + import json + report = json.loads(completed.stdout) + assert report['transports'] == ['graphql', 'http', 'mcp_http', 'path_get'] + assert report['server_implementation_imported'] is False + + +def test_install_metadata_separates_roles_but_dev_keeps_full_server(): + project = tomllib.loads((ROOT / 'pyproject.toml').read_text())['project'] + def name(requirement): + return requirement.split('>=')[0].split('[')[0] + server_names = {'starlette', 'uvicorn', 'psycopg', 'valkey', 'aiohttp', 'dnspython', 'graphql-core'} + assert not server_names & {name(value) for value in project['dependencies']} + extras = project['optional-dependencies'] + assert server_names <= {name(value) for value in extras['server']} + assert 'msg-lmm-best[server]' in extras['dev'] + assert project['scripts'] == {'msg': 'msg.cli:main', 'msgd': 'msg.daemon:main'} + + +def test_missing_server_dependencies_are_reported_before_local_state(tmp_path, monkeypatch, capsys): + import msg.daemon as daemon + original = importlib.util.find_spec + monkeypatch.setattr(importlib.util, 'find_spec', + lambda name, *args, **kwargs: None if name == 'psycopg' else original(name, *args, **kwargs)) + directory = tmp_path / 'not-created' + assert daemon.main(['--config-dir', str(directory), 'serve']) == 2 + captured = capsys.readouterr() + assert 'server_dependencies_required' in captured.err + assert 'msg-lmm-best[server]' in captured.err + assert not directory.exists() From 93d864b3b003310c6a139a918e5e14e65482e404 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 29 Sep 2026 05:14:48 +0800 Subject: [PATCH 2/4] ci(client): apply the reviewed dependency-boundary patch on its fixed cloud branch Refs #158 #82 #83 #85. Patch SHA-256 244778f572724b991a8b14ff3334b810a3af14329de999bf148c5445950c8a02. Restrict publishing to 19 enumerated files and this feature branch; remove the one-shot publisher in the generated source commit. Record RED and explicit full-server installation compatibility. --- .github/workflows/prepare-client-boundary.yml | 82 ++++ docs/CLIENT_BOUNDARY_PROGRESS.md | 24 +- docs/CLIENT_INSTALLATION.md | 31 ++ scripts/patches/client-boundary.patch | 456 ++++++++++++++++++ 4 files changed, 581 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/prepare-client-boundary.yml create mode 100644 docs/CLIENT_INSTALLATION.md create mode 100644 scripts/patches/client-boundary.patch diff --git a/.github/workflows/prepare-client-boundary.yml b/.github/workflows/prepare-client-boundary.yml new file mode 100644 index 00000000..34249493 --- /dev/null +++ b/.github/workflows/prepare-client-boundary.yml @@ -0,0 +1,82 @@ +name: Apply reviewed client boundary patch +on: + push: + branches: ['fix/client-boundary-20260929'] + paths: ['scripts/patches/client-boundary.patch'] +permissions: + contents: read +jobs: + apply-reviewed-patch: + if: github.repository == 'TokenNotIncluded/msg.lmm.best' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + fetch-depth: 2 + - name: Apply only the reviewed patch to its recorded parent + run: | + test "$(git rev-parse HEAD^)" = 6ea463d6fa8b256cb07d0ede2d7795a5336eda4f + git apply --index --check scripts/patches/client-boundary.patch + git apply --index scripts/patches/client-boundary.patch + git diff --cached --check + - name: Publish only this feature branch without force + env: + GH_TOKEN: ${{ github.token }} + run: | + python - <<'PY' + import hashlib, json, os, pathlib, subprocess, urllib.request + repo = 'TokenNotIncluded/msg.lmm.best' + branch = 'fix/client-boundary-20260929' + assert os.environ['GITHUB_REPOSITORY'] == repo + assert os.environ['GITHUB_REF'] == 'refs/heads/' + branch + paths = [ + 'README.md', 'docs/DEPLOYMENT.md', 'pyproject.toml', 'src/msg/atomic_file.py', + 'src/msg/cli.py', 'src/msg/client.py', 'src/msg/client_certificates.py', + 'src/msg/client_custodial.py', 'src/msg/client_upgrade.py', 'src/msg/core/codec.py', + 'src/msg/core/executor.py', 'src/msg/daemon.py', 'src/msg/security/custodial_migration.py', + 'src/msg/security/custodial_protocol.py', 'src/msg/security/vault.py', + 'src/msg/storage/git.py', 'src/msg/transports/client.py', 'src/msg/transports/mcp.py', + 'src/msg/transports/mcp_protocol.py', + ] + changed = subprocess.check_output(['git', 'diff', '--cached', '--name-only'], text=True).splitlines() + assert sorted(changed) == sorted(paths), changed + def api(method, endpoint, payload=None): + data = None if payload is None else json.dumps(payload).encode() + request = urllib.request.Request('https://api.github.com/repos/' + repo + endpoint, + data=data, method=method, headers={ + 'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], + 'Accept': 'application/vnd.github+json', 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + }) + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + parent = os.environ['GITHUB_SHA'] + assert api('GET', '/git/ref/heads/' + branch)['object']['sha'] == parent + old = api('GET', '/git/commits/' + parent) + assert [p['sha'] for p in old['parents']] == ['6ea463d6fa8b256cb07d0ede2d7795a5336eda4f'] + entries = [{'path': p, 'mode': '100644', 'type': 'blob', + 'content': pathlib.Path(p).read_text()} for p in paths] + for p in ['scripts/patches/client-boundary.patch', '.github/workflows/prepare-client-boundary.yml']: + entries.append({'path': p, 'mode': '100644', 'type': 'blob', 'sha': None}) + tree = api('POST', '/git/trees', {'base_tree': old['tree']['sha'], 'tree': entries}) + commit = api('POST', '/git/commits', { + 'message': 'refactor(client): isolate shared protocol helpers and client-only dependencies\n\nRefs #158 #82 #83 #85. Preserve signed bytes, credential persistence and explicit full-server installation.', + 'tree': tree['sha'], 'parents': [parent], + }) + api('PATCH', '/git/refs/heads/' + branch, {'sha': commit['sha'], 'force': False}) + pathlib.Path('artifacts').mkdir(exist_ok=True) + pathlib.Path('artifacts/patch-result.json').write_text(json.dumps({ + 'source': parent, 'commit': commit['sha'], 'tree': tree['sha'], + 'patch_sha256': hashlib.sha256(pathlib.Path('scripts/patches/client-boundary.patch').read_bytes()).hexdigest(), + 'paths': paths, + }, indent=2) + '\n') + print('Published reviewed source:', commit['sha'], tree['sha']) + PY + - uses: actions/upload-artifact@v4 + with: + name: client-patch-result + path: artifacts/ diff --git a/docs/CLIENT_BOUNDARY_PROGRESS.md b/docs/CLIENT_BOUNDARY_PROGRESS.md index 11966c65..c1f09106 100644 --- a/docs/CLIENT_BOUNDARY_PROGRESS.md +++ b/docs/CLIENT_BOUNDARY_PROGRESS.md @@ -1,21 +1,21 @@ # Client dependency boundary progress — 2026-09-29 -Refs #158/#82/#83/#85. Branch `fix/client-boundary-20260929` starts at tool-boundary head `f14fd1533f70983b046df4b4c813993e8a536d12` (#165). It does not write the #155 recovery, #156 executor or #166 metadata-session branches. +Refs #158/#82/#83/#85. Branch `fix/client-boundary-20260929` starts at tool head f14fd1533f70983b046df4b4c813993e8a536d12. #165 was subsequently merged as 08c58928ee3f6c6cb350d9e9fba75d072bd78f08 with the same exact tested tree; #160 is closed. Do not write #155 recovery, #169 executor or #166 metadata-session branches from this slice. -## Regression-first work +## Independently verified RED -This commit adds seven boundary/compatibility/installation checks and a standalone fresh-interpreter client probe. It contains no implementation fix yet. The probe imports client/CLI/TUI modules under a strict server-import guard, creates/reloads protected client key files, and sends the same genuinely signed request through HTTP, path GET, GraphQL and MCP using an in-process mock HTTP endpoint. It never sends to a real service. +- Head **6ea463d6fa8b256cb07d0ede2d7795a5336eda4f**, tree **2a89a0a36f2d87646cccd4fdbd5ed8420f31c9b9**. +- Cloud run **36483229167**, artifact **10998106395**, SHA-256 `449fc080ba7232cceea4c6f6444d1468fda03b6278112d1c9cbec96dcfdce112` was downloaded and independently verified. Source JSON matches this commit/tree; JUnit reports **7 failed / 0 errors / 0 skipped**, including the actual client import guard refusing server vault imports. This is expected regression-first failure, not a successful implementation. +- The real baseline wheel was built; minimal installation remains a failure because its base dependencies include server packages. No project code/test/build ran locally. -The independent workflow builds the real wheel and installs it with normal dependencies into a fresh virtualenv outside the source checkout. It runs pip check, both command help entrypoints and the probe with explicit rejection of server-only packages. No PostgreSQL/Valkey service is configured for this workflow; full repository CI remains unchanged and mandatory. +## Reviewed implementation -## Intended ownership and compatibility +A fixed-parent one-shot cloud patch applies only the 19 enumerated source/install-document files on this feature branch, with no force update or project execution under write credentials. The temporary publisher and patch are removed from the resulting source tree. Exact generated head/tree and artifact hash must be read before final PR verification. -- Compact result serialization belongs to protocol codec; executor keeps only a compatibility import. -- Atomic protected local-file replacement has one narrowly named owner, not the Git/CAS backend. Existing permissions/fsync/replacement semantics remain unchanged. -- Custodial proof input/derivation and signed migration statements share one state-free owner; server-held vault and retirement verification stay server-side. Existing exports remain aliases, not copied security code. -- MCP version negotiation constants must not require importing the server implementation from the network client. -- Base installation expresses client dependencies; the server extra is the complete server install. The existing dev extra retains server dependencies and msg/msgd command names are unchanged. Documentation must explicitly migrate full server installation to the server extra; no automatic package installation. +The change moves compact result encoding, atomic local-file replacement, state-free custodial proof/statement bytes and MCP version constants into their actual shared owners, retaining identity-preserving compatibility exports. Clients no longer import executor, Git storage or server vault for those helpers. Published bytes, credential file protections and server-held authority/state stay unchanged. Base metadata becomes client-only; server extra holds the original server constraints, dev still includes server, and daemon commands fail clearly before state creation when those dependencies are absent. See CLIENT_INSTALLATION.md for migration instructions. -## Evidence still required +## Remaining gates -Read the RED run before implementation. Then verify helper compatibility, four real client transport calls, a dependency-clean wheel environment, all existing credential journal/recovery and server tests, full exact-node-ID/conformance/build gates and current integration tree. No size, speed, Windows/Android field result or production readiness is claimed by this refactor. Keep #158 open until those facts are proved. +The independent workflow must prove all seven checks, real wheel outside the checkout, no server-only Python dependencies, both help entrypoints and four actual signed transport calls using an in-process mock endpoint. Full original server, journal/recovery, protocol and four-shard exact-node-ID/build gates remain mandatory. Review and verify the final combination with current main/#169 rather than treating this isolated branch as the combined result. No GREEN, merge or #158 closure is claimed at this checkpoint. + +No production deployment, Root/PIN, funds, real external notification, key destruction or recovery promotion was performed. Production and platform-field issues remain blocked on their own evidence. diff --git a/docs/CLIENT_INSTALLATION.md b/docs/CLIENT_INSTALLATION.md new file mode 100644 index 00000000..8b013e83 --- /dev/null +++ b/docs/CLIENT_INSTALLATION.md @@ -0,0 +1,31 @@ +# 客户端与服务端安装边界 + +本页对应 #158 的依赖边界调整,不表示新的协议版本或生产部署已经完成。Python 版本要求仍以 pyproject.toml 为准。 + +## 安装角色 + +仅连接远程 msg 服务的客户端,源码安装使用 `python -m pip install .` 或 `uv sync`。安装已经构建的发行文件时,使用 `python -m pip install /path/to/msg_lmm_best--py3-none-any.whl`。基础 Python 依赖只有 cryptography、httpx、jsonschema、referencing 及它们的传递依赖,不需要安装 Starlette、Uvicorn、psycopg、Valkey、aiohttp、dnspython 或 graphql-core,也不要求本机启动数据库。 + +完整服务端使用 `python -m pip install '.[server]'` 或 `uv sync --extra server`。发行包安装使用 `python -m pip install 'msg-lmm-best[server]'`,应选用已发布且与服务端部署匹配的版本。此前基础安装包含服务端依赖;升级安装脚本、镜像或虚拟环境时要显式加入 `server` extra,不能继续假定基础安装代表服务端。现有 `.[dev]` 仍拉入完整服务端依赖,原全量 CI 安装路径不变。 + +`msg`、`msgd` 两个命令入口都保留。`--help` 不要求服务端依赖。客户端环境误运行 `msgd` 的服务命令时,返回 `server_dependencies_required` 和安装提示;不会创建服务目录或偷偷安装依赖。安装 extra 只安装 Python 包,不启动数据库、不部署服务、不授予证书权限。`age`、Git、git-lfs、SSH、bubblewrap 等系统工具仍按具体功能的原要求提供;本次没有无加密、无隔离或空成功的降级实现。 + +## 共享能力归属 + +| 能力 | 唯一实现所有者 | 服务端兼容导出 | +| --- | --- | --- | +| OperationResult 的紧凑 wire 表示 | `msg.core.codec.result_wire` | `msg.core.executor.result_wire` | +| 受保护本地文件的原子替换和同步 | `msg.atomic_file.durable_write` | `msg.storage.git.durable_write` | +| 托管升级 PoP 上下文、派生及客户端证明 | `msg.security.custodial_protocol` | `msg.security.vault` 对应名称 | +| 托管迁移决策和 ACK 的签署语句 | `msg.security.custodial_protocol` | `msg.security.custodial_migration` 对应名称 | +| 已发布 MCP 协商版本集合 | `msg.transports.mcp_protocol` | `msg.transports.mcp` 对应名称 | + +兼容导出是同一函数或同一常量对象,不复制安全敏感实现。共享协议模块不持有服务器密钥、不接受 Application 或事务,也不验证销毁/退役事实。服务端 vault、权限复核、历史清单、恢复和事务发布继续留在原服务端模块。客户端签名仍绑定原 purpose、主体、请求和上下文;请求版本、签署字节、秘密一次释放、journal 与凭据权限语义不变。 + +CLI/TUI/签名客户端可以依赖 core 的协议模型、codec、requests、errors,客户端各功能模块、无服务状态的签名/证书/age/托管协议能力、atomic_file 及客户端传输。不能通过执行器、Git/CAS 存储、托管 vault、admin 或 workers 取得公共辅助函数。MCP HTTP 客户端读取版本常量也不再导入服务端适配器;stdio 的原行为与全量协议回归保留。 + +## 验证边界 + +`tests/test_client_boundary.py` 检查唯一实现、兼容导出、精确证明上下文、原子替换失败保护和缺失依赖前置错误。`scripts/check_client_install.py` 在全新解释器安装导入拦截器,再加载客户端功能、保存/重读受保护密钥,并通过四个真实传输实现发送同一个签名请求;HTTP 端点由进程内 MockTransport 提供,不访问生产站点。 + +`.github/workflows/client-boundary.yml` 构建实际 wheel/sdist,在源码目录之外的新虚拟环境中正常安装 wheel 并运行 pip check、两条 help 命令及四传输探针;`--minimal-install` 显式拒绝服务端 Python 依赖。全量 PostgreSQL/Valkey、CLI/MCP、凭据 journal/恢复、协议一致性与构建门禁仍必须通过。该验证不等于 Windows/Android 现场矩阵、外部网络、包体积/性能或生产验收;实际结果及精确提交记录在 CLIENT_BOUNDARY_PROGRESS.md 和对应 PR 中。 diff --git a/scripts/patches/client-boundary.patch b/scripts/patches/client-boundary.patch new file mode 100644 index 00000000..dbeb5aeb --- /dev/null +++ b/scripts/patches/client-boundary.patch @@ -0,0 +1,456 @@ +diff --git a/README.md b/README.md +index 74d7a1a..978ca32 100644 +--- a/README.md ++++ b/README.md +@@ -29,6 +29,15 @@ msg.lmm.best 是一个开放的交流空间。你可以发布想法、回复讨 + uv sync + ``` + ++基础安装仅包含签名客户端及传输所需的 Python 依赖,不要求本机运行 PostgreSQL 或 Valkey。 ++安装发行包时使用 `python -m pip install msg-lmm-best`。`age` 加密/备份等操作仍需相应系统工具, ++并不因为依赖分组而获得空实现或降级执行。 ++ ++部署完整服务端请使用 `uv sync --extra server`,或在源码目录安装 `python -m pip install '.[server]'`。 ++从此前默认包含服务端依赖的版本升级时,也应显式加入 `server` extra;`dev` extra 仍包含完整服务端依赖。 ++`msg`、`msgd` 的命令名和协议版本不变。仅客户端环境运行服务命令会提示缺少服务端依赖,而不会创建服务状态。 ++完整依赖与边界见[客户端安装说明](docs/CLIENT_INSTALLATION.md)。 ++ + 连接到**已启用这个版本**的服务。首次使用时将示例地址换成实际服务地址;客户端会保存服务地址和身份私钥,请妥善保管。 + + ```bash +diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md +index 32ac5ef..45db645 100644 +--- a/docs/DEPLOYMENT.md ++++ b/docs/DEPLOYMENT.md +@@ -27,7 +27,7 @@ service_keys = "/var/lib/msgd/service" + ```bash + sudo install -d -m 0755 /opt/msgd + sudo python3.15 -m venv /opt/msgd/venv +-sudo /opt/msgd/venv/bin/python -m pip install . ++sudo /opt/msgd/venv/bin/python -m pip install '.[server]' + ``` + + 在真实本机 VT 或串行控制台运行: +diff --git a/pyproject.toml b/pyproject.toml +index cfc5664..cb7cf9f 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -11,16 +11,9 @@ requires-python = ">=3.15" + license = "MIT" + dependencies = [ + "cryptography>=46,<52", +- "starlette>=0.50,<2", +- "uvicorn>=0.38,<1", + "httpx>=0.28,<1", + "jsonschema>=4.25,<5", + "referencing>=0.36,<1", +- "aiohttp>=3.13,<4", +- "dnspython>=2.8,<3", +- "graphql-core>=3.2.7,<3.3", +- "psycopg[binary]>=3.3,<4", +- "valkey>=6.1,<7", + ] + + [[project.authors]] +@@ -31,7 +24,17 @@ msg = "msg.cli:main" + msgd = "msg.daemon:main" + + [project.optional-dependencies] ++server = [ ++ "starlette>=0.50,<2", ++ "uvicorn>=0.38,<1", ++ "aiohttp>=3.13,<4", ++ "dnspython>=2.8,<3", ++ "graphql-core>=3.2.7,<3.3", ++ "psycopg[binary]>=3.3,<4", ++ "valkey>=6.1,<7", ++] + dev = [ ++ "msg-lmm-best[server]", + "pytest>=8.4", + "pytest-asyncio>=1.1", + "pytest-cov>=6", +diff --git a/src/msg/atomic_file.py b/src/msg/atomic_file.py +new file mode 100644 +index 0000000..beaf703 +--- /dev/null ++++ b/src/msg/atomic_file.py +@@ -0,0 +1,26 @@ ++"""Atomic local-file replacement with caller-selected mode and durable syncs.""" ++from __future__ import annotations ++ ++import os ++from pathlib import Path ++import tempfile ++ ++ ++def durable_write(path: Path, data: bytes, mode: int = 0o600): ++ path.parent.mkdir(parents=True,exist_ok=True) ++ fd,temporary=tempfile.mkstemp(prefix='.pending-',dir=path.parent) ++ try: ++ os.fchmod(fd,mode) ++ with os.fdopen(fd,'wb') as stream: ++ stream.write(data) ++ stream.flush() ++ os.fsync(stream.fileno()) ++ os.replace(temporary,path) ++ directory=os.open(path.parent,os.O_RDONLY|os.O_DIRECTORY) ++ try: ++ os.fsync(directory) ++ finally: ++ os.close(directory) ++ finally: ++ if os.path.exists(temporary): ++ os.unlink(temporary) +diff --git a/src/msg/cli.py b/src/msg/cli.py +index 9913a59..2f3d74f 100644 +--- a/src/msg/cli.py ++++ b/src/msg/cli.py +@@ -10,7 +10,7 @@ from pathlib import Path + from msg.client import ClientState,MsgClient + from msg.core.codec import canonical,loads,wire + from msg.core.errors import Failure,require +-from msg.core.executor import result_wire ++from msg.core.codec import result_wire + from msg.core.models import ResourceRef,OperationResult + from msg.transports.client import TRANSPORTS + +diff --git a/src/msg/client.py b/src/msg/client.py +index 144c0ff..326ad17 100644 +--- a/src/msg/client.py ++++ b/src/msg/client.py +@@ -20,8 +20,8 @@ from msg.core.requests import request_for + from msg.security.crypto import Ed25519Signer, subject_id, key_id + from msg.security.age_keys import (generate_age_key,recipient_from_identity, + public_from_recipient,encryption_key_id) +-from msg.security.vault import client_upgrade_proof +-from msg.storage.git import durable_write ++from msg.security.custodial_protocol import client_upgrade_proof ++from msg.atomic_file import durable_write + from msg.client_tokens import read_journal, remove_journal, token_operation + from msg.transports.client import HTTPTransport, GraphQLTransport, MCPHTTPTransport + +diff --git a/src/msg/client_certificates.py b/src/msg/client_certificates.py +index d8b9c30..75fa957 100644 +--- a/src/msg/client_certificates.py ++++ b/src/msg/client_certificates.py +@@ -12,7 +12,7 @@ from msg.core.errors import require + from msg.core.models import CertificateRequest,Certificate,Signature,CapabilityGrant,IssuancePolicy + from msg.security.certificates import csr_body,verify_csr,sign_certificate + from msg.security.crypto import Ed25519Signer,key_id +-from msg.storage.git import durable_write ++from msg.atomic_file import durable_write + + + def local_key(path, *, create=False): +diff --git a/src/msg/client_custodial.py b/src/msg/client_custodial.py +index f79430e..5c162dd 100644 +--- a/src/msg/client_custodial.py ++++ b/src/msg/client_custodial.py +@@ -10,9 +10,9 @@ from msg.client_secrets import write_private + from msg.core.codec import canonical, digest, loads, wire + from msg.core.errors import Failure, require + from msg.security.age_keys import encryption_key_id, public_from_recipient, recipient_from_identity +-from msg.security.custodial_migration import ack_statement, stage_statement +-from msg.security.vault import client_upgrade_proof +-from msg.storage.git import durable_write ++from msg.security.custodial_protocol import ack_statement, stage_statement ++from msg.security.custodial_protocol import client_upgrade_proof ++from msg.atomic_file import durable_write + + + def journal_for(state): +diff --git a/src/msg/client_upgrade.py b/src/msg/client_upgrade.py +index 03c5c92..b68e887 100644 +--- a/src/msg/client_upgrade.py ++++ b/src/msg/client_upgrade.py +@@ -8,7 +8,7 @@ from uuid import uuid4 + from msg.core.codec import b64, canonical, loads, wire + from msg.core.errors import Failure, require + from msg.security.crypto import Ed25519Signer +-from msg.storage.git import durable_write ++from msg.atomic_file import durable_write + + + @contextmanager +diff --git a/src/msg/core/codec.py b/src/msg/core/codec.py +index 133d30e..c3cdd9a 100644 +--- a/src/msg/core/codec.py ++++ b/src/msg/core/codec.py +@@ -251,3 +251,11 @@ def record(**kwargs): + cls.__post_init__ = validate_record + return dataclasses.dataclass(**kwargs)(cls) + return decorate ++ ++ ++def result_wire(result): ++ value=wire(result,compact=True) ++ for name in ('replayed','prefer_cli'): ++ if not value.get(name): ++ value.pop(name,None) ++ return value +diff --git a/src/msg/core/executor.py b/src/msg/core/executor.py +index d00ac08..5eb6ba4 100644 +--- a/src/msg/core/executor.py ++++ b/src/msg/core/executor.py +@@ -4,7 +4,7 @@ import logging + import time + from dataclasses import replace + from uuid import uuid4 +-from msg.core.codec import wire,digest ++from msg.core.codec import wire,digest,result_wire + from msg.core.errors import Failure,require + from msg.core.models import ExecutionContext,HandlerOutput,OperationResult,OperationError,Event,AccessRequirement + from msg.core.requests import SECRET_DELIVERY_MIN_VERSION,receipt_bytes +@@ -186,11 +186,3 @@ class OperationExecutor: + request_id=request.request_id,actor=principal.actor,subject=principal.subject,resources=result.resources,data={'operation':spec.name})) + await tx.save_result(principal.subject,request.payload_digest,result) + return replace(result,prefer_cli=request.source!='msg',cli_url='/AGENTS.md' if request.source!='msg' else None) +- +- +-def result_wire(result): +- value=wire(result,compact=True) +- for name in ('replayed','prefer_cli'): +- if not value.get(name): +- value.pop(name,None) +- return value +diff --git a/src/msg/daemon.py b/src/msg/daemon.py +index 637715d..fc6b26b 100644 +--- a/src/msg/daemon.py ++++ b/src/msg/daemon.py +@@ -1,6 +1,7 @@ + """msgd: local installation, serving, workers, diagnostics and restricted SSH.""" + from __future__ import annotations + import argparse ++import importlib.util + import asyncio + from datetime import UTC,datetime + import json +@@ -139,6 +140,11 @@ def parser(): + + def main(argv=None): + args=parser().parse_args(argv) ++ server_modules=('starlette','uvicorn','psycopg','valkey','aiohttp','dns','graphql') ++ if any(importlib.util.find_spec(name) is None for name in server_modules): ++ print(canonical({'status':'error','error':{'code':'server_dependencies_required', ++ 'hint':"Install msg-lmm-best[server] before running server commands."}}).decode(),file=sys.stderr) ++ return 2 + try: + if args.command in {'doctor','selftest'}: + from msg.admin.diagnostics import doctor,selftest +diff --git a/src/msg/security/custodial_migration.py b/src/msg/security/custodial_migration.py +index bcff825..022d59a 100644 +--- a/src/msg/security/custodial_migration.py ++++ b/src/msg/security/custodial_migration.py +@@ -13,6 +13,7 @@ from msg.core.codec import canonical, decode, digest, loads, unb64, wire + from msg.core.errors import Failure, require + from msg.core.models import ResourceRef, Signature + from msg.security.crypto import key_id, verify ++from msg.security.custodial_protocol import stage_statement, ack_statement + from msg.security.age_keys import encryption_key_id, public_from_recipient + from msg.security.backup_retirement import BackupRetirement, verified as verified_backup + +@@ -43,22 +44,6 @@ def inventory_commitment(subject, details, challenge): + 'source_keys': details.get('source_keys', {})}) + + +-def stage_statement(subject, arguments, request_id): +- """Sign every decision field, not a caller-selected subset of the manifest.""" +- return {'domain': 'msg-custodial-decision-v1', 'subject_id': subject, +- 'request_id': request_id, +- 'decision': {key: value for key, value in arguments.items() +- if key != 'migration_ack'}} +- +- +-def ack_statement(subject, challenge_id, inventory_digest, source, target, +- plaintext_digest, request_id, *, method='rewrap'): +- return {'domain': 'msg-custodial-history-ack-v1', 'subject_id': subject, +- 'challenge_id': challenge_id, 'inventory_digest': inventory_digest, +- 'source': source, 'target': target, 'method': method, +- 'plaintext_digest': plaintext_digest, 'request_id': request_id} +- +- + def keyed_source(item, details): + return dict(item, key_id=details.get('source_keys', {}).get( + item['revision'], details['old_encryption_key_id'])) +diff --git a/src/msg/security/custodial_protocol.py b/src/msg/security/custodial_protocol.py +new file mode 100644 +index 0000000..80821ca +--- /dev/null ++++ b/src/msg/security/custodial_protocol.py +@@ -0,0 +1,49 @@ ++"""Published custodial proof and statement bytes shared by both endpoints. ++ ++This module has no server-held key, transaction, current-authority or retirement ++state. Producing a proof does not verify authority or attest key destruction. ++""" ++from __future__ import annotations ++ ++import hmac ++from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PublicKey ++from cryptography.hazmat.primitives.kdf.hkdf import HKDF ++from cryptography.hazmat.primitives import hashes ++from msg.core.codec import b64, canonical, unb64 ++from msg.security.age_keys import private_from_identity ++ ++ ++UPGRADE_CONTEXT_FIELDS=('subject_id','challenge_id','request_id','handle','public_key', ++ 'encryption_recipient','server_public','nonce','expires_at') ++ ++ ++def _proof(shared,challenge): ++ context={name:challenge[name] for name in UPGRADE_CONTEXT_FIELDS} ++ key=HKDF(algorithm=hashes.SHA256(),length=32, ++ salt=unb64(context['nonce'],limit=64), ++ info=b'msg-custodial-upgrade-pop-v1').derive(shared) ++ return b64(hmac.digest(key,canonical(context),'sha256')) ++ ++ ++def client_upgrade_proof(age_identity,challenge): ++ private=private_from_identity(age_identity) ++ shared=private.exchange(X25519PublicKey.from_public_bytes( ++ unb64(challenge['server_public'],limit=32))) ++ return _proof(shared,challenge) ++ ++ ++ ++def stage_statement(subject, arguments, request_id): ++ """Sign every decision field, not a caller-selected subset of the manifest.""" ++ return {'domain': 'msg-custodial-decision-v1', 'subject_id': subject, ++ 'request_id': request_id, ++ 'decision': {key: value for key, value in arguments.items() ++ if key != 'migration_ack'}} ++ ++ ++def ack_statement(subject, challenge_id, inventory_digest, source, target, ++ plaintext_digest, request_id, *, method='rewrap'): ++ return {'domain': 'msg-custodial-history-ack-v1', 'subject_id': subject, ++ 'challenge_id': challenge_id, 'inventory_digest': inventory_digest, ++ 'source': source, 'target': target, 'method': method, ++ 'plaintext_digest': plaintext_digest, 'request_id': request_id} +diff --git a/src/msg/security/vault.py b/src/msg/security/vault.py +index baff9a4..c61f454 100644 +--- a/src/msg/security/vault.py ++++ b/src/msg/security/vault.py +@@ -2,7 +2,6 @@ + from __future__ import annotations + + import os +-import hmac + import shutil + import subprocess + import tempfile +@@ -11,12 +10,11 @@ from pathlib import Path + from cryptography.exceptions import InvalidTag + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey,X25519PublicKey +-from cryptography.hazmat.primitives.kdf.hkdf import HKDF +-from cryptography.hazmat.primitives import hashes + + from msg.core.codec import b64,canonical,unb64,wire + from msg.core.errors import Failure,require +-from msg.security.age_keys import recipient_from_identity,private_from_identity ++from msg.security.age_keys import recipient_from_identity ++from msg.security.custodial_protocol import UPGRADE_CONTEXT_FIELDS, _proof, client_upgrade_proof + from msg.security.crypto import Ed25519Signer + + +@@ -128,25 +126,6 @@ def open_upgrade_private(app,subject,challenge_id,nonce,ciphertext): + raise Failure('custodial_upgrade_challenge_corrupt') from exc + + +-UPGRADE_CONTEXT_FIELDS=('subject_id','challenge_id','request_id','handle','public_key', +- 'encryption_recipient','server_public','nonce','expires_at') +- +- +-def _proof(shared,challenge): +- context={name:challenge[name] for name in UPGRADE_CONTEXT_FIELDS} +- key=HKDF(algorithm=hashes.SHA256(),length=32, +- salt=unb64(context['nonce'],limit=64), +- info=b'msg-custodial-upgrade-pop-v1').derive(shared) +- return b64(hmac.digest(key,canonical(context),'sha256')) +- +- +-def client_upgrade_proof(age_identity,challenge): +- private=private_from_identity(age_identity) +- shared=private.exchange(X25519PublicKey.from_public_bytes( +- unb64(challenge['server_public'],limit=32))) +- return _proof(shared,challenge) +- +- + def server_upgrade_proof(app,subject,challenge,nonce,ciphertext): + from msg.security.age_keys import public_from_recipient + private=open_upgrade_private(app,subject,challenge['challenge_id'],nonce,ciphertext) +diff --git a/src/msg/storage/git.py b/src/msg/storage/git.py +index 9846a21..43d51ca 100644 +--- a/src/msg/storage/git.py ++++ b/src/msg/storage/git.py +@@ -17,26 +17,7 @@ from pathlib import Path + from msg.core.codec import canonical, digest, loads + from msg.core.errors import Failure, require + from msg.core.models import BlobRef +- +- +-def durable_write(path: Path, data: bytes, mode: int = 0o600): +- path.parent.mkdir(parents=True,exist_ok=True) +- fd,temporary=tempfile.mkstemp(prefix='.pending-',dir=path.parent) +- try: +- os.fchmod(fd,mode) +- with os.fdopen(fd,'wb') as stream: +- stream.write(data) +- stream.flush() +- os.fsync(stream.fileno()) +- os.replace(temporary,path) +- directory=os.open(path.parent,os.O_RDONLY|os.O_DIRECTORY) +- try: +- os.fsync(directory) +- finally: +- os.close(directory) +- finally: +- if os.path.exists(temporary): +- os.unlink(temporary) ++from msg.atomic_file import durable_write + + + class LFSObjectStore: +diff --git a/src/msg/transports/client.py b/src/msg/transports/client.py +index dee3f63..546fbcf 100644 +--- a/src/msg/transports/client.py ++++ b/src/msg/transports/client.py +@@ -163,7 +163,7 @@ class MCPHTTPTransport(HTTPTransport): + async def call(self, request): + self._secure_delivery(request) + await self._effect(request.operation) +- from msg.transports.mcp import PROTOCOL_VERSION ++ from msg.transports.mcp_protocol import PROTOCOL_VERSION + tool_name = request.operation if request.contract_version==1 else ( + f'{request.operation}@{request.contract_version}') + value = await self._json('POST','/-/mcp',body={'jsonrpc':'2.0','id':request.request_id, +diff --git a/src/msg/transports/mcp.py b/src/msg/transports/mcp.py +index 8474f57..aa81f77 100644 +--- a/src/msg/transports/mcp.py ++++ b/src/msg/transports/mcp.py +@@ -9,11 +9,10 @@ from dataclasses import replace + from msg import __version__ + from msg.core.codec import canonical,digest,loads + from msg.core.errors import Failure,require +-from msg.core.executor import result_wire ++from msg.core.codec import result_wire + from msg.transports.packet import decode_packet,REQUEST_SCHEMA,RESULT_SCHEMA + +-PROTOCOL_VERSION='2025-11-25' +-SUPPORTED_VERSIONS=frozenset({PROTOCOL_VERSION,'2025-06-18','2025-03-26'}) ++from msg.transports.mcp_protocol import PROTOCOL_VERSION, SUPPORTED_VERSIONS + + + class MCPServer: +diff --git a/src/msg/transports/mcp_protocol.py b/src/msg/transports/mcp_protocol.py +new file mode 100644 +index 0000000..7eca1c5 +--- /dev/null ++++ b/src/msg/transports/mcp_protocol.py +@@ -0,0 +1,4 @@ ++"""Published MCP negotiation versions; no server or client runtime imports.""" ++ ++PROTOCOL_VERSION='2025-11-25' ++SUPPORTED_VERSIONS=frozenset({PROTOCOL_VERSION,'2025-06-18','2025-03-26'}) From 649a85da210bdc5d13f84a4339719f9628b23b7f Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 29 Sep 2026 05:23:27 +0800 Subject: [PATCH 3/4] fix(ci): commit client dependency metadata in the repository's original format The source archive normalizes pyproject formatting; cloud run36484923358 refused that hunk before any publish. Commit the same reviewed dependency split against the live repository file and apply the unchanged verified source patch excluding only that already-committed metadata. Refs #158 #83. --- .github/workflows/prepare-client-boundary.yml | 28 +++++++++++++------ pyproject.toml | 5 ++-- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.github/workflows/prepare-client-boundary.yml b/.github/workflows/prepare-client-boundary.yml index 34249493..5b10f23e 100644 --- a/.github/workflows/prepare-client-boundary.yml +++ b/.github/workflows/prepare-client-boundary.yml @@ -2,7 +2,9 @@ name: Apply reviewed client boundary patch on: push: branches: ['fix/client-boundary-20260929'] - paths: ['scripts/patches/client-boundary.patch'] + paths: + - 'scripts/patches/client-boundary.patch' + - '.github/workflows/prepare-client-boundary.yml' permissions: contents: read jobs: @@ -17,24 +19,25 @@ jobs: with: persist-credentials: false fetch-depth: 2 - - name: Apply only the reviewed patch to its recorded parent + - name: Apply reviewed source patch after explicit repository-format metadata commit run: | - test "$(git rev-parse HEAD^)" = 6ea463d6fa8b256cb07d0ede2d7795a5336eda4f - git apply --index --check scripts/patches/client-boundary.patch - git apply --index scripts/patches/client-boundary.patch + test "$(git rev-parse HEAD^)" = 93d864b3b003310c6a139a918e5e14e65482e404 + echo '244778f572724b991a8b14ff3334b810a3af14329de999bf148c5445950c8a02 scripts/patches/client-boundary.patch' | sha256sum --check + git apply --index --check --exclude=pyproject.toml scripts/patches/client-boundary.patch + git apply --index --exclude=pyproject.toml scripts/patches/client-boundary.patch git diff --cached --check - name: Publish only this feature branch without force env: GH_TOKEN: ${{ github.token }} run: | python - <<'PY' - import hashlib, json, os, pathlib, subprocess, urllib.request + import hashlib, json, os, pathlib, subprocess, tomllib, urllib.request repo = 'TokenNotIncluded/msg.lmm.best' branch = 'fix/client-boundary-20260929' assert os.environ['GITHUB_REPOSITORY'] == repo assert os.environ['GITHUB_REF'] == 'refs/heads/' + branch paths = [ - 'README.md', 'docs/DEPLOYMENT.md', 'pyproject.toml', 'src/msg/atomic_file.py', + 'README.md', 'docs/DEPLOYMENT.md', 'src/msg/atomic_file.py', 'src/msg/cli.py', 'src/msg/client.py', 'src/msg/client_certificates.py', 'src/msg/client_custodial.py', 'src/msg/client_upgrade.py', 'src/msg/core/codec.py', 'src/msg/core/executor.py', 'src/msg/daemon.py', 'src/msg/security/custodial_migration.py', @@ -44,6 +47,13 @@ jobs: ] changed = subprocess.check_output(['git', 'diff', '--cached', '--name-only'], text=True).splitlines() assert sorted(changed) == sorted(paths), changed + metadata = tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project'] + assert metadata['dependencies'] == ['cryptography>=46,<52', 'httpx>=0.28,<1', + 'jsonschema>=4.25,<5', 'referencing>=0.36,<1'] + assert metadata['optional-dependencies']['server'] == [ + 'starlette>=0.50,<2', 'uvicorn>=0.38,<1', 'aiohttp>=3.13,<4', 'dnspython>=2.8,<3', + 'graphql-core>=3.2.7,<3.3', 'psycopg[binary]>=3.3,<4', 'valkey>=6.1,<7'] + assert metadata['optional-dependencies']['dev'][0] == 'msg-lmm-best[server]' def api(method, endpoint, payload=None): data = None if payload is None else json.dumps(payload).encode() request = urllib.request.Request('https://api.github.com/repos/' + repo + endpoint, @@ -57,7 +67,7 @@ jobs: parent = os.environ['GITHUB_SHA'] assert api('GET', '/git/ref/heads/' + branch)['object']['sha'] == parent old = api('GET', '/git/commits/' + parent) - assert [p['sha'] for p in old['parents']] == ['6ea463d6fa8b256cb07d0ede2d7795a5336eda4f'] + assert [p['sha'] for p in old['parents']] == ['93d864b3b003310c6a139a918e5e14e65482e404'] entries = [{'path': p, 'mode': '100644', 'type': 'blob', 'content': pathlib.Path(p).read_text()} for p in paths] for p in ['scripts/patches/client-boundary.patch', '.github/workflows/prepare-client-boundary.yml']: @@ -72,7 +82,7 @@ jobs: pathlib.Path('artifacts/patch-result.json').write_text(json.dumps({ 'source': parent, 'commit': commit['sha'], 'tree': tree['sha'], 'patch_sha256': hashlib.sha256(pathlib.Path('scripts/patches/client-boundary.patch').read_bytes()).hexdigest(), - 'paths': paths, + 'paths': paths, 'metadata_committed_before_patch': True, }, indent=2) + '\n') print('Published reviewed source:', commit['sha'], tree['sha']) PY diff --git a/pyproject.toml b/pyproject.toml index dac7c944..43d0c21a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,14 +10,15 @@ readme = "README.md" requires-python = ">=3.15" license = "MIT" authors = [{name="TokenNotIncluded"}] -dependencies = ["cryptography>=46,<52", "starlette>=0.50,<2", "uvicorn>=0.38,<1", "httpx>=0.28,<1", "jsonschema>=4.25,<5", "referencing>=0.36,<1", "aiohttp>=3.13,<4", "dnspython>=2.8,<3", "graphql-core>=3.2.7,<3.3", "psycopg[binary]>=3.3,<4", "valkey>=6.1,<7"] +dependencies = ["cryptography>=46,<52", "httpx>=0.28,<1", "jsonschema>=4.25,<5", "referencing>=0.36,<1"] [project.scripts] msg = "msg.cli:main" msgd = "msg.daemon:main" [project.optional-dependencies] -dev = ["pytest>=8.4", "pytest-asyncio>=1.1", "pytest-cov>=6", "ruff>=0.14", "build>=1.2", "tiktoken>=0.12"] +server = ["starlette>=0.50,<2", "uvicorn>=0.38,<1", "aiohttp>=3.13,<4", "dnspython>=2.8,<3", "graphql-core>=3.2.7,<3.3", "psycopg[binary]>=3.3,<4", "valkey>=6.1,<7"] +dev = ["msg-lmm-best[server]", "pytest>=8.4", "pytest-asyncio>=1.1", "pytest-cov>=6", "ruff>=0.14", "build>=1.2", "tiktoken>=0.12"] [tool.uv.build-backend] module-name = "msg" From 5f56bbe4ea1cddc46bed12d29026589d65485d62 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:24:52 +0000 Subject: [PATCH 4/4] refactor(client): isolate shared protocol helpers and client-only dependencies Refs #158 #82 #83 #85. Preserve signed bytes, credential persistence and explicit full-server installation. --- .github/workflows/prepare-client-boundary.yml | 92 ---- README.md | 9 + docs/DEPLOYMENT.md | 2 +- scripts/patches/client-boundary.patch | 456 ------------------ src/msg/atomic_file.py | 26 + src/msg/cli.py | 2 +- src/msg/client.py | 4 +- src/msg/client_certificates.py | 2 +- src/msg/client_custodial.py | 6 +- src/msg/client_upgrade.py | 2 +- src/msg/core/codec.py | 8 + src/msg/core/executor.py | 10 +- src/msg/daemon.py | 6 + src/msg/security/custodial_migration.py | 17 +- src/msg/security/custodial_protocol.py | 49 ++ src/msg/security/vault.py | 25 +- src/msg/storage/git.py | 21 +- src/msg/transports/client.py | 2 +- src/msg/transports/mcp.py | 5 +- src/msg/transports/mcp_protocol.py | 4 + 20 files changed, 119 insertions(+), 629 deletions(-) delete mode 100644 .github/workflows/prepare-client-boundary.yml delete mode 100644 scripts/patches/client-boundary.patch create mode 100644 src/msg/atomic_file.py create mode 100644 src/msg/security/custodial_protocol.py create mode 100644 src/msg/transports/mcp_protocol.py diff --git a/.github/workflows/prepare-client-boundary.yml b/.github/workflows/prepare-client-boundary.yml deleted file mode 100644 index 5b10f23e..00000000 --- a/.github/workflows/prepare-client-boundary.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: Apply reviewed client boundary patch -on: - push: - branches: ['fix/client-boundary-20260929'] - paths: - - 'scripts/patches/client-boundary.patch' - - '.github/workflows/prepare-client-boundary.yml' -permissions: - contents: read -jobs: - apply-reviewed-patch: - if: github.repository == 'TokenNotIncluded/msg.lmm.best' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: write - steps: - - uses: actions/checkout@v7 - with: - persist-credentials: false - fetch-depth: 2 - - name: Apply reviewed source patch after explicit repository-format metadata commit - run: | - test "$(git rev-parse HEAD^)" = 93d864b3b003310c6a139a918e5e14e65482e404 - echo '244778f572724b991a8b14ff3334b810a3af14329de999bf148c5445950c8a02 scripts/patches/client-boundary.patch' | sha256sum --check - git apply --index --check --exclude=pyproject.toml scripts/patches/client-boundary.patch - git apply --index --exclude=pyproject.toml scripts/patches/client-boundary.patch - git diff --cached --check - - name: Publish only this feature branch without force - env: - GH_TOKEN: ${{ github.token }} - run: | - python - <<'PY' - import hashlib, json, os, pathlib, subprocess, tomllib, urllib.request - repo = 'TokenNotIncluded/msg.lmm.best' - branch = 'fix/client-boundary-20260929' - assert os.environ['GITHUB_REPOSITORY'] == repo - assert os.environ['GITHUB_REF'] == 'refs/heads/' + branch - paths = [ - 'README.md', 'docs/DEPLOYMENT.md', 'src/msg/atomic_file.py', - 'src/msg/cli.py', 'src/msg/client.py', 'src/msg/client_certificates.py', - 'src/msg/client_custodial.py', 'src/msg/client_upgrade.py', 'src/msg/core/codec.py', - 'src/msg/core/executor.py', 'src/msg/daemon.py', 'src/msg/security/custodial_migration.py', - 'src/msg/security/custodial_protocol.py', 'src/msg/security/vault.py', - 'src/msg/storage/git.py', 'src/msg/transports/client.py', 'src/msg/transports/mcp.py', - 'src/msg/transports/mcp_protocol.py', - ] - changed = subprocess.check_output(['git', 'diff', '--cached', '--name-only'], text=True).splitlines() - assert sorted(changed) == sorted(paths), changed - metadata = tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project'] - assert metadata['dependencies'] == ['cryptography>=46,<52', 'httpx>=0.28,<1', - 'jsonschema>=4.25,<5', 'referencing>=0.36,<1'] - assert metadata['optional-dependencies']['server'] == [ - 'starlette>=0.50,<2', 'uvicorn>=0.38,<1', 'aiohttp>=3.13,<4', 'dnspython>=2.8,<3', - 'graphql-core>=3.2.7,<3.3', 'psycopg[binary]>=3.3,<4', 'valkey>=6.1,<7'] - assert metadata['optional-dependencies']['dev'][0] == 'msg-lmm-best[server]' - def api(method, endpoint, payload=None): - data = None if payload is None else json.dumps(payload).encode() - request = urllib.request.Request('https://api.github.com/repos/' + repo + endpoint, - data=data, method=method, headers={ - 'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], - 'Accept': 'application/vnd.github+json', 'Content-Type': 'application/json', - 'X-GitHub-Api-Version': '2022-11-28', - }) - with urllib.request.urlopen(request, timeout=60) as response: - return json.load(response) - parent = os.environ['GITHUB_SHA'] - assert api('GET', '/git/ref/heads/' + branch)['object']['sha'] == parent - old = api('GET', '/git/commits/' + parent) - assert [p['sha'] for p in old['parents']] == ['93d864b3b003310c6a139a918e5e14e65482e404'] - entries = [{'path': p, 'mode': '100644', 'type': 'blob', - 'content': pathlib.Path(p).read_text()} for p in paths] - for p in ['scripts/patches/client-boundary.patch', '.github/workflows/prepare-client-boundary.yml']: - entries.append({'path': p, 'mode': '100644', 'type': 'blob', 'sha': None}) - tree = api('POST', '/git/trees', {'base_tree': old['tree']['sha'], 'tree': entries}) - commit = api('POST', '/git/commits', { - 'message': 'refactor(client): isolate shared protocol helpers and client-only dependencies\n\nRefs #158 #82 #83 #85. Preserve signed bytes, credential persistence and explicit full-server installation.', - 'tree': tree['sha'], 'parents': [parent], - }) - api('PATCH', '/git/refs/heads/' + branch, {'sha': commit['sha'], 'force': False}) - pathlib.Path('artifacts').mkdir(exist_ok=True) - pathlib.Path('artifacts/patch-result.json').write_text(json.dumps({ - 'source': parent, 'commit': commit['sha'], 'tree': tree['sha'], - 'patch_sha256': hashlib.sha256(pathlib.Path('scripts/patches/client-boundary.patch').read_bytes()).hexdigest(), - 'paths': paths, 'metadata_committed_before_patch': True, - }, indent=2) + '\n') - print('Published reviewed source:', commit['sha'], tree['sha']) - PY - - uses: actions/upload-artifact@v4 - with: - name: client-patch-result - path: artifacts/ diff --git a/README.md b/README.md index 74d7a1a8..978ca324 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,15 @@ msg.lmm.best 是一个开放的交流空间。你可以发布想法、回复讨 uv sync ``` +基础安装仅包含签名客户端及传输所需的 Python 依赖,不要求本机运行 PostgreSQL 或 Valkey。 +安装发行包时使用 `python -m pip install msg-lmm-best`。`age` 加密/备份等操作仍需相应系统工具, +并不因为依赖分组而获得空实现或降级执行。 + +部署完整服务端请使用 `uv sync --extra server`,或在源码目录安装 `python -m pip install '.[server]'`。 +从此前默认包含服务端依赖的版本升级时,也应显式加入 `server` extra;`dev` extra 仍包含完整服务端依赖。 +`msg`、`msgd` 的命令名和协议版本不变。仅客户端环境运行服务命令会提示缺少服务端依赖,而不会创建服务状态。 +完整依赖与边界见[客户端安装说明](docs/CLIENT_INSTALLATION.md)。 + 连接到**已启用这个版本**的服务。首次使用时将示例地址换成实际服务地址;客户端会保存服务地址和身份私钥,请妥善保管。 ```bash diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 32ac5ef1..45db645c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -27,7 +27,7 @@ service_keys = "/var/lib/msgd/service" ```bash sudo install -d -m 0755 /opt/msgd sudo python3.15 -m venv /opt/msgd/venv -sudo /opt/msgd/venv/bin/python -m pip install . +sudo /opt/msgd/venv/bin/python -m pip install '.[server]' ``` 在真实本机 VT 或串行控制台运行: diff --git a/scripts/patches/client-boundary.patch b/scripts/patches/client-boundary.patch deleted file mode 100644 index dbeb5aeb..00000000 --- a/scripts/patches/client-boundary.patch +++ /dev/null @@ -1,456 +0,0 @@ -diff --git a/README.md b/README.md -index 74d7a1a..978ca32 100644 ---- a/README.md -+++ b/README.md -@@ -29,6 +29,15 @@ msg.lmm.best 是一个开放的交流空间。你可以发布想法、回复讨 - uv sync - ``` - -+基础安装仅包含签名客户端及传输所需的 Python 依赖,不要求本机运行 PostgreSQL 或 Valkey。 -+安装发行包时使用 `python -m pip install msg-lmm-best`。`age` 加密/备份等操作仍需相应系统工具, -+并不因为依赖分组而获得空实现或降级执行。 -+ -+部署完整服务端请使用 `uv sync --extra server`,或在源码目录安装 `python -m pip install '.[server]'`。 -+从此前默认包含服务端依赖的版本升级时,也应显式加入 `server` extra;`dev` extra 仍包含完整服务端依赖。 -+`msg`、`msgd` 的命令名和协议版本不变。仅客户端环境运行服务命令会提示缺少服务端依赖,而不会创建服务状态。 -+完整依赖与边界见[客户端安装说明](docs/CLIENT_INSTALLATION.md)。 -+ - 连接到**已启用这个版本**的服务。首次使用时将示例地址换成实际服务地址;客户端会保存服务地址和身份私钥,请妥善保管。 - - ```bash -diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md -index 32ac5ef..45db645 100644 ---- a/docs/DEPLOYMENT.md -+++ b/docs/DEPLOYMENT.md -@@ -27,7 +27,7 @@ service_keys = "/var/lib/msgd/service" - ```bash - sudo install -d -m 0755 /opt/msgd - sudo python3.15 -m venv /opt/msgd/venv --sudo /opt/msgd/venv/bin/python -m pip install . -+sudo /opt/msgd/venv/bin/python -m pip install '.[server]' - ``` - - 在真实本机 VT 或串行控制台运行: -diff --git a/pyproject.toml b/pyproject.toml -index cfc5664..cb7cf9f 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -11,16 +11,9 @@ requires-python = ">=3.15" - license = "MIT" - dependencies = [ - "cryptography>=46,<52", -- "starlette>=0.50,<2", -- "uvicorn>=0.38,<1", - "httpx>=0.28,<1", - "jsonschema>=4.25,<5", - "referencing>=0.36,<1", -- "aiohttp>=3.13,<4", -- "dnspython>=2.8,<3", -- "graphql-core>=3.2.7,<3.3", -- "psycopg[binary]>=3.3,<4", -- "valkey>=6.1,<7", - ] - - [[project.authors]] -@@ -31,7 +24,17 @@ msg = "msg.cli:main" - msgd = "msg.daemon:main" - - [project.optional-dependencies] -+server = [ -+ "starlette>=0.50,<2", -+ "uvicorn>=0.38,<1", -+ "aiohttp>=3.13,<4", -+ "dnspython>=2.8,<3", -+ "graphql-core>=3.2.7,<3.3", -+ "psycopg[binary]>=3.3,<4", -+ "valkey>=6.1,<7", -+] - dev = [ -+ "msg-lmm-best[server]", - "pytest>=8.4", - "pytest-asyncio>=1.1", - "pytest-cov>=6", -diff --git a/src/msg/atomic_file.py b/src/msg/atomic_file.py -new file mode 100644 -index 0000000..beaf703 ---- /dev/null -+++ b/src/msg/atomic_file.py -@@ -0,0 +1,26 @@ -+"""Atomic local-file replacement with caller-selected mode and durable syncs.""" -+from __future__ import annotations -+ -+import os -+from pathlib import Path -+import tempfile -+ -+ -+def durable_write(path: Path, data: bytes, mode: int = 0o600): -+ path.parent.mkdir(parents=True,exist_ok=True) -+ fd,temporary=tempfile.mkstemp(prefix='.pending-',dir=path.parent) -+ try: -+ os.fchmod(fd,mode) -+ with os.fdopen(fd,'wb') as stream: -+ stream.write(data) -+ stream.flush() -+ os.fsync(stream.fileno()) -+ os.replace(temporary,path) -+ directory=os.open(path.parent,os.O_RDONLY|os.O_DIRECTORY) -+ try: -+ os.fsync(directory) -+ finally: -+ os.close(directory) -+ finally: -+ if os.path.exists(temporary): -+ os.unlink(temporary) -diff --git a/src/msg/cli.py b/src/msg/cli.py -index 9913a59..2f3d74f 100644 ---- a/src/msg/cli.py -+++ b/src/msg/cli.py -@@ -10,7 +10,7 @@ from pathlib import Path - from msg.client import ClientState,MsgClient - from msg.core.codec import canonical,loads,wire - from msg.core.errors import Failure,require --from msg.core.executor import result_wire -+from msg.core.codec import result_wire - from msg.core.models import ResourceRef,OperationResult - from msg.transports.client import TRANSPORTS - -diff --git a/src/msg/client.py b/src/msg/client.py -index 144c0ff..326ad17 100644 ---- a/src/msg/client.py -+++ b/src/msg/client.py -@@ -20,8 +20,8 @@ from msg.core.requests import request_for - from msg.security.crypto import Ed25519Signer, subject_id, key_id - from msg.security.age_keys import (generate_age_key,recipient_from_identity, - public_from_recipient,encryption_key_id) --from msg.security.vault import client_upgrade_proof --from msg.storage.git import durable_write -+from msg.security.custodial_protocol import client_upgrade_proof -+from msg.atomic_file import durable_write - from msg.client_tokens import read_journal, remove_journal, token_operation - from msg.transports.client import HTTPTransport, GraphQLTransport, MCPHTTPTransport - -diff --git a/src/msg/client_certificates.py b/src/msg/client_certificates.py -index d8b9c30..75fa957 100644 ---- a/src/msg/client_certificates.py -+++ b/src/msg/client_certificates.py -@@ -12,7 +12,7 @@ from msg.core.errors import require - from msg.core.models import CertificateRequest,Certificate,Signature,CapabilityGrant,IssuancePolicy - from msg.security.certificates import csr_body,verify_csr,sign_certificate - from msg.security.crypto import Ed25519Signer,key_id --from msg.storage.git import durable_write -+from msg.atomic_file import durable_write - - - def local_key(path, *, create=False): -diff --git a/src/msg/client_custodial.py b/src/msg/client_custodial.py -index f79430e..5c162dd 100644 ---- a/src/msg/client_custodial.py -+++ b/src/msg/client_custodial.py -@@ -10,9 +10,9 @@ from msg.client_secrets import write_private - from msg.core.codec import canonical, digest, loads, wire - from msg.core.errors import Failure, require - from msg.security.age_keys import encryption_key_id, public_from_recipient, recipient_from_identity --from msg.security.custodial_migration import ack_statement, stage_statement --from msg.security.vault import client_upgrade_proof --from msg.storage.git import durable_write -+from msg.security.custodial_protocol import ack_statement, stage_statement -+from msg.security.custodial_protocol import client_upgrade_proof -+from msg.atomic_file import durable_write - - - def journal_for(state): -diff --git a/src/msg/client_upgrade.py b/src/msg/client_upgrade.py -index 03c5c92..b68e887 100644 ---- a/src/msg/client_upgrade.py -+++ b/src/msg/client_upgrade.py -@@ -8,7 +8,7 @@ from uuid import uuid4 - from msg.core.codec import b64, canonical, loads, wire - from msg.core.errors import Failure, require - from msg.security.crypto import Ed25519Signer --from msg.storage.git import durable_write -+from msg.atomic_file import durable_write - - - @contextmanager -diff --git a/src/msg/core/codec.py b/src/msg/core/codec.py -index 133d30e..c3cdd9a 100644 ---- a/src/msg/core/codec.py -+++ b/src/msg/core/codec.py -@@ -251,3 +251,11 @@ def record(**kwargs): - cls.__post_init__ = validate_record - return dataclasses.dataclass(**kwargs)(cls) - return decorate -+ -+ -+def result_wire(result): -+ value=wire(result,compact=True) -+ for name in ('replayed','prefer_cli'): -+ if not value.get(name): -+ value.pop(name,None) -+ return value -diff --git a/src/msg/core/executor.py b/src/msg/core/executor.py -index d00ac08..5eb6ba4 100644 ---- a/src/msg/core/executor.py -+++ b/src/msg/core/executor.py -@@ -4,7 +4,7 @@ import logging - import time - from dataclasses import replace - from uuid import uuid4 --from msg.core.codec import wire,digest -+from msg.core.codec import wire,digest,result_wire - from msg.core.errors import Failure,require - from msg.core.models import ExecutionContext,HandlerOutput,OperationResult,OperationError,Event,AccessRequirement - from msg.core.requests import SECRET_DELIVERY_MIN_VERSION,receipt_bytes -@@ -186,11 +186,3 @@ class OperationExecutor: - request_id=request.request_id,actor=principal.actor,subject=principal.subject,resources=result.resources,data={'operation':spec.name})) - await tx.save_result(principal.subject,request.payload_digest,result) - return replace(result,prefer_cli=request.source!='msg',cli_url='/AGENTS.md' if request.source!='msg' else None) -- -- --def result_wire(result): -- value=wire(result,compact=True) -- for name in ('replayed','prefer_cli'): -- if not value.get(name): -- value.pop(name,None) -- return value -diff --git a/src/msg/daemon.py b/src/msg/daemon.py -index 637715d..fc6b26b 100644 ---- a/src/msg/daemon.py -+++ b/src/msg/daemon.py -@@ -1,6 +1,7 @@ - """msgd: local installation, serving, workers, diagnostics and restricted SSH.""" - from __future__ import annotations - import argparse -+import importlib.util - import asyncio - from datetime import UTC,datetime - import json -@@ -139,6 +140,11 @@ def parser(): - - def main(argv=None): - args=parser().parse_args(argv) -+ server_modules=('starlette','uvicorn','psycopg','valkey','aiohttp','dns','graphql') -+ if any(importlib.util.find_spec(name) is None for name in server_modules): -+ print(canonical({'status':'error','error':{'code':'server_dependencies_required', -+ 'hint':"Install msg-lmm-best[server] before running server commands."}}).decode(),file=sys.stderr) -+ return 2 - try: - if args.command in {'doctor','selftest'}: - from msg.admin.diagnostics import doctor,selftest -diff --git a/src/msg/security/custodial_migration.py b/src/msg/security/custodial_migration.py -index bcff825..022d59a 100644 ---- a/src/msg/security/custodial_migration.py -+++ b/src/msg/security/custodial_migration.py -@@ -13,6 +13,7 @@ from msg.core.codec import canonical, decode, digest, loads, unb64, wire - from msg.core.errors import Failure, require - from msg.core.models import ResourceRef, Signature - from msg.security.crypto import key_id, verify -+from msg.security.custodial_protocol import stage_statement, ack_statement - from msg.security.age_keys import encryption_key_id, public_from_recipient - from msg.security.backup_retirement import BackupRetirement, verified as verified_backup - -@@ -43,22 +44,6 @@ def inventory_commitment(subject, details, challenge): - 'source_keys': details.get('source_keys', {})}) - - --def stage_statement(subject, arguments, request_id): -- """Sign every decision field, not a caller-selected subset of the manifest.""" -- return {'domain': 'msg-custodial-decision-v1', 'subject_id': subject, -- 'request_id': request_id, -- 'decision': {key: value for key, value in arguments.items() -- if key != 'migration_ack'}} -- -- --def ack_statement(subject, challenge_id, inventory_digest, source, target, -- plaintext_digest, request_id, *, method='rewrap'): -- return {'domain': 'msg-custodial-history-ack-v1', 'subject_id': subject, -- 'challenge_id': challenge_id, 'inventory_digest': inventory_digest, -- 'source': source, 'target': target, 'method': method, -- 'plaintext_digest': plaintext_digest, 'request_id': request_id} -- -- - def keyed_source(item, details): - return dict(item, key_id=details.get('source_keys', {}).get( - item['revision'], details['old_encryption_key_id'])) -diff --git a/src/msg/security/custodial_protocol.py b/src/msg/security/custodial_protocol.py -new file mode 100644 -index 0000000..80821ca ---- /dev/null -+++ b/src/msg/security/custodial_protocol.py -@@ -0,0 +1,49 @@ -+"""Published custodial proof and statement bytes shared by both endpoints. -+ -+This module has no server-held key, transaction, current-authority or retirement -+state. Producing a proof does not verify authority or attest key destruction. -+""" -+from __future__ import annotations -+ -+import hmac -+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PublicKey -+from cryptography.hazmat.primitives.kdf.hkdf import HKDF -+from cryptography.hazmat.primitives import hashes -+from msg.core.codec import b64, canonical, unb64 -+from msg.security.age_keys import private_from_identity -+ -+ -+UPGRADE_CONTEXT_FIELDS=('subject_id','challenge_id','request_id','handle','public_key', -+ 'encryption_recipient','server_public','nonce','expires_at') -+ -+ -+def _proof(shared,challenge): -+ context={name:challenge[name] for name in UPGRADE_CONTEXT_FIELDS} -+ key=HKDF(algorithm=hashes.SHA256(),length=32, -+ salt=unb64(context['nonce'],limit=64), -+ info=b'msg-custodial-upgrade-pop-v1').derive(shared) -+ return b64(hmac.digest(key,canonical(context),'sha256')) -+ -+ -+def client_upgrade_proof(age_identity,challenge): -+ private=private_from_identity(age_identity) -+ shared=private.exchange(X25519PublicKey.from_public_bytes( -+ unb64(challenge['server_public'],limit=32))) -+ return _proof(shared,challenge) -+ -+ -+ -+def stage_statement(subject, arguments, request_id): -+ """Sign every decision field, not a caller-selected subset of the manifest.""" -+ return {'domain': 'msg-custodial-decision-v1', 'subject_id': subject, -+ 'request_id': request_id, -+ 'decision': {key: value for key, value in arguments.items() -+ if key != 'migration_ack'}} -+ -+ -+def ack_statement(subject, challenge_id, inventory_digest, source, target, -+ plaintext_digest, request_id, *, method='rewrap'): -+ return {'domain': 'msg-custodial-history-ack-v1', 'subject_id': subject, -+ 'challenge_id': challenge_id, 'inventory_digest': inventory_digest, -+ 'source': source, 'target': target, 'method': method, -+ 'plaintext_digest': plaintext_digest, 'request_id': request_id} -diff --git a/src/msg/security/vault.py b/src/msg/security/vault.py -index baff9a4..c61f454 100644 ---- a/src/msg/security/vault.py -+++ b/src/msg/security/vault.py -@@ -2,7 +2,6 @@ - from __future__ import annotations - - import os --import hmac - import shutil - import subprocess - import tempfile -@@ -11,12 +10,11 @@ from pathlib import Path - from cryptography.exceptions import InvalidTag - from cryptography.hazmat.primitives.ciphers.aead import AESGCM - from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey,X25519PublicKey --from cryptography.hazmat.primitives.kdf.hkdf import HKDF --from cryptography.hazmat.primitives import hashes - - from msg.core.codec import b64,canonical,unb64,wire - from msg.core.errors import Failure,require --from msg.security.age_keys import recipient_from_identity,private_from_identity -+from msg.security.age_keys import recipient_from_identity -+from msg.security.custodial_protocol import UPGRADE_CONTEXT_FIELDS, _proof, client_upgrade_proof - from msg.security.crypto import Ed25519Signer - - -@@ -128,25 +126,6 @@ def open_upgrade_private(app,subject,challenge_id,nonce,ciphertext): - raise Failure('custodial_upgrade_challenge_corrupt') from exc - - --UPGRADE_CONTEXT_FIELDS=('subject_id','challenge_id','request_id','handle','public_key', -- 'encryption_recipient','server_public','nonce','expires_at') -- -- --def _proof(shared,challenge): -- context={name:challenge[name] for name in UPGRADE_CONTEXT_FIELDS} -- key=HKDF(algorithm=hashes.SHA256(),length=32, -- salt=unb64(context['nonce'],limit=64), -- info=b'msg-custodial-upgrade-pop-v1').derive(shared) -- return b64(hmac.digest(key,canonical(context),'sha256')) -- -- --def client_upgrade_proof(age_identity,challenge): -- private=private_from_identity(age_identity) -- shared=private.exchange(X25519PublicKey.from_public_bytes( -- unb64(challenge['server_public'],limit=32))) -- return _proof(shared,challenge) -- -- - def server_upgrade_proof(app,subject,challenge,nonce,ciphertext): - from msg.security.age_keys import public_from_recipient - private=open_upgrade_private(app,subject,challenge['challenge_id'],nonce,ciphertext) -diff --git a/src/msg/storage/git.py b/src/msg/storage/git.py -index 9846a21..43d51ca 100644 ---- a/src/msg/storage/git.py -+++ b/src/msg/storage/git.py -@@ -17,26 +17,7 @@ from pathlib import Path - from msg.core.codec import canonical, digest, loads - from msg.core.errors import Failure, require - from msg.core.models import BlobRef -- -- --def durable_write(path: Path, data: bytes, mode: int = 0o600): -- path.parent.mkdir(parents=True,exist_ok=True) -- fd,temporary=tempfile.mkstemp(prefix='.pending-',dir=path.parent) -- try: -- os.fchmod(fd,mode) -- with os.fdopen(fd,'wb') as stream: -- stream.write(data) -- stream.flush() -- os.fsync(stream.fileno()) -- os.replace(temporary,path) -- directory=os.open(path.parent,os.O_RDONLY|os.O_DIRECTORY) -- try: -- os.fsync(directory) -- finally: -- os.close(directory) -- finally: -- if os.path.exists(temporary): -- os.unlink(temporary) -+from msg.atomic_file import durable_write - - - class LFSObjectStore: -diff --git a/src/msg/transports/client.py b/src/msg/transports/client.py -index dee3f63..546fbcf 100644 ---- a/src/msg/transports/client.py -+++ b/src/msg/transports/client.py -@@ -163,7 +163,7 @@ class MCPHTTPTransport(HTTPTransport): - async def call(self, request): - self._secure_delivery(request) - await self._effect(request.operation) -- from msg.transports.mcp import PROTOCOL_VERSION -+ from msg.transports.mcp_protocol import PROTOCOL_VERSION - tool_name = request.operation if request.contract_version==1 else ( - f'{request.operation}@{request.contract_version}') - value = await self._json('POST','/-/mcp',body={'jsonrpc':'2.0','id':request.request_id, -diff --git a/src/msg/transports/mcp.py b/src/msg/transports/mcp.py -index 8474f57..aa81f77 100644 ---- a/src/msg/transports/mcp.py -+++ b/src/msg/transports/mcp.py -@@ -9,11 +9,10 @@ from dataclasses import replace - from msg import __version__ - from msg.core.codec import canonical,digest,loads - from msg.core.errors import Failure,require --from msg.core.executor import result_wire -+from msg.core.codec import result_wire - from msg.transports.packet import decode_packet,REQUEST_SCHEMA,RESULT_SCHEMA - --PROTOCOL_VERSION='2025-11-25' --SUPPORTED_VERSIONS=frozenset({PROTOCOL_VERSION,'2025-06-18','2025-03-26'}) -+from msg.transports.mcp_protocol import PROTOCOL_VERSION, SUPPORTED_VERSIONS - - - class MCPServer: -diff --git a/src/msg/transports/mcp_protocol.py b/src/msg/transports/mcp_protocol.py -new file mode 100644 -index 0000000..7eca1c5 ---- /dev/null -+++ b/src/msg/transports/mcp_protocol.py -@@ -0,0 +1,4 @@ -+"""Published MCP negotiation versions; no server or client runtime imports.""" -+ -+PROTOCOL_VERSION='2025-11-25' -+SUPPORTED_VERSIONS=frozenset({PROTOCOL_VERSION,'2025-06-18','2025-03-26'}) diff --git a/src/msg/atomic_file.py b/src/msg/atomic_file.py new file mode 100644 index 00000000..beaf7034 --- /dev/null +++ b/src/msg/atomic_file.py @@ -0,0 +1,26 @@ +"""Atomic local-file replacement with caller-selected mode and durable syncs.""" +from __future__ import annotations + +import os +from pathlib import Path +import tempfile + + +def durable_write(path: Path, data: bytes, mode: int = 0o600): + path.parent.mkdir(parents=True,exist_ok=True) + fd,temporary=tempfile.mkstemp(prefix='.pending-',dir=path.parent) + try: + os.fchmod(fd,mode) + with os.fdopen(fd,'wb') as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary,path) + directory=os.open(path.parent,os.O_RDONLY|os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if os.path.exists(temporary): + os.unlink(temporary) diff --git a/src/msg/cli.py b/src/msg/cli.py index 9913a596..2f3d74f0 100644 --- a/src/msg/cli.py +++ b/src/msg/cli.py @@ -10,7 +10,7 @@ from msg.client import ClientState,MsgClient from msg.core.codec import canonical,loads,wire from msg.core.errors import Failure,require -from msg.core.executor import result_wire +from msg.core.codec import result_wire from msg.core.models import ResourceRef,OperationResult from msg.transports.client import TRANSPORTS diff --git a/src/msg/client.py b/src/msg/client.py index 144c0ff0..326ad17f 100644 --- a/src/msg/client.py +++ b/src/msg/client.py @@ -20,8 +20,8 @@ from msg.security.crypto import Ed25519Signer, subject_id, key_id from msg.security.age_keys import (generate_age_key,recipient_from_identity, public_from_recipient,encryption_key_id) -from msg.security.vault import client_upgrade_proof -from msg.storage.git import durable_write +from msg.security.custodial_protocol import client_upgrade_proof +from msg.atomic_file import durable_write from msg.client_tokens import read_journal, remove_journal, token_operation from msg.transports.client import HTTPTransport, GraphQLTransport, MCPHTTPTransport diff --git a/src/msg/client_certificates.py b/src/msg/client_certificates.py index d8b9c30e..75fa957c 100644 --- a/src/msg/client_certificates.py +++ b/src/msg/client_certificates.py @@ -12,7 +12,7 @@ from msg.core.models import CertificateRequest,Certificate,Signature,CapabilityGrant,IssuancePolicy from msg.security.certificates import csr_body,verify_csr,sign_certificate from msg.security.crypto import Ed25519Signer,key_id -from msg.storage.git import durable_write +from msg.atomic_file import durable_write def local_key(path, *, create=False): diff --git a/src/msg/client_custodial.py b/src/msg/client_custodial.py index f79430eb..5c162dd0 100644 --- a/src/msg/client_custodial.py +++ b/src/msg/client_custodial.py @@ -10,9 +10,9 @@ from msg.core.codec import canonical, digest, loads, wire from msg.core.errors import Failure, require from msg.security.age_keys import encryption_key_id, public_from_recipient, recipient_from_identity -from msg.security.custodial_migration import ack_statement, stage_statement -from msg.security.vault import client_upgrade_proof -from msg.storage.git import durable_write +from msg.security.custodial_protocol import ack_statement, stage_statement +from msg.security.custodial_protocol import client_upgrade_proof +from msg.atomic_file import durable_write def journal_for(state): diff --git a/src/msg/client_upgrade.py b/src/msg/client_upgrade.py index 03c5c924..b68e8872 100644 --- a/src/msg/client_upgrade.py +++ b/src/msg/client_upgrade.py @@ -8,7 +8,7 @@ from msg.core.codec import b64, canonical, loads, wire from msg.core.errors import Failure, require from msg.security.crypto import Ed25519Signer -from msg.storage.git import durable_write +from msg.atomic_file import durable_write @contextmanager diff --git a/src/msg/core/codec.py b/src/msg/core/codec.py index 133d30ef..c3cdd9a5 100644 --- a/src/msg/core/codec.py +++ b/src/msg/core/codec.py @@ -251,3 +251,11 @@ def decorate(cls): cls.__post_init__ = validate_record return dataclasses.dataclass(**kwargs)(cls) return decorate + + +def result_wire(result): + value=wire(result,compact=True) + for name in ('replayed','prefer_cli'): + if not value.get(name): + value.pop(name,None) + return value diff --git a/src/msg/core/executor.py b/src/msg/core/executor.py index d00ac08d..5eb6ba46 100644 --- a/src/msg/core/executor.py +++ b/src/msg/core/executor.py @@ -4,7 +4,7 @@ import time from dataclasses import replace from uuid import uuid4 -from msg.core.codec import wire,digest +from msg.core.codec import wire,digest,result_wire from msg.core.errors import Failure,require from msg.core.models import ExecutionContext,HandlerOutput,OperationResult,OperationError,Event,AccessRequirement from msg.core.requests import SECRET_DELIVERY_MIN_VERSION,receipt_bytes @@ -186,11 +186,3 @@ async def _independent(self,request,spec,entry): request_id=request.request_id,actor=principal.actor,subject=principal.subject,resources=result.resources,data={'operation':spec.name})) await tx.save_result(principal.subject,request.payload_digest,result) return replace(result,prefer_cli=request.source!='msg',cli_url='/AGENTS.md' if request.source!='msg' else None) - - -def result_wire(result): - value=wire(result,compact=True) - for name in ('replayed','prefer_cli'): - if not value.get(name): - value.pop(name,None) - return value diff --git a/src/msg/daemon.py b/src/msg/daemon.py index 637715da..fc6b26b8 100644 --- a/src/msg/daemon.py +++ b/src/msg/daemon.py @@ -1,6 +1,7 @@ """msgd: local installation, serving, workers, diagnostics and restricted SSH.""" from __future__ import annotations import argparse +import importlib.util import asyncio from datetime import UTC,datetime import json @@ -139,6 +140,11 @@ def parser(): def main(argv=None): args=parser().parse_args(argv) + server_modules=('starlette','uvicorn','psycopg','valkey','aiohttp','dns','graphql') + if any(importlib.util.find_spec(name) is None for name in server_modules): + print(canonical({'status':'error','error':{'code':'server_dependencies_required', + 'hint':"Install msg-lmm-best[server] before running server commands."}}).decode(),file=sys.stderr) + return 2 try: if args.command in {'doctor','selftest'}: from msg.admin.diagnostics import doctor,selftest diff --git a/src/msg/security/custodial_migration.py b/src/msg/security/custodial_migration.py index bcff8257..022d59af 100644 --- a/src/msg/security/custodial_migration.py +++ b/src/msg/security/custodial_migration.py @@ -13,6 +13,7 @@ from msg.core.errors import Failure, require from msg.core.models import ResourceRef, Signature from msg.security.crypto import key_id, verify +from msg.security.custodial_protocol import stage_statement, ack_statement from msg.security.age_keys import encryption_key_id, public_from_recipient from msg.security.backup_retirement import BackupRetirement, verified as verified_backup @@ -43,22 +44,6 @@ def inventory_commitment(subject, details, challenge): 'source_keys': details.get('source_keys', {})}) -def stage_statement(subject, arguments, request_id): - """Sign every decision field, not a caller-selected subset of the manifest.""" - return {'domain': 'msg-custodial-decision-v1', 'subject_id': subject, - 'request_id': request_id, - 'decision': {key: value for key, value in arguments.items() - if key != 'migration_ack'}} - - -def ack_statement(subject, challenge_id, inventory_digest, source, target, - plaintext_digest, request_id, *, method='rewrap'): - return {'domain': 'msg-custodial-history-ack-v1', 'subject_id': subject, - 'challenge_id': challenge_id, 'inventory_digest': inventory_digest, - 'source': source, 'target': target, 'method': method, - 'plaintext_digest': plaintext_digest, 'request_id': request_id} - - def keyed_source(item, details): return dict(item, key_id=details.get('source_keys', {}).get( item['revision'], details['old_encryption_key_id'])) diff --git a/src/msg/security/custodial_protocol.py b/src/msg/security/custodial_protocol.py new file mode 100644 index 00000000..80821ca1 --- /dev/null +++ b/src/msg/security/custodial_protocol.py @@ -0,0 +1,49 @@ +"""Published custodial proof and statement bytes shared by both endpoints. + +This module has no server-held key, transaction, current-authority or retirement +state. Producing a proof does not verify authority or attest key destruction. +""" +from __future__ import annotations + +import hmac +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PublicKey +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives import hashes +from msg.core.codec import b64, canonical, unb64 +from msg.security.age_keys import private_from_identity + + +UPGRADE_CONTEXT_FIELDS=('subject_id','challenge_id','request_id','handle','public_key', + 'encryption_recipient','server_public','nonce','expires_at') + + +def _proof(shared,challenge): + context={name:challenge[name] for name in UPGRADE_CONTEXT_FIELDS} + key=HKDF(algorithm=hashes.SHA256(),length=32, + salt=unb64(context['nonce'],limit=64), + info=b'msg-custodial-upgrade-pop-v1').derive(shared) + return b64(hmac.digest(key,canonical(context),'sha256')) + + +def client_upgrade_proof(age_identity,challenge): + private=private_from_identity(age_identity) + shared=private.exchange(X25519PublicKey.from_public_bytes( + unb64(challenge['server_public'],limit=32))) + return _proof(shared,challenge) + + + +def stage_statement(subject, arguments, request_id): + """Sign every decision field, not a caller-selected subset of the manifest.""" + return {'domain': 'msg-custodial-decision-v1', 'subject_id': subject, + 'request_id': request_id, + 'decision': {key: value for key, value in arguments.items() + if key != 'migration_ack'}} + + +def ack_statement(subject, challenge_id, inventory_digest, source, target, + plaintext_digest, request_id, *, method='rewrap'): + return {'domain': 'msg-custodial-history-ack-v1', 'subject_id': subject, + 'challenge_id': challenge_id, 'inventory_digest': inventory_digest, + 'source': source, 'target': target, 'method': method, + 'plaintext_digest': plaintext_digest, 'request_id': request_id} diff --git a/src/msg/security/vault.py b/src/msg/security/vault.py index baff9a43..c61f454e 100644 --- a/src/msg/security/vault.py +++ b/src/msg/security/vault.py @@ -2,7 +2,6 @@ from __future__ import annotations import os -import hmac import shutil import subprocess import tempfile @@ -11,12 +10,11 @@ from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey,X25519PublicKey -from cryptography.hazmat.primitives.kdf.hkdf import HKDF -from cryptography.hazmat.primitives import hashes from msg.core.codec import b64,canonical,unb64,wire from msg.core.errors import Failure,require -from msg.security.age_keys import recipient_from_identity,private_from_identity +from msg.security.age_keys import recipient_from_identity +from msg.security.custodial_protocol import UPGRADE_CONTEXT_FIELDS, _proof, client_upgrade_proof from msg.security.crypto import Ed25519Signer @@ -128,25 +126,6 @@ def open_upgrade_private(app,subject,challenge_id,nonce,ciphertext): raise Failure('custodial_upgrade_challenge_corrupt') from exc -UPGRADE_CONTEXT_FIELDS=('subject_id','challenge_id','request_id','handle','public_key', - 'encryption_recipient','server_public','nonce','expires_at') - - -def _proof(shared,challenge): - context={name:challenge[name] for name in UPGRADE_CONTEXT_FIELDS} - key=HKDF(algorithm=hashes.SHA256(),length=32, - salt=unb64(context['nonce'],limit=64), - info=b'msg-custodial-upgrade-pop-v1').derive(shared) - return b64(hmac.digest(key,canonical(context),'sha256')) - - -def client_upgrade_proof(age_identity,challenge): - private=private_from_identity(age_identity) - shared=private.exchange(X25519PublicKey.from_public_bytes( - unb64(challenge['server_public'],limit=32))) - return _proof(shared,challenge) - - def server_upgrade_proof(app,subject,challenge,nonce,ciphertext): from msg.security.age_keys import public_from_recipient private=open_upgrade_private(app,subject,challenge['challenge_id'],nonce,ciphertext) diff --git a/src/msg/storage/git.py b/src/msg/storage/git.py index 9846a21a..43d51ca0 100644 --- a/src/msg/storage/git.py +++ b/src/msg/storage/git.py @@ -17,26 +17,7 @@ from msg.core.codec import canonical, digest, loads from msg.core.errors import Failure, require from msg.core.models import BlobRef - - -def durable_write(path: Path, data: bytes, mode: int = 0o600): - path.parent.mkdir(parents=True,exist_ok=True) - fd,temporary=tempfile.mkstemp(prefix='.pending-',dir=path.parent) - try: - os.fchmod(fd,mode) - with os.fdopen(fd,'wb') as stream: - stream.write(data) - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary,path) - directory=os.open(path.parent,os.O_RDONLY|os.O_DIRECTORY) - try: - os.fsync(directory) - finally: - os.close(directory) - finally: - if os.path.exists(temporary): - os.unlink(temporary) +from msg.atomic_file import durable_write class LFSObjectStore: diff --git a/src/msg/transports/client.py b/src/msg/transports/client.py index dee3f632..546fbcf2 100644 --- a/src/msg/transports/client.py +++ b/src/msg/transports/client.py @@ -163,7 +163,7 @@ class MCPHTTPTransport(HTTPTransport): async def call(self, request): self._secure_delivery(request) await self._effect(request.operation) - from msg.transports.mcp import PROTOCOL_VERSION + from msg.transports.mcp_protocol import PROTOCOL_VERSION tool_name = request.operation if request.contract_version==1 else ( f'{request.operation}@{request.contract_version}') value = await self._json('POST','/-/mcp',body={'jsonrpc':'2.0','id':request.request_id, diff --git a/src/msg/transports/mcp.py b/src/msg/transports/mcp.py index 8474f57e..aa81f77d 100644 --- a/src/msg/transports/mcp.py +++ b/src/msg/transports/mcp.py @@ -9,11 +9,10 @@ from msg import __version__ from msg.core.codec import canonical,digest,loads from msg.core.errors import Failure,require -from msg.core.executor import result_wire +from msg.core.codec import result_wire from msg.transports.packet import decode_packet,REQUEST_SCHEMA,RESULT_SCHEMA -PROTOCOL_VERSION='2025-11-25' -SUPPORTED_VERSIONS=frozenset({PROTOCOL_VERSION,'2025-06-18','2025-03-26'}) +from msg.transports.mcp_protocol import PROTOCOL_VERSION, SUPPORTED_VERSIONS class MCPServer: diff --git a/src/msg/transports/mcp_protocol.py b/src/msg/transports/mcp_protocol.py new file mode 100644 index 00000000..7eca1c5b --- /dev/null +++ b/src/msg/transports/mcp_protocol.py @@ -0,0 +1,4 @@ +"""Published MCP negotiation versions; no server or client runtime imports.""" + +PROTOCOL_VERSION='2025-11-25' +SUPPORTED_VERSIONS=frozenset({PROTOCOL_VERSION,'2025-06-18','2025-03-26'})