@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.
@@ -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())