@minaim/openclaw-channel 0.0.0-stage → 0.2.0
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.
- package/README.md +50 -2
- package/bridge.mjs +92 -0
- package/diagnostics.mjs +47 -0
- package/index.mjs +128 -0
- package/openclaw.plugin.json +25 -0
- package/package.json +55 -4
- package/setup.mjs +37 -0
- package/vendor/__init__.py +0 -0
- package/vendor/compatibility.json +7 -0
- package/vendor/mina_agent.py +363 -0
- package/vendor/mina_media.py +172 -0
- package/vendor/mina_native.py +293 -0
- package/vendor/mina_setup.py +133 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Private-only native host bridge. Credentials stay in the protocol worker.
|
|
3
|
+
|
|
4
|
+
JSON-lines is a local process protocol, never a remote command interface. A
|
|
5
|
+
host request is emitted only after Runner has durably saved the start permit.
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import base64
|
|
9
|
+
import copy
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
import re
|
|
14
|
+
import select
|
|
15
|
+
import signal
|
|
16
|
+
import stat
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
if __package__:
|
|
21
|
+
from .mina_agent import ConfigurationError, ProtocolError, HttpGateway, Journal, Runner, encode
|
|
22
|
+
else:
|
|
23
|
+
from mina_agent import ConfigurationError, ProtocolError, HttpGateway, Journal, Runner, encode
|
|
24
|
+
|
|
25
|
+
if __package__:
|
|
26
|
+
from .mina_media import MediaFiles
|
|
27
|
+
else:
|
|
28
|
+
from mina_media import MediaFiles
|
|
29
|
+
|
|
30
|
+
FRAME_LIMIT = 131072
|
|
31
|
+
CONFIG_KEYS = {'gateway', 'app_id', 'bot_id', 'owner_id', 'app_secret', 'credential_version', 'state_file'}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def strict_object(pairs):
|
|
35
|
+
result = {}
|
|
36
|
+
for key, value in pairs:
|
|
37
|
+
if key in result:
|
|
38
|
+
raise ConfigurationError('duplicate JSON key')
|
|
39
|
+
result[key] = value
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def decode(raw):
|
|
44
|
+
try:
|
|
45
|
+
value = json.loads(raw, object_pairs_hook=strict_object)
|
|
46
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
47
|
+
raise ConfigurationError('invalid bridge JSON') from None
|
|
48
|
+
if not isinstance(value, dict):
|
|
49
|
+
raise ConfigurationError('bridge object required')
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_config(path):
|
|
54
|
+
path = pathlib.Path(path)
|
|
55
|
+
try:
|
|
56
|
+
fd = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | os.O_NONBLOCK)
|
|
57
|
+
except OSError:
|
|
58
|
+
raise ConfigurationError('native config cannot be opened safely') from None
|
|
59
|
+
try:
|
|
60
|
+
info = os.fstat(fd)
|
|
61
|
+
if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o077 or info.st_size > 16384:
|
|
62
|
+
raise ConfigurationError('config must be a private regular file, mode 0600')
|
|
63
|
+
with os.fdopen(fd, 'rb', closefd=False) as stream:
|
|
64
|
+
config = decode(stream.read(16385))
|
|
65
|
+
finally:
|
|
66
|
+
os.close(fd)
|
|
67
|
+
result=validate_configuration(config)
|
|
68
|
+
result['_config_path']=str(path.resolve())
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def validate_configuration(config):
|
|
73
|
+
config=dict(config)
|
|
74
|
+
if not CONFIG_KEYS.issubset(config) or set(config)-CONFIG_KEYS-{'media_roots','media_hosts'}:
|
|
75
|
+
raise ConfigurationError('unexpected or missing native config fields')
|
|
76
|
+
for key, pattern in [('app_id', r'mina_[a-f0-9]{32}'), ('bot_id', r'bot_[a-f0-9]{32}'),
|
|
77
|
+
('owner_id', r'[A-Za-z0-9_-]{1,128}'), ('app_secret', r'[A-Za-z0-9_-]{32,128}')]:
|
|
78
|
+
if not isinstance(config[key], str) or not re.fullmatch(pattern, config[key]):
|
|
79
|
+
raise ConfigurationError('invalid native identity configuration')
|
|
80
|
+
version = config['credential_version']
|
|
81
|
+
if type(version) is not int or version < 1 or version > 9007199254740991:
|
|
82
|
+
raise ConfigurationError('invalid credential version')
|
|
83
|
+
if not isinstance(config['state_file'], str) or not pathlib.Path(config['state_file']).is_absolute():
|
|
84
|
+
raise ConfigurationError('state_file must be an absolute private path')
|
|
85
|
+
HttpGateway(config['gateway'], config['app_id'], config['app_secret'], version)
|
|
86
|
+
for name in ('media_roots','media_hosts'):
|
|
87
|
+
values=config.get(name,[])
|
|
88
|
+
if not isinstance(values,list) or len(values)>16 or any(not isinstance(x,str) or not x for x in values):
|
|
89
|
+
raise ConfigurationError('invalid media configuration')
|
|
90
|
+
if any(not pathlib.Path(x).is_absolute() for x in config.get('media_roots',[])):
|
|
91
|
+
raise ConfigurationError('media roots must be absolute')
|
|
92
|
+
if any(not re.fullmatch(r'[a-z0-9][a-z0-9.-]{0,252}',x) for x in config.get('media_hosts',[])):
|
|
93
|
+
raise ConfigurationError('media hosts must be lowercase hostnames')
|
|
94
|
+
return config
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def validate_event(event, config):
|
|
98
|
+
if not isinstance(event, dict):
|
|
99
|
+
raise ConfigurationError('invalid native event')
|
|
100
|
+
for key, expected in [('schema_version', '1'), ('event_type', 'message.created'),
|
|
101
|
+
('bot_id', config['bot_id']), ('sender_id', config['owner_id']),
|
|
102
|
+
('conversation_id', config['owner_id']), ('conversation_type', 'private'),
|
|
103
|
+
('credential_version', config['credential_version'])]:
|
|
104
|
+
if event.get(key) != expected or (type(expected) is int and type(event.get(key)) is not int):
|
|
105
|
+
raise ConfigurationError('native private event scope mismatch')
|
|
106
|
+
for key, pattern in [('event_id', r'evt_[a-f0-9]{32}'),
|
|
107
|
+
('session_key', r'mina:private:[a-f0-9]{64}'),
|
|
108
|
+
('message_id', r'[A-Za-z0-9_-]{1,128}')]:
|
|
109
|
+
if not isinstance(event.get(key), str) or not re.fullmatch(pattern, event[key]):
|
|
110
|
+
raise ConfigurationError('invalid trusted event metadata')
|
|
111
|
+
for key in ('timestamp_ms', 'expires_at_ms', 'context_epoch'):
|
|
112
|
+
if type(event.get(key)) is not int or event[key] < 1 or event[key] > 9007199254740991:
|
|
113
|
+
raise ConfigurationError('invalid event lifecycle metadata')
|
|
114
|
+
if event['expires_at_ms'] <= event['timestamp_ms']:
|
|
115
|
+
raise ConfigurationError('invalid event deadline')
|
|
116
|
+
kind=event.get('message_type')
|
|
117
|
+
items=event.get('attachments',[])
|
|
118
|
+
if kind not in ('text','image','file') or not isinstance(items,list) or (kind=='text' and items) or (kind!='text' and len(items)!=1):
|
|
119
|
+
raise ConfigurationError('unsupported native attachment event')
|
|
120
|
+
for item in items:
|
|
121
|
+
if (not isinstance(item,dict) or item.get('kind')!=kind
|
|
122
|
+
or not isinstance(item.get('attachment_id'),str) or not re.fullmatch(r'att_[a-f0-9]{32}',item['attachment_id'])
|
|
123
|
+
or not isinstance(item.get('sha256'),str) or not re.fullmatch(r'[a-f0-9]{64}',item['sha256'])
|
|
124
|
+
or type(item.get('size')) is not int or not 1<=item['size']<=(8388608 if kind=='image' else 20971520)):
|
|
125
|
+
raise ConfigurationError('invalid attachment metadata')
|
|
126
|
+
if not isinstance(event.get('content'), str) or not (0 if items else 1) <= len(event['content'].encode('utf-8')) <= 8192:
|
|
127
|
+
raise ConfigurationError('unsupported native input')
|
|
128
|
+
return event
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def read_frame(stream):
|
|
132
|
+
raw = stream.readline(FRAME_LIMIT + 1)
|
|
133
|
+
if not raw.endswith(b'\n') or len(raw) > FRAME_LIMIT:
|
|
134
|
+
raise ConfigurationError('missing or oversized bridge frame')
|
|
135
|
+
return decode(raw)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class NativeRunner(Runner):
|
|
139
|
+
def __init__(self, gateway, journal, handler, config, client_type='Mina native'):
|
|
140
|
+
super().__init__(gateway, journal, handler, client_type)
|
|
141
|
+
self.config = config
|
|
142
|
+
self.capabilities=['text','image','file']
|
|
143
|
+
self.media_files=MediaFiles(config)
|
|
144
|
+
self.prepared={}
|
|
145
|
+
|
|
146
|
+
def connect(self, takeover=False):
|
|
147
|
+
response = super().connect(takeover)
|
|
148
|
+
if (response.get('bot_id') != self.config['bot_id'] or response.get('schema_version') != '1'
|
|
149
|
+
or not {'text','polling','private'}.issubset(response.get('capabilities', []))):
|
|
150
|
+
self.stop.set()
|
|
151
|
+
raise ConfigurationError('native connection identity or protocol mismatch')
|
|
152
|
+
self.cleanup_terminal()
|
|
153
|
+
return response
|
|
154
|
+
|
|
155
|
+
def cleanup_terminal(self):
|
|
156
|
+
now=int(time.time()*1000)
|
|
157
|
+
if now-getattr(self,'last_media_cleanup',0)<60000: return
|
|
158
|
+
self.last_media_cleanup=now
|
|
159
|
+
with self.journal.lock:
|
|
160
|
+
rows=self.journal.db.execute("SELECT id FROM events WHERE state IN ('submitted','expired','fenced','unknown') AND created_ms<?",(now-86400000,)).fetchall()
|
|
161
|
+
for row in rows: self.media_files.cleanup(row['id'])
|
|
162
|
+
|
|
163
|
+
def before_start(self,event):
|
|
164
|
+
prepared=copy.deepcopy(event)
|
|
165
|
+
for item in prepared.get('attachments',[]):
|
|
166
|
+
response=self.request('/bot/v1/events/'+event['event_id']+'/media/'+item['attachment_id']+'/download')
|
|
167
|
+
if any(response.get(key)!=item.get(key) for key in ('attachment_id','sha256','size','kind','name')):
|
|
168
|
+
raise ConfigurationError('input attachment descriptor mismatch')
|
|
169
|
+
try:data=base64.b64decode(response['data_base64'],validate=True)
|
|
170
|
+
except (ValueError,KeyError):raise ConfigurationError('invalid input attachment') from None
|
|
171
|
+
item['path']=self.media_files.save_input(event['event_id'],item,data)
|
|
172
|
+
self.prepared[event['event_id']]=prepared
|
|
173
|
+
|
|
174
|
+
def invoke_handler(self,event):
|
|
175
|
+
return self.handler(self.prepared.pop(event['event_id'],event))
|
|
176
|
+
|
|
177
|
+
def store_result(self,event,result):
|
|
178
|
+
prepared=self.media_files.prepare_result(result,event['event_id'])
|
|
179
|
+
# Plain text keeps the old journal representation for restart compatibility.
|
|
180
|
+
self.prepared_format='native_media_v1' if prepared['attachments'] else 'text'
|
|
181
|
+
return prepared['text'] if not prepared['attachments'] else encode(prepared)
|
|
182
|
+
|
|
183
|
+
def result_format(self,event):
|
|
184
|
+
return self.prepared_format
|
|
185
|
+
|
|
186
|
+
def submit_result(self,event,row):
|
|
187
|
+
if row.get('result_format','text')!='native_media_v1':
|
|
188
|
+
return super().submit_result(event,row)
|
|
189
|
+
result=decode(row['result'])
|
|
190
|
+
assets=[]
|
|
191
|
+
for item in result['attachments']:
|
|
192
|
+
if item.get('attachment_id'):
|
|
193
|
+
assets.append(item['attachment_id']);continue
|
|
194
|
+
data=self.media_files.read_snapshot(item,event['event_id'])
|
|
195
|
+
uploaded=self.request('/bot/v1/tasks/'+event['event_id']+'/media/upload',execution_id=row['execution_id'],
|
|
196
|
+
upload_id=item['upload_id'],kind=item['kind'],name=item['name'],data_base64=base64.b64encode(data).decode('ascii'))
|
|
197
|
+
asset=uploaded.get('attachment_id')
|
|
198
|
+
if not isinstance(asset,str) or not re.fullmatch(r'att_[a-f0-9]{32}',asset):
|
|
199
|
+
raise ConfigurationError('attachment upload not confirmed')
|
|
200
|
+
assets.append(asset)
|
|
201
|
+
item['attachment_id']=asset
|
|
202
|
+
self.journal.transition(event['event_id'],'result_ready',result=encode(result),result_format='native_media_v1')
|
|
203
|
+
self.request('/bot/v1/tasks/'+event['event_id']+'/reply',execution_id=row['execution_id'],content=result['text'],attachments=assets)
|
|
204
|
+
|
|
205
|
+
def handle(self, event):
|
|
206
|
+
# Validation precedes ACK and the irreversible execution decision, including recovered inbox entries.
|
|
207
|
+
validate_event(event, self.config)
|
|
208
|
+
try:
|
|
209
|
+
return super().handle(event)
|
|
210
|
+
finally:
|
|
211
|
+
row=self.journal.event(event['event_id'])
|
|
212
|
+
if row and row['state']=='submitted': self.media_files.cleanup(event['event_id'],output_only=True)
|
|
213
|
+
self.cleanup_terminal()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class Worker(NativeRunner):
|
|
217
|
+
def report_status(self,code):
|
|
218
|
+
from mina_setup import safe_status
|
|
219
|
+
code=safe_status(code)
|
|
220
|
+
if code!=getattr(self,'last_status',None):
|
|
221
|
+
self.last_status=code
|
|
222
|
+
self.emit(dict(type='status',code=code))
|
|
223
|
+
|
|
224
|
+
def emit(self, value):
|
|
225
|
+
raw = (encode(value) + '\n').encode('utf-8')
|
|
226
|
+
if len(raw) > FRAME_LIMIT:
|
|
227
|
+
raise ConfigurationError('oversized bridge output')
|
|
228
|
+
sys.stdout.buffer.write(raw)
|
|
229
|
+
sys.stdout.buffer.flush()
|
|
230
|
+
|
|
231
|
+
def connect(self, takeover=False):
|
|
232
|
+
connection=super().connect(takeover)
|
|
233
|
+
self.last_status='connected'
|
|
234
|
+
self.emit(dict(type='ready', protocol=1, owner_id=self.config['owner_id'],
|
|
235
|
+
bot_id=self.config['bot_id'], app_id=self.config['app_id'],
|
|
236
|
+
credential_version=self.config['credential_version'],media_enabled={'image','file'}.issubset(connection.get('capabilities',[]))))
|
|
237
|
+
|
|
238
|
+
def execute(self, event):
|
|
239
|
+
self.emit(dict(type='execute', event=event))
|
|
240
|
+
# No late reply can be attached to another request. A stopped lease or host
|
|
241
|
+
# timeout terminates this worker and leaves a durable unknown execution.
|
|
242
|
+
deadline = time.monotonic() + 240
|
|
243
|
+
raw = b''
|
|
244
|
+
while not self.stop.is_set() and time.monotonic() < deadline:
|
|
245
|
+
if not select.select([sys.stdin.buffer], [], [], 0.5)[0]:
|
|
246
|
+
continue
|
|
247
|
+
chunk = os.read(sys.stdin.fileno(), 65536)
|
|
248
|
+
if not chunk:
|
|
249
|
+
break
|
|
250
|
+
raw += chunk
|
|
251
|
+
if len(raw) > FRAME_LIMIT:
|
|
252
|
+
break
|
|
253
|
+
if b'\n' not in raw:
|
|
254
|
+
continue
|
|
255
|
+
if not raw.endswith(b'\n') or raw.count(b'\n') != 1:
|
|
256
|
+
break
|
|
257
|
+
response = decode(raw)
|
|
258
|
+
if (set(response) in ({'type','event_id','content'},{'type','event_id','content','attachments'}) and response['type'] == 'result'
|
|
259
|
+
and response['event_id'] == event['event_id']):
|
|
260
|
+
return {'text':response['content'],'attachments':response['attachments']} if response.get('attachments') else response['content']
|
|
261
|
+
break
|
|
262
|
+
self.stop.set()
|
|
263
|
+
raise ConfigurationError('native host reply missing or invalid')
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def main():
|
|
267
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
268
|
+
parser.add_argument('--config', required=True)
|
|
269
|
+
parser.add_argument('--host', required=True, choices=('OpenClaw', 'Hermes'))
|
|
270
|
+
args = parser.parse_args()
|
|
271
|
+
config = load_config(args.config)
|
|
272
|
+
journal = Journal(config['state_file'], config['app_id'], config['credential_version'])
|
|
273
|
+
worker = Worker(HttpGateway(config['gateway'], config['app_id'], config['app_secret'], config['credential_version']),
|
|
274
|
+
journal, None, config, args.host)
|
|
275
|
+
worker.handler = worker.execute
|
|
276
|
+
signal.signal(signal.SIGTERM, lambda *_: worker.stop.set())
|
|
277
|
+
signal.signal(signal.SIGINT, lambda *_: worker.stop.set())
|
|
278
|
+
try:
|
|
279
|
+
worker.run()
|
|
280
|
+
finally:
|
|
281
|
+
journal.close()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
if __name__ == '__main__':
|
|
285
|
+
try:
|
|
286
|
+
main()
|
|
287
|
+
except BaseException as error:
|
|
288
|
+
from mina_setup import safe_status
|
|
289
|
+
code=safe_status(error.code) if isinstance(error,ProtocolError) else 'configuration_invalid' if isinstance(error,ConfigurationError) else 'network_unavailable' if isinstance(error,OSError) else 'worker_stopped'
|
|
290
|
+
if isinstance(error,ConfigurationError) and str(error)=='another adapter is already using this journal':code='state_locked'
|
|
291
|
+
# The wire carries an allowlisted code only, never exception text or server responses.
|
|
292
|
+
sys.stdout.write(encode(dict(type='error',code=code))+'\n');sys.stdout.flush()
|
|
293
|
+
raise SystemExit(1) from None
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Private local configuration and passive diagnostics. No network calls or journal writes."""
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import secrets
|
|
8
|
+
import stat
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
if __package__:
|
|
12
|
+
from .mina_agent import ConfigurationError
|
|
13
|
+
from .mina_native import validate_configuration,decode
|
|
14
|
+
else:
|
|
15
|
+
from mina_agent import ConfigurationError
|
|
16
|
+
from mina_native import validate_configuration,decode
|
|
17
|
+
|
|
18
|
+
STATUS_CODES=frozenset(('connected','invalid_agent_credentials','authorization_revoked','lease_held',
|
|
19
|
+
'stale_lease','network_unavailable','transport_unavailable','media_unavailable','configuration_invalid',
|
|
20
|
+
'configuration_missing','host_version_unsupported','python_unsupported','platform_unsupported',
|
|
21
|
+
'state_locked','worker_stopped','protocol_error','rate_limited','storage_unavailable'))
|
|
22
|
+
|
|
23
|
+
def safe_status(code):
|
|
24
|
+
return code if isinstance(code,str) and code in STATUS_CODES else 'worker_stopped'
|
|
25
|
+
|
|
26
|
+
def compatibility():
|
|
27
|
+
return json.loads(Path(__file__).with_name('compatibility.json').read_text())
|
|
28
|
+
|
|
29
|
+
def runtime_code(host,host_version=None,python_version=None,platform=None):
|
|
30
|
+
data=compatibility();info=data.get(host)
|
|
31
|
+
if not info or (host_version is not None and host_version not in info['host_versions']):return 'host_version_unsupported'
|
|
32
|
+
if (platform or sys.platform) not in data['platforms']:return 'platform_unsupported'
|
|
33
|
+
current=tuple((python_version or sys.version_info)[:2])
|
|
34
|
+
if current<tuple(info['python_min']) or ('python_max_exclusive' in info and current>=tuple(info['python_max_exclusive'])):return 'python_unsupported'
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
def _directory(path,create=False):
|
|
38
|
+
path=Path(path)
|
|
39
|
+
if not path.is_absolute() or '..' in path.parts:raise ConfigurationError('private_directory_required')
|
|
40
|
+
fd=os.open('/',os.O_RDONLY|os.O_DIRECTORY)
|
|
41
|
+
try:
|
|
42
|
+
for part in path.parts[1:]:
|
|
43
|
+
if create:
|
|
44
|
+
try:os.mkdir(part,0o700,dir_fd=fd)
|
|
45
|
+
except FileExistsError:pass
|
|
46
|
+
nxt=os.open(part,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=fd)
|
|
47
|
+
os.close(fd);fd=nxt
|
|
48
|
+
info=os.fstat(fd)
|
|
49
|
+
if info.st_uid!=os.getuid() or info.st_mode&0o077:raise ConfigurationError('private_directory_required')
|
|
50
|
+
return fd
|
|
51
|
+
except BaseException:
|
|
52
|
+
os.close(fd);raise
|
|
53
|
+
|
|
54
|
+
def _read_at(fd,name):
|
|
55
|
+
file=os.open(name,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK,dir_fd=fd)
|
|
56
|
+
try:
|
|
57
|
+
info=os.fstat(file)
|
|
58
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid!=os.getuid() or info.st_mode&0o077 or info.st_size>16384:raise ConfigurationError('private_file_required')
|
|
59
|
+
with os.fdopen(file,'rb',closefd=False) as stream:return decode(stream.read(16385))
|
|
60
|
+
finally:os.close(file)
|
|
61
|
+
|
|
62
|
+
def atomic_private_json(directory,name,value):
|
|
63
|
+
"""Create-only + immutable retry. Directory FDs prevent parent substitution and final symlinks."""
|
|
64
|
+
fd=_directory(directory,create=True);temporary='.mina-'+secrets.token_hex(12)
|
|
65
|
+
raw=(json.dumps(value,ensure_ascii=False,sort_keys=True,indent=2)+'\n').encode()
|
|
66
|
+
try:
|
|
67
|
+
try:
|
|
68
|
+
previous=_read_at(fd,name)
|
|
69
|
+
except FileNotFoundError:previous=None
|
|
70
|
+
if previous is not None:
|
|
71
|
+
if previous!=value:raise ConfigurationError('configuration_exists')
|
|
72
|
+
return
|
|
73
|
+
file=os.open(temporary,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600,dir_fd=fd)
|
|
74
|
+
try:
|
|
75
|
+
with os.fdopen(file,'wb',closefd=False) as stream:stream.write(raw);stream.flush();os.fsync(file)
|
|
76
|
+
finally:os.close(file)
|
|
77
|
+
try:os.link(temporary,name,src_dir_fd=fd,dst_dir_fd=fd,follow_symlinks=False)
|
|
78
|
+
except FileExistsError:
|
|
79
|
+
if _read_at(fd,name)!=value:raise ConfigurationError('configuration_exists')
|
|
80
|
+
os.fsync(fd)
|
|
81
|
+
finally:
|
|
82
|
+
try:os.unlink(temporary,dir_fd=fd)
|
|
83
|
+
except FileNotFoundError:pass
|
|
84
|
+
os.close(fd)
|
|
85
|
+
|
|
86
|
+
def configure(directory,values):
|
|
87
|
+
directory=Path(directory)
|
|
88
|
+
if not isinstance(values,dict) or 'state_file' in values or not directory.is_absolute() or '..' in directory.parts:raise ConfigurationError('invalid_setup_input')
|
|
89
|
+
version=values.get('credential_version')
|
|
90
|
+
if type(version) is not int or version<1 or version>9007199254740991:raise ConfigurationError('invalid_setup_input')
|
|
91
|
+
result=validate_configuration(dict(values,state_file=str(directory/('state-v'+str(version)+'.sqlite'))))
|
|
92
|
+
for root in result.get('media_roots',[]):
|
|
93
|
+
path=Path(root)
|
|
94
|
+
if root=='/' or not path.is_dir() or path.is_symlink() or directory==path or path in directory.parents:raise ConfigurationError('invalid_media_root')
|
|
95
|
+
try:atomic_private_json(directory,'assistant.json',result)
|
|
96
|
+
except (OSError,ValueError):raise ConfigurationError('private_config_write_failed') from None
|
|
97
|
+
return dict(config_file=str(directory/'assistant.json'),state_file=result['state_file'])
|
|
98
|
+
|
|
99
|
+
def doctor(config_file,host,host_version=None):
|
|
100
|
+
code=runtime_code(host,host_version)
|
|
101
|
+
configured=False
|
|
102
|
+
if code is None:
|
|
103
|
+
fd=None
|
|
104
|
+
try:
|
|
105
|
+
path=Path(config_file)
|
|
106
|
+
if not path.exists() and not path.is_symlink():raise FileNotFoundError()
|
|
107
|
+
fd=_directory(path.parent);values=validate_configuration(_read_at(fd,path.name));configured=True
|
|
108
|
+
code='configuration_valid'
|
|
109
|
+
state=Path(values['state_file'])
|
|
110
|
+
if state.is_symlink():code='configuration_invalid';configured=False
|
|
111
|
+
except FileNotFoundError:code='configuration_missing'
|
|
112
|
+
except Exception:code='configuration_invalid'
|
|
113
|
+
finally:
|
|
114
|
+
if fd is not None:os.close(fd)
|
|
115
|
+
return dict(ok=code=='configuration_valid',code=code,host=host,configured=configured,
|
|
116
|
+
connected=None,network_checked=False,host_version_checked=host_version is not None)
|
|
117
|
+
|
|
118
|
+
def main(argv=None):
|
|
119
|
+
parser=argparse.ArgumentParser(description=__doc__);sub=parser.add_subparsers(dest='command',required=True)
|
|
120
|
+
configure_parser=sub.add_parser('configure');configure_parser.add_argument('--directory',required=True)
|
|
121
|
+
inspect=sub.add_parser('doctor');inspect.add_argument('--config',required=True);inspect.add_argument('--host',choices=('OpenClaw','Hermes'),required=True);inspect.add_argument('--host-version')
|
|
122
|
+
args=parser.parse_args(argv)
|
|
123
|
+
try:
|
|
124
|
+
if args.command=='configure':
|
|
125
|
+
raw=sys.stdin.buffer.read(16385)
|
|
126
|
+
if len(raw)>16384:raise ConfigurationError('invalid_setup_input')
|
|
127
|
+
result=configure(args.directory,decode(raw));code=0
|
|
128
|
+
else:result=doctor(args.config,args.host,args.host_version);code=0 if result['ok'] else 1
|
|
129
|
+
except Exception:
|
|
130
|
+
result={'ok':False,'code':'configuration_invalid'};code=1
|
|
131
|
+
print(json.dumps(result,ensure_ascii=False));return code
|
|
132
|
+
|
|
133
|
+
if __name__=='__main__':raise SystemExit(main())
|