msgctl 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. msg/__init__.py +2 -0
  2. msg/admin/__init__.py +0 -0
  3. msg/admin/backup_retirement.py +56 -0
  4. msg/admin/backups.py +341 -0
  5. msg/admin/custodial_check.py +77 -0
  6. msg/admin/diagnostics.py +750 -0
  7. msg/admin/market.py +91 -0
  8. msg/admin/market_check.py +341 -0
  9. msg/admin/money.py +322 -0
  10. msg/admin/preflight.py +142 -0
  11. msg/admin/recovery_replay.py +281 -0
  12. msg/admin/restore_database.py +37 -0
  13. msg/admin/root.py +347 -0
  14. msg/admin/rotation.py +197 -0
  15. msg/admin/token_delivery_check.py +54 -0
  16. msg/admin/upgrade_check.py +58 -0
  17. msg/application.py +163 -0
  18. msg/bootstrap.py +305 -0
  19. msg/cli.py +537 -0
  20. msg/client.py +725 -0
  21. msg/client_certificates.py +98 -0
  22. msg/client_content.py +43 -0
  23. msg/client_custodial.py +206 -0
  24. msg/client_market.py +104 -0
  25. msg/client_recovery.py +186 -0
  26. msg/client_secrets.py +58 -0
  27. msg/client_tokens.py +99 -0
  28. msg/client_upgrade.py +188 -0
  29. msg/config.py +318 -0
  30. msg/constants.py +11 -0
  31. msg/core/__init__.py +0 -0
  32. msg/core/batching.py +23 -0
  33. msg/core/codec.py +253 -0
  34. msg/core/contracts.py +128 -0
  35. msg/core/cursors.py +68 -0
  36. msg/core/email_address.py +33 -0
  37. msg/core/errors.py +22 -0
  38. msg/core/events.py +6 -0
  39. msg/core/execution_ports.py +20 -0
  40. msg/core/executor.py +193 -0
  41. msg/core/models.py +517 -0
  42. msg/core/packet.py +71 -0
  43. msg/core/permissions.py +14 -0
  44. msg/core/query.py +44 -0
  45. msg/core/read_query.py +60 -0
  46. msg/core/registry.py +173 -0
  47. msg/core/requests.py +58 -0
  48. msg/core/schema_policy.py +28 -0
  49. msg/core/schemas.py +30 -0
  50. msg/core/tags.py +21 -0
  51. msg/core/template_dsl.py +94 -0
  52. msg/core/text_patch.py +283 -0
  53. msg/core/tool_execution.py +28 -0
  54. msg/core/transfer.py +183 -0
  55. msg/daemon.py +255 -0
  56. msg/data/__init__.py +0 -0
  57. msg/data/bootstrap.json +72 -0
  58. msg/data/favicon.png +0 -0
  59. msg/data/logo-dark.svg +5 -0
  60. msg/data/logo.svg +5 -0
  61. msg/data/recovery-checkpoint.example.json +1 -0
  62. msg/data/recovery-checkpoint.schema.json +118 -0
  63. msg/data/shortcodes.json +1 -0
  64. msg/data/system/AGENTS.md +6 -0
  65. msg/data/system/rules/_index.md +17 -0
  66. msg/data/system/rules/auth.md +6 -0
  67. msg/data/system/rules/files.md +6 -0
  68. msg/data/system/rules/identity.md +6 -0
  69. msg/data/system/rules/protocol.md +6 -0
  70. msg/data/system/rules/read-write.md +6 -0
  71. msg/data/system/rules/recovery.md +6 -0
  72. msg/data/system/rules/security.md +10 -0
  73. msg/data/system/rules/topics.md +6 -0
  74. msg/extensions/__init__.py +1 -0
  75. msg/extensions/hosting.py +259 -0
  76. msg/extensions/keystore.py +96 -0
  77. msg/extensions/repositories.py +769 -0
  78. msg/extensions/rss.py +67 -0
  79. msg/extensions/ssh.py +264 -0
  80. msg/extensions/ssh_git.py +300 -0
  81. msg/extensions/tools.py +150 -0
  82. msg/hosting_runtime.py +138 -0
  83. msg/market/__init__.py +1 -0
  84. msg/market/arbitration.py +481 -0
  85. msg/market/delivery.py +338 -0
  86. msg/market/delivery_notifications.py +110 -0
  87. msg/market/delivery_targets.py +76 -0
  88. msg/market/email.py +100 -0
  89. msg/market/escrow.py +345 -0
  90. msg/market/orders.py +218 -0
  91. msg/market/policy.py +147 -0
  92. msg/market/rationale.py +91 -0
  93. msg/market/references.py +19 -0
  94. msg/market/targets.py +157 -0
  95. msg/plugins/__init__.py +28 -0
  96. msg/plugins/achievements.py +316 -0
  97. msg/plugins/batch.py +32 -0
  98. msg/plugins/bounty.py +366 -0
  99. msg/plugins/collaboration.py +261 -0
  100. msg/plugins/common.py +217 -0
  101. msg/plugins/communication.py +853 -0
  102. msg/plugins/content.py +855 -0
  103. msg/plugins/custodial_lifecycle.py +224 -0
  104. msg/plugins/delivery.py +230 -0
  105. msg/plugins/discovery.py +1266 -0
  106. msg/plugins/discussion.py +162 -0
  107. msg/plugins/extensions.py +10 -0
  108. msg/plugins/following.py +78 -0
  109. msg/plugins/hosting_capacity.py +52 -0
  110. msg/plugins/identity.py +1654 -0
  111. msg/plugins/money.py +221 -0
  112. msg/plugins/offers.py +183 -0
  113. msg/plugins/orders.py +273 -0
  114. msg/plugins/recovery.py +376 -0
  115. msg/plugins/schemas.py +5 -0
  116. msg/plugins/sharing.py +300 -0
  117. msg/plugins/store.py +249 -0
  118. msg/plugins/system.py +75 -0
  119. msg/plugins/transfer.py +249 -0
  120. msg/py.typed +0 -0
  121. msg/security/__init__.py +0 -0
  122. msg/security/age_keys.py +112 -0
  123. msg/security/authentication.py +141 -0
  124. msg/security/authorization.py +300 -0
  125. msg/security/backup_retirement.py +135 -0
  126. msg/security/capabilities.py +157 -0
  127. msg/security/certificates.py +183 -0
  128. msg/security/crypto.py +102 -0
  129. msg/security/custodial_migration.py +292 -0
  130. msg/security/custody_history.py +146 -0
  131. msg/security/network.py +81 -0
  132. msg/security/policy.py +67 -0
  133. msg/security/quarantine.py +19 -0
  134. msg/security/root_files.py +67 -0
  135. msg/security/rotation_journal.py +60 -0
  136. msg/security/sealed_box.py +70 -0
  137. msg/security/sharing_policy.py +29 -0
  138. msg/security/token_delivery.py +93 -0
  139. msg/security/vault.py +186 -0
  140. msg/storage/__init__.py +0 -0
  141. msg/storage/capacity.py +100 -0
  142. msg/storage/custodial_migration.py +22 -0
  143. msg/storage/git.py +435 -0
  144. msg/storage/ledger_migration.py +307 -0
  145. msg/storage/market_migration.py +49 -0
  146. msg/storage/postgres.py +663 -0
  147. msg/storage/query.py +39 -0
  148. msg/storage/read_only.py +126 -0
  149. msg/storage/session.py +403 -0
  150. msg/storage/sqlite.py +360 -0
  151. msg/storage/topic_event_migration.py +26 -0
  152. msg/storage/valkey_bus.py +45 -0
  153. msg/transports/__init__.py +0 -0
  154. msg/transports/client.py +183 -0
  155. msg/transports/dictionary.py +387 -0
  156. msg/transports/graphql.py +75 -0
  157. msg/transports/http.py +72 -0
  158. msg/transports/http_routes.py +1577 -0
  159. msg/transports/mcp.py +127 -0
  160. msg/transports/packet.py +42 -0
  161. msg/transports/read_tree_path.py +65 -0
  162. msg/transports/stdio.py +36 -0
  163. msg/transports/url_safety.py +123 -0
  164. msg/tui.py +368 -0
  165. msg/workers/__init__.py +1 -0
  166. msg/workers/effects.py +406 -0
  167. msg/workers/leases.py +19 -0
  168. msg/workers/mail.py +63 -0
  169. msg/workers/maintenance.py +310 -0
  170. msg/workers/sandbox.py +74 -0
  171. msg/workers/sandbox_child.py +189 -0
  172. msg/workers/webhook.py +160 -0
  173. msgctl-0.1.0a1.dist-info/METADATA +96 -0
  174. msgctl-0.1.0a1.dist-info/RECORD +176 -0
  175. msgctl-0.1.0a1.dist-info/WHEEL +4 -0
  176. msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
msg/cli.py ADDED
@@ -0,0 +1,537 @@
1
+ """The msg client. Only JSON results go to stdout; help is explicitly requested."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import asyncio
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from msg.client import ClientState,MsgClient
11
+ from msg.core.codec import canonical,loads,wire
12
+ from msg.core.errors import Failure,require
13
+ from msg.core.executor import result_wire
14
+ from msg.core.models import ResourceRef,OperationResult
15
+ from msg.transports.client import TRANSPORTS
16
+
17
+
18
+ def json_input(value):
19
+ if value=='-':
20
+ raw=sys.stdin.buffer.read(1048577)
21
+ elif value.startswith('@'):
22
+ raw=Path(value[1:]).read_bytes()
23
+ else:
24
+ raw=value.encode()
25
+ require(len(raw)<=1048576,'arguments_too_large')
26
+ return loads(raw)
27
+
28
+
29
+ def arguments(value):
30
+ parsed=json_input(value)
31
+ require(isinstance(parsed,dict),'arguments_must_be_object')
32
+ return parsed
33
+
34
+
35
+ def parser():
36
+ cli=argparse.ArgumentParser(prog='msg',description='Signed atomic communication for sandboxed agents.')
37
+ cli.add_argument('--config-dir',type=Path,default=Path.home()/'.config'/'msg')
38
+ cli.add_argument('--server')
39
+ cli.add_argument('--transport',choices=TRANSPORTS,default='http')
40
+ cli.add_argument('--certificate',action='append',default=[],help='Attach a registered certificate ID for this invocation.')
41
+ cli.add_argument('--key',type=Path,help='Use a local delegated/CA signing key for this invocation.')
42
+ cli.add_argument('--as-subject',help='Represent a principal authorized by an explicit delegation certificate.')
43
+ commands=cli.add_subparsers(dest='command',required=True)
44
+ identity=commands.add_parser('identity').add_subparsers(dest='action',required=True)
45
+ identity.add_parser('new').add_argument('handle')
46
+ identity.add_parser('temporary')
47
+ identity.add_parser('rotate-token')
48
+ identity.add_parser('recover-token',help='Use a saved one-time recovery journal after a lost token response.')
49
+ identity.add_parser('upgrade',help='Upgrade or resume a saved upgrade intention.').add_argument('handle',nargs='?')
50
+ identity.add_parser('custodial').add_argument('handle')
51
+ custodial=identity.add_parser('upgrade-custodial')
52
+ custodial.add_argument('handle')
53
+ custodial.add_argument('--external-history',choices=('migrated','unknown'),required=True)
54
+ migration=identity.add_parser('migration').add_subparsers(dest='migration_action',required=True)
55
+ for name in ('status','refresh','envelope'):
56
+ migration.add_parser(name)
57
+ migrate=migration.add_parser('migrate',help='Decrypt and ACK an explicit bounded batch; never finalize automatically.')
58
+ migrate.add_argument('--method',choices=('rewrap','compatibility_recovery'),default='rewrap')
59
+ migrate.add_argument('--limit',type=int,default=100)
60
+ finalize=migration.add_parser('finalize')
61
+ finalize.add_argument('--resolution',choices=('verified','accept_loss','retain_decrypt'),required=True)
62
+ finalize.add_argument('--external-history',choices=('migrated','unknown'),required=True)
63
+ finalize.add_argument('--loss-revision',action='append',default=[])
64
+ finalize.add_argument('--reason')
65
+ identity.add_parser('show')
66
+ call=commands.add_parser('call',help='Call any declared operation with JSON, @file, or - for stdin.')
67
+ call.add_argument('operation');call.add_argument('arguments',nargs='?',default='{}')
68
+ call.add_argument('--request-id');call.add_argument('--expect',action='append',default=[],metavar='ID=GENERATION')
69
+ call.add_argument('--return-field',action='append',default=[])
70
+ schema=commands.add_parser('schema');schema.add_argument('operation')
71
+ commands.add_parser('operations')
72
+ read=commands.add_parser('read');read.add_argument('resource');read.add_argument('--revision')
73
+ read.add_argument('--field',action='append',default=[]);read.add_argument('--meta',action='store_true')
74
+ read.add_argument('--ack',action='store_true',help='Explicitly submit ACK after a successful read.')
75
+ following=commands.add_parser('following',help='One private page of currently readable watched resources.')
76
+ following.add_argument('--limit',type=int)
77
+ following.add_argument('--cursor')
78
+ search=commands.add_parser('search',help='One bounded page of scoped lexical results.')
79
+ search.add_argument('scope',nargs='?',help='Required for a new search; omit with --cursor.')
80
+ search.add_argument('terms',nargs='?',help='Words to find; omit with --cursor.')
81
+ search.add_argument('--cursor',help='Fetch one next page using the server-issued cursor.')
82
+ search.add_argument('--limit',type=int,default=50)
83
+ search.add_argument('--mode',choices=('all','any'),default='all')
84
+ search.add_argument('--field',choices=('all','name','body','metadata'),default='all')
85
+ search.add_argument('--exact');search.add_argument('--exclude',dest='not_terms')
86
+ search.add_argument('--type');search.add_argument('--owner');search.add_argument('--author')
87
+ search.add_argument('--tag');search.add_argument('--state',choices=('active','archived'))
88
+ search.add_argument('--created-after');search.add_argument('--created-before')
89
+ search.add_argument('--updated-after');search.add_argument('--updated-before')
90
+ search.add_argument('--has-attachment',action='store_true')
91
+ search.add_argument('--depth',type=int);search.add_argument('--no-recursive',action='store_true')
92
+ search.add_argument('--order',choices=('relevance','updated','created','name'))
93
+ search.add_argument('--no-snippet',action='store_true')
94
+ search.add_argument('--explain',action='store_true')
95
+ grep=commands.add_parser('grep',help='Search a known scope with explicit result caps.')
96
+ grep.add_argument('scope');grep.add_argument('pattern')
97
+ grep.add_argument('--regex',action='store_true');grep.add_argument('--ignore-case',action='store_true')
98
+ grep.add_argument('--glob');grep.add_argument('--exclude-glob')
99
+ grep.add_argument('--before',type=int,default=0);grep.add_argument('--after',type=int,default=0)
100
+ grep.add_argument('--max-matches',type=int,default=50);grep.add_argument('--max-files',type=int,default=50)
101
+ grep_mode=grep.add_mutually_exclusive_group()
102
+ grep_mode.add_argument('--files-with-matches',action='store_true')
103
+ grep_mode.add_argument('--count-only',action='store_true')
104
+ post=commands.add_parser('post');post.add_argument('topic')
105
+ body=post.add_mutually_exclusive_group(required=True);body.add_argument('--text');body.add_argument('--file',type=Path)
106
+ reply=commands.add_parser('reply');reply.add_argument('resource');reply.add_argument('--text',required=True)
107
+ dm=commands.add_parser('dm',help='Direct conversation using the public Operation contract.')
108
+ dm_actions=dm.add_subparsers(dest='action',required=True)
109
+ dm_actions.add_parser('list')
110
+ dm_request=dm_actions.add_parser('request');dm_request.add_argument('recipient')
111
+ dm_request.add_argument('--intro')
112
+ dm_send=dm_actions.add_parser('send');dm_send.add_argument('conversation');dm_send.add_argument('--text',required=True)
113
+ dm_read=dm_actions.add_parser('read');dm_read.add_argument('resource')
114
+ for action in ('accept','reject','archive'):
115
+ dm_actions.add_parser(action).add_argument('conversation')
116
+ dm_actions.add_parser('block').add_argument('subject')
117
+ handoff=commands.add_parser('handoff',help='Explicit collaboration context; never transfers permission.')
118
+ handoff_actions=handoff.add_subparsers(dest='action',required=True)
119
+ handoff_create=handoff_actions.add_parser('create')
120
+ handoff_create.add_argument('recipient')
121
+ handoff_create.add_argument('--ref',action='append',default=[])
122
+ handoff_create.add_argument('--message')
123
+ handoff_create.add_argument('--next-action')
124
+ handoff_list=handoff_actions.add_parser('list')
125
+ handoff_list.add_argument('--limit',type=int)
126
+ handoff_list.add_argument('--after')
127
+ handoff_actions.add_parser('get').add_argument('id')
128
+ for action in ('accept','reject','cancel'):
129
+ decision=handoff_actions.add_parser(action)
130
+ decision.add_argument('id')
131
+ decision.add_argument('--generation',type=int,required=True)
132
+ lease=commands.add_parser('lease',help='Expiring coordination hint; never a security lock.')
133
+ lease_actions=lease.add_subparsers(dest='action',required=True)
134
+ lease_acquire=lease_actions.add_parser('acquire')
135
+ lease_acquire.add_argument('target')
136
+ lease_acquire.add_argument('--purpose',required=True)
137
+ lease_acquire.add_argument('--expires-at',required=True)
138
+ lease_list=lease_actions.add_parser('list')
139
+ lease_list.add_argument('--limit',type=int)
140
+ lease_list.add_argument('--after')
141
+ lease_actions.add_parser('get').add_argument('id')
142
+ lease_renew=lease_actions.add_parser('renew')
143
+ lease_renew.add_argument('id')
144
+ lease_renew.add_argument('--expires-at',required=True)
145
+ lease_renew.add_argument('--generation',type=int,required=True)
146
+ lease_release=lease_actions.add_parser('release')
147
+ lease_release.add_argument('id')
148
+ lease_release.add_argument('--generation',type=int,required=True)
149
+ ack=commands.add_parser('ack');ack.add_argument('resource');ack.add_argument('revision')
150
+ upload=commands.add_parser('upload');upload.add_argument('file',type=Path);upload.add_argument('--resume')
151
+ upload.add_argument('--part-bytes',type=int,default=65536);upload.add_argument('--media-type',default='application/octet-stream')
152
+ upload.add_argument('--target')
153
+ download=commands.add_parser('download');download.add_argument('resource');download.add_argument('file',type=Path)
154
+ download.add_argument('--revision');download.add_argument('--part-bytes',type=int,default=65536)
155
+ commands.add_parser('mcp',help='Expose the same operations over auto-signing local MCP stdio.')
156
+ commands.add_parser('tui',help='Browse Home, Inbox, search and threads without ACK or writes.')
157
+ cert=commands.add_parser('cert').add_subparsers(dest='action',required=True)
158
+ cert.add_parser('renew',help='Renew the base certificate without attaching an expired chain.')
159
+ certget=cert.add_parser('get');certget.add_argument('id')
160
+ certrequest=cert.add_parser('request');certrequest.add_argument('spec',help='JSON or @file with issuer, grants, TTL and optional CA issuance.')
161
+ certrequest.add_argument('--ca-key',type=Path,help='Dedicated local key, generated if absent.')
162
+ certissue=cert.add_parser('issue');certissue.add_argument('csr_id');certissue.add_argument('--issuer',required=True)
163
+ certissue.add_argument('--ca-key',type=Path,required=True)
164
+ certissue.add_argument('--approve-digest',help='Bind noninteractive CA approval to the reviewed CSR digest.')
165
+ vault=commands.add_parser('keystore').add_subparsers(dest='action',required=True)
166
+ keygen=vault.add_parser('keygen');keygen.add_argument('--output',type=Path,required=True)
167
+ put=vault.add_parser('put');put.add_argument('name');put.add_argument('file',type=Path);put.add_argument('--recipient',required=True)
168
+ get=vault.add_parser('get');get.add_argument('resource');get.add_argument('--output',type=Path,required=True);get.add_argument('--private-key',type=Path,required=True)
169
+ vault.add_parser('list')
170
+ recovery=commands.add_parser('recovery',help='Explicit self-custody age backup; no account authority.')
171
+ recovery_actions=recovery.add_subparsers(dest='action',required=True)
172
+ recovery_actions.add_parser('policy')
173
+ policy_set=recovery_actions.add_parser('policy-set')
174
+ policy_set.add_argument('--recipient',action='append',default=[])
175
+ policy_set.add_argument('--clear',action='store_true',help='Opt out of future envelope registration.')
176
+ backup=recovery_actions.add_parser('backup')
177
+ backup.add_argument('--recipient',action='append',required=True)
178
+ backup.add_argument('--policy-version',type=int,required=True)
179
+ backup.add_argument('--name')
180
+ recovery_actions.add_parser('list')
181
+ envelope_get=recovery_actions.add_parser('get');envelope_get.add_argument('id')
182
+ restore=recovery_actions.add_parser('restore')
183
+ restore.add_argument('ciphertext',type=Path)
184
+ restore.add_argument('--identity',type=Path,required=True)
185
+ restore.add_argument('--output',type=Path,required=True)
186
+ restore.add_argument('--subject',required=True)
187
+ restore.add_argument('--key-id',required=True)
188
+ rewrap=recovery_actions.add_parser('rewrap')
189
+ rewrap.add_argument('resource')
190
+ rewrap.add_argument('--old-identity',type=Path,required=True)
191
+ rewrap.add_argument('--expected-revision',required=True)
192
+ legacy=commands.add_parser('legacy',help='Signed last-will declarations; never executes account actions.')
193
+ legacy_actions=legacy.add_subparsers(dest='action',required=True)
194
+ legacy_put=legacy_actions.add_parser('put')
195
+ legacy_put.add_argument('spec',help='JSON, @file or - for stdin.')
196
+ legacy_put.add_argument('--contract-version',type=int,choices=(1,2),default=1,
197
+ help='2 requires resource_id, revision_id, content_created_at and content_signature.')
198
+ legacy_actions.add_parser('archive')
199
+ legacy_actions.add_parser('status')
200
+ legacy_get=legacy_actions.add_parser('get')
201
+ legacy_get.add_argument('--subject')
202
+ legacy_get.add_argument('--revision')
203
+ hosting=commands.add_parser('hosting',help='Explicit signed website candidates and revisions.')
204
+ hosting_actions=hosting.add_subparsers(dest='action',required=True)
205
+ hosting_create=hosting_actions.add_parser('create')
206
+ hosting_create.add_argument('parent');hosting_create.add_argument('name')
207
+ for action in ('preview','deploy'):
208
+ command=hosting_actions.add_parser(action)
209
+ command.add_argument('website');command.add_argument('entries',
210
+ help='JSON array or @file of {path,source:{id,revision}} entries.')
211
+ hosting_fetch=hosting_actions.add_parser('preview-get',
212
+ help='Authenticated read of a private candidate into a new local file.')
213
+ hosting_fetch.add_argument('site_path',help='Website path, e.g. /@alice/web.')
214
+ hosting_fetch.add_argument('candidate_id')
215
+ hosting_fetch.add_argument('file_path')
216
+ hosting_fetch.add_argument('--output',type=Path,required=True)
217
+ hosting_activate=hosting_actions.add_parser('activate',
218
+ help='Explicitly publish a previously deployed website revision.')
219
+ hosting_activate.add_argument('website');hosting_activate.add_argument('revision')
220
+ hosting_history=hosting_actions.add_parser('history')
221
+ hosting_history.add_argument('website')
222
+ from msg.client_market import add_commands
223
+ add_commands(commands)
224
+ return cli
225
+
226
+
227
+ async def run(args):
228
+ if args.command=='keystore' and args.action=='keygen':
229
+ from msg.client_secrets import encryption_keygen
230
+ print(canonical(encryption_keygen(args.output)).decode())
231
+ return 0
232
+ if args.command=='recovery' and args.action=='restore':
233
+ from msg.client_recovery import restore_recovery_envelope
234
+ value=restore_recovery_envelope(args.ciphertext.read_bytes(),args.identity,args.output,
235
+ expected_subject_id=args.subject,expected_encryption_key_id=args.key_id)
236
+ print(canonical(value).decode())
237
+ return 0
238
+ state=ClientState(args.config_dir,server=args.server)
239
+ transport=TRANSPORTS[args.transport](state.server)
240
+ client=MsgClient(state,transport)
241
+ if args.key:
242
+ from msg.security.crypto import Ed25519Signer
243
+ require(args.key.stat().st_mode&0o077==0,'unsafe_client_key_permissions')
244
+ state.signer=Ed25519Signer.from_bytes(args.key.read_bytes())
245
+ # Key override is invocation-only, never written back to the primary identity.
246
+ state.data.pop('token',None)
247
+ if args.certificate:
248
+ state.data['certificates']=list(dict.fromkeys([*state.certificates,*args.certificate]))
249
+ if args.as_subject:
250
+ state.data['subject_id']=args.as_subject
251
+ try:
252
+ command=args.command
253
+ if command=='identity':
254
+ if args.action=='new': result=await client.register(args.handle)
255
+ elif args.action=='temporary': result=await client.temporary()
256
+ elif args.action=='rotate-token': result=await client.rotate_token()
257
+ elif args.action=='recover-token': result=await client.recover_token()
258
+ elif args.action=='upgrade': result=await client.upgrade(args.handle)
259
+ elif args.action=='custodial': result=await client.custodial(args.handle)
260
+ elif args.action=='upgrade-custodial':
261
+ result=await client.upgrade_custodial(args.handle,
262
+ external_ciphertexts_migrated=args.external_history=='migrated')
263
+ elif args.action=='migration':
264
+ from msg.client_custodial import inventory,transition,migrate
265
+ if args.migration_action=='status':result=await inventory(client)
266
+ elif args.migration_action=='migrate':
267
+ result=await migrate(client,method=args.method,limit=args.limit)
268
+ elif args.migration_action=='finalize':
269
+ result=await transition(client,'finalize',resolution=args.resolution,
270
+ loss_revisions=args.loss_revision,reason=args.reason,
271
+ external_ciphertexts_migrated=args.external_history=='migrated')
272
+ else:
273
+ result=await transition(client,'recovery_envelope' if args.migration_action=='envelope' else 'refresh')
274
+ else:
275
+ from msg.client_upgrade import pending_upgrade
276
+ result={'subject_id':state.subject,'key_id':state.signer.key_id if state.signer else None,
277
+ 'server':state.server,'certificates':state.certificates,'auth':'token' if state.token else 'signature'}
278
+ pending=pending_upgrade(state)
279
+ if pending is not None: result['pending_upgrade']=pending
280
+ elif command=='call':
281
+ expected=[]
282
+ for item in args.expect:
283
+ rid,sep,generation=item.rpartition('=')
284
+ require(sep and generation.isdecimal(),'invalid_expected_generation')
285
+ expected.append((rid,int(generation)))
286
+ result=await client.call(args.operation,arguments(args.arguments),request_id=args.request_id,
287
+ expected=expected,return_fields=args.return_field)
288
+ elif command in {'money','store','bounty','orders','delivery'}:
289
+ from msg.client_market import run_command
290
+ result=await run_command(client,args,arguments)
291
+ elif command=='operations': result=await client.call('discovery.operations')
292
+ elif command=='schema': result=await client.call('discovery.schema',{'operation':args.operation})
293
+ elif command=='read':
294
+ params={'id':args.resource}
295
+ if args.revision:params['revision']=args.revision
296
+ if args.field:params['fields']=args.field
297
+ if args.meta:params['view']='meta'
298
+ result=await client.call('discovery.get',params)
299
+ if args.ack and result.status=='ok':
300
+ require(result.data.get('revision'),'ack_revision_required')
301
+ ack=await client.ack(ResourceRef(id=result.data['id'],revision=result.data['revision']))
302
+ result={'read':result_wire(result),'ack':result_wire(ack)}
303
+ elif command=='following':
304
+ if args.cursor:
305
+ require(args.limit is None,'cursor_query_mismatch')
306
+ params={'cursor':args.cursor}
307
+ else:
308
+ limit=20 if args.limit is None else args.limit
309
+ require(1<=limit<=100,'invalid_limit')
310
+ params={'limit':limit}
311
+ result=await client.call('communication.following',params)
312
+ elif command=='search':
313
+ require(1<=args.limit<=100,'invalid_search_limit')
314
+ if args.cursor:
315
+ require(args.scope is None and args.terms is None,'cursor_query_mismatch')
316
+ require(args.limit==50 and args.mode=='all' and args.field=='all' and
317
+ args.exact is None and args.not_terms is None and args.type is None and
318
+ args.owner is None and args.author is None and args.tag is None and
319
+ args.state is None and args.created_after is None and
320
+ args.created_before is None and args.updated_after is None and
321
+ args.updated_before is None and not args.has_attachment and
322
+ args.depth is None and not args.no_recursive and args.order is None and
323
+ not args.no_snippet and not args.explain,'cursor_query_mismatch')
324
+ params={'cursor':args.cursor}
325
+ else:
326
+ require(args.scope is not None and (args.terms or args.exact),
327
+ 'search_query_required')
328
+ params={'scope':args.scope,'mode':args.mode,'field':args.field,'limit':args.limit}
329
+ if args.terms:params['terms']=args.terms
330
+ for key in ('exact','not_terms','type','owner','author','tag','state',
331
+ 'created_after','created_before','updated_after','updated_before',
332
+ 'depth','order'):
333
+ value=getattr(args,key)
334
+ if value is not None:params[key]=value
335
+ if args.has_attachment:params['has_attachment']=True
336
+ if args.no_recursive:params['recursive']=False
337
+ if args.no_snippet:params['snippet']=False
338
+ if args.explain:params['explain']='compact'
339
+ result=await client.call('discovery.lexical_search',params)
340
+ elif command=='grep':
341
+ require(0<=args.before<=3 and 0<=args.after<=3,'invalid_grep_context')
342
+ require(1<=args.max_matches<=100 and 1<=args.max_files<=100,
343
+ 'invalid_grep_limit')
344
+ params={'scope':args.scope,'pattern':args.pattern,'regex':args.regex,
345
+ 'case_sensitive':not args.ignore_case,'before':args.before,
346
+ 'after':args.after,'max_matches':args.max_matches,
347
+ 'max_files':args.max_files}
348
+ for key in ('glob','exclude_glob'):
349
+ value=getattr(args,key)
350
+ if value is not None:params[key]=value
351
+ if args.files_with_matches:params['files_with_matches']=True
352
+ if args.count_only:params['count_only']=True
353
+ result=await client.call('discovery.grep',params)
354
+ elif command=='post':
355
+ if args.file:
356
+ uploaded=await client.upload(args.file,media_type='text/markdown')
357
+ params={'parent':args.topic,'source':wire(uploaded.output)}
358
+ else: params={'parent':args.topic,'body':args.text}
359
+ result=await client.call('content.post_create',params)
360
+ elif command=='reply': result=await client.call('discussion.reply',{'target':{'id':args.resource},'body':args.text})
361
+ elif command=='dm':
362
+ if args.action=='list':result=await client.call('communication.dm_list')
363
+ elif args.action=='request':result=await client.call('communication.dm_request',
364
+ {'recipient':args.recipient,**({'introduction':args.intro}
365
+ if args.intro is not None else {})},
366
+ contract_version=2 if args.intro is not None else 1)
367
+ elif args.action=='send':result=await client.call('communication.dm_send',
368
+ {'conversation_id':args.conversation,'body':args.text})
369
+ elif args.action=='read':result=await client.call('discovery.get',{'id':args.resource})
370
+ elif args.action=='block':result=await client.call('communication.dm_block',
371
+ {'subject_id':args.subject})
372
+ else:result=await client.call('communication.dm_'+args.action,
373
+ {'conversation_id':args.conversation})
374
+ elif command=='handoff':
375
+ if args.action=='create':
376
+ params={'to_subject':args.recipient,'resource_refs':args.ref}
377
+ if args.message is not None:params['message']=args.message
378
+ if args.next_action is not None:params['next_action']=args.next_action
379
+ result=await client.call('communication.handoff_create',params)
380
+ elif args.action=='list':
381
+ params={}
382
+ if args.limit is not None:params['limit']=args.limit
383
+ if args.after is not None:params['after']=args.after
384
+ result=await client.call('communication.handoff_list',params)
385
+ elif args.action=='get':
386
+ result=await client.call('communication.handoff_get',{'id':args.id})
387
+ else:
388
+ result=await client.call('communication.handoff_decide',
389
+ {'id':args.id,'decision':args.action,
390
+ 'expected_generation':args.generation})
391
+ elif command=='lease':
392
+ if args.action=='acquire':
393
+ result=await client.call('communication.lease_acquire',
394
+ {'target':args.target,'purpose':args.purpose,
395
+ 'expires_at':args.expires_at})
396
+ elif args.action=='list':
397
+ params={}
398
+ if args.limit is not None:params['limit']=args.limit
399
+ if args.after is not None:params['after']=args.after
400
+ result=await client.call('communication.lease_list',params)
401
+ elif args.action=='get':
402
+ result=await client.call('communication.lease_get',{'id':args.id})
403
+ elif args.action=='renew':
404
+ result=await client.call('communication.lease_renew',
405
+ {'id':args.id,'expires_at':args.expires_at,
406
+ 'expected_generation':args.generation})
407
+ else:
408
+ result=await client.call('communication.lease_release',
409
+ {'id':args.id,'expected_generation':args.generation})
410
+ elif command=='ack': result=await client.ack(ResourceRef(id=args.resource,revision=args.revision))
411
+ elif command=='upload': result=await client.upload(args.file,transfer_id=args.resume,
412
+ part_bytes=args.part_bytes,media_type=args.media_type,target=args.target)
413
+ elif command=='download':result=await client.download(ResourceRef(id=args.resource,revision=args.revision),args.file,part_bytes=args.part_bytes)
414
+ elif command=='cert':
415
+ from msg.client_certificates import request_certificate,issue_certificate
416
+ if args.action=='renew': result=await client.renew_certificate()
417
+ elif args.action=='get': result=await client.call('cert.get',{'id':args.id})
418
+ elif args.action=='request': result=await request_certificate(client,arguments(args.spec),args.ca_key)
419
+ else: result=await issue_certificate(client,args.csr_id,args.issuer,args.ca_key,expected_digest=args.approve_digest)
420
+ elif command=='keystore':
421
+ from msg.client_secrets import put_secret,get_secret
422
+ if args.action=='put':result=await put_secret(client,args.name,args.file,args.recipient)
423
+ elif args.action=='get':result=await get_secret(client,args.resource,args.output,args.private_key)
424
+ else:result=await client.call('keystore.list')
425
+ elif command=='recovery':
426
+ from msg.client_recovery import save_recovery_envelope,rewrap_age_keystore_entry
427
+ from msg.security.age_keys import encryption_key_id,public_from_recipient
428
+ if args.action=='policy':result=await client.call('identity.recovery_policy_get')
429
+ elif args.action=='policy-set':
430
+ require(args.clear or bool(args.recipient),'recovery_recipients_required')
431
+ require(not (args.clear and args.recipient),'ambiguous_recovery_policy')
432
+ require(state.encryption_recipient is not None,'encryption_key_not_found')
433
+ current=client.checked(await client.call('identity.recovery_policy_get'))
434
+ result=await client.call('identity.recovery_policy_set',{
435
+ 'expected_version':current.data['version'],
436
+ 'encryption_key_id':encryption_key_id(public_from_recipient(
437
+ state.encryption_recipient)),
438
+ 'recipients':[] if args.clear else [{'recipient':value}
439
+ for value in args.recipient]})
440
+ elif args.action=='backup':
441
+ stored,envelope,metadata=await save_recovery_envelope(
442
+ client,args.recipient,policy_version=args.policy_version,name=args.name)
443
+ result={'keystore':result_wire(stored),'envelope':result_wire(envelope),
444
+ 'metadata':metadata}
445
+ elif args.action=='list':result=await client.call('identity.recovery_envelope_list')
446
+ elif args.action=='get':result=await client.call('identity.recovery_envelope_get',
447
+ {'id':args.id})
448
+ else:result=await rewrap_age_keystore_entry(client,args.resource,
449
+ args.old_identity,expected_revision=args.expected_revision)
450
+ elif command=='legacy':
451
+ if args.action in {'put','archive'}:
452
+ payload=arguments(args.spec) if args.action=='put' else {}
453
+ expected=()
454
+ if args.action=='archive' or 'expected_revision' in payload:
455
+ require(state.subject is not None,'subject_required')
456
+ current=client.checked(await client.call('identity.legacy_get',
457
+ {'subject_id':state.subject}))
458
+ if 'expected_revision' in payload:
459
+ require(current.data['revision']==payload['expected_revision'],
460
+ 'revision_conflict')
461
+ meta=client.checked(await client.call('discovery.get',
462
+ {'id':current.data['id'],'view':'meta'}))
463
+ expected=((current.data['id'],meta.data['generation']),)
464
+ operation='identity.legacy_put' if args.action=='put' else 'identity.legacy_archive'
465
+ result=await client.call(operation,payload,expected=expected,
466
+ contract_version=args.contract_version if args.action=='put' else 1)
467
+ elif args.action=='status':result=await client.call('identity.legacy_status')
468
+ else:
469
+ subject=args.subject or state.subject
470
+ require(subject is not None,'subject_required')
471
+ params={'subject_id':subject}
472
+ if args.revision:params['revision']=args.revision
473
+ result=await client.call('identity.legacy_get',params)
474
+ elif command=='hosting':
475
+ if args.action=='create':
476
+ require(state.signer is not None and state.token is None,
477
+ 'signing_identity_required')
478
+ result=await client.call('hosting.create',{'parent':args.parent,'name':args.name})
479
+ elif args.action in {'preview','deploy'}:
480
+ entries=json_input(args.entries)
481
+ if args.action=='preview':result=await client.hosting_preview(args.website,entries)
482
+ else:result=await client.hosting_deploy(args.website,entries)
483
+ elif args.action=='activate':
484
+ result=await client.hosting_activate(args.website,args.revision)
485
+ elif args.action=='history':
486
+ result=await client.call('discovery.get',{'id':args.website,'view':'history'})
487
+ else:
488
+ # A downloaded HTML/SVG file has no server CSP when opened from
489
+ # disk. Keep the CLI's preview artifact inert by construction.
490
+ require(args.output.suffix.lower() in {'.txt','.bin'},
491
+ 'inert_preview_output_required')
492
+ data,metadata=await client.hosting_preview_get(
493
+ args.site_path,args.candidate_id,args.file_path)
494
+ flags=os.O_CREAT|os.O_EXCL|os.O_WRONLY
495
+ if hasattr(os,'O_NOFOLLOW'):flags|=os.O_NOFOLLOW
496
+ fd=os.open(args.output,flags,0o600)
497
+ try:
498
+ with os.fdopen(fd,'wb') as output:output.write(data)
499
+ except BaseException:
500
+ args.output.unlink(missing_ok=True)
501
+ raise
502
+ result={'status':'ok','output':str(args.output),'bytes':len(data),**metadata}
503
+ elif command=='mcp':
504
+ from msg.transports.stdio import serve_stdio
505
+ await serve_stdio(client)
506
+ return 0
507
+ elif command=='tui':
508
+ from msg.tui import run_tui
509
+ await run_tui(client)
510
+ return 0
511
+ else: raise Failure('unknown_command')
512
+ rendered=result_wire(result) if isinstance(result,OperationResult) else result
513
+ if command=='hosting' and args.action=='preview' and isinstance(rendered,dict) and rendered.get('status')=='ok':
514
+ rendered['data'].pop('url',None)
515
+ print(canonical(rendered).decode())
516
+ return 1 if isinstance(result,OperationResult) and result.status=='error' else 0
517
+ finally:
518
+ await transport.close()
519
+
520
+
521
+ def main(argv=None):
522
+ args=parser().parse_args(argv)
523
+ try:
524
+ return asyncio.run(run(args))
525
+ except Failure as exc:
526
+ print(canonical({'status':'error','error':exc.as_dict()}).decode(),file=sys.stderr)
527
+ return 1
528
+ except (OSError,ValueError) as exc:
529
+ # File contents and credential-bearing URLs never appear in errors.
530
+ print(canonical({'status':'error','error':{'code':'local_io_error','type':type(exc).__name__}}).decode(),file=sys.stderr)
531
+ return 1
532
+ except KeyboardInterrupt:
533
+ return 130
534
+
535
+
536
+ if __name__=='__main__':
537
+ raise SystemExit(main())