@topolo/cli 0.13.2 → 0.13.4

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.
@@ -5,6 +5,7 @@ or another Codex profile. The renderer bridge is available only in the managed
5
5
  profile explicitly launched with local debugging enabled.
6
6
  """
7
7
  import asyncio
8
+ from contextlib import closing
8
9
  import base64
9
10
  import hashlib
10
11
  import json
@@ -24,7 +25,16 @@ import urllib.parse
24
25
  import uuid
25
26
 
26
27
  from .storage import Library,PackError,atomic_json,canonical,read_json
27
- from .execution import load_gate, qualify_protocol
28
+ from .execution import ExecutionGate, load_gate, prepare_runtime, qualify_protocol
29
+ from .approvals import ApprovalRouter
30
+
31
+ def permission_settings(agent, current, consent):
32
+ from .policy import settings
33
+ wanted=settings(agent,'codex',consent)['nativeSettings']
34
+ # Permission transitions use canonical defaults, never old applied grants.
35
+ # Keep independently qualified models and effort on the owned session.
36
+ for field in ['model','model_reasoning_effort']:wanted[field]=current[field]
37
+ return wanted
28
38
 
29
39
  def socket_path(lib):
30
40
  directory=pathlib.Path('/tmp')/('topolo-agent-pack-'+hashlib.sha256(str(lib.root).encode()).hexdigest()[:16])
@@ -32,14 +42,14 @@ def socket_path(lib):
32
42
  directory.mkdir(mode=0o700,exist_ok=True)
33
43
  return directory/'control.sock'
34
44
 
35
- def request(lib,operation):
45
+ def request(lib,operation,params=None):
36
46
  record=read_json(lib.root/'live-bridge.json',{})
37
47
  if not record: raise PackError('This desktop profile has no live bridge. Open it through topolo-agent-pack first.')
38
48
  with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as connection:
39
- connection.settimeout(3 if operation=='status' else 20)
49
+ connection.settimeout(3 if operation=='status' else 60 if operation in {'permissions-configure','owned-roster-read'} else 20)
40
50
  try: connection.connect(record['socket'])
41
51
  except OSError as exc: raise PackError('Managed live bridge is unavailable: '+str(exc)) from exc
42
- connection.sendall(canonical({'token':record['token'],'operation':operation})+b'\n')
52
+ connection.sendall(canonical({'token':record['token'],'operation':operation,**({'params':params} if params is not None else {})})+b'\n')
43
53
  with connection.makefile('rb') as stream:
44
54
  line=stream.readline(1024*1024)
45
55
  if not line: raise PackError('Live bridge closed without a reply')
@@ -71,12 +81,18 @@ class BackendProxy:
71
81
  self.token=secrets.token_hex(32);self.path=socket_path(lib)
72
82
  self.execution=None;self.catalog_ready=asyncio.Event();self.catalog_id='topolo-catalog-'+uuid.uuid4().hex
73
83
  self.catalog_rows=[];self.catalog_cursors=set()
84
+ self.approvals=None;self.native_calls={};self.notifications=set()
85
+ self.permission_configuration=False
86
+ self.permission_batch=None
87
+ self._permission_configuration_inputs=None
74
88
 
75
89
  async def start_child(self):
76
90
  active=self.lib.active()
77
91
  if not active.get('runtime'): raise PackError('No selected managed runtime')
78
92
  self.runtime=active['runtime'];env=os.environ.copy()
79
93
  self.execution=load_gate(self.runtime);self.catalog_ready.clear()
94
+ self.approvals=ApprovalRouter(pathlib.Path(self.runtime)/'approval-inbox',
95
+ list(self.execution.roles.values()),uuid.uuid4().hex) if self.execution else None
80
96
  self.catalog_rows=[];self.catalog_cursors=set()
81
97
  self.protocol=qualify_protocol(self.real) if self.execution else None
82
98
  env['CODEX_HOME']=str(pathlib.Path(self.runtime)/'home');env['CODEX_CLI_PATH']=self.real
@@ -86,6 +102,10 @@ class BackendProxy:
86
102
  async def output(self):
87
103
  while line:=await self.child.stdout.readline():
88
104
  value=json.loads(line)
105
+ if str(value.get('id')) in self.native_calls and ('result' in value or 'error' in value):
106
+ future=self.native_calls.pop(str(value['id']));self.pending.discard(str(value['id']))
107
+ if not future.done():future.set_result(value)
108
+ continue
89
109
  if value.get('id')==self.catalog_id:
90
110
  self.pending.discard(self.catalog_id)
91
111
  if self.execution and 'result' in value:
@@ -107,11 +127,19 @@ class BackendProxy:
107
127
  self.pending.discard(str(value['id']))
108
128
  if 'result' in value and isinstance(value['result'],dict) and 'userAgent' in value['result']: self.initialized=True
109
129
  method=value.get('method');params=value.get('params') or {}
130
+ if self.approvals:
131
+ record=self.approvals.capture(value)
132
+ if record and record['sourceThreadId']!=record['intakeThreadId'] and record['notificationState']!='delivered':
133
+ self.schedule_notification(record)
110
134
  if method=='turn/started': self.active_turns.add(params['threadId'])
111
135
  if method=='turn/completed': self.active_turns.discard(params['threadId'])
112
136
  if method=='thread/status/changed':
113
137
  if params.get('status',{}).get('type')=='active':self.active_turns.add(params['threadId'])
114
138
  elif params.get('status',{}).get('type') in {'idle','notLoaded','systemError'}:self.active_turns.discard(params['threadId'])
139
+ if self.approvals and params.get('status',{}).get('type')=='idle':
140
+ for row in self.approvals.records():
141
+ if row['nativeConnectionCurrent'] and row['intakeThreadId']==params['threadId'] and row['notificationState']!='delivered':
142
+ self.schedule_notification(row)
115
143
  sys.stdout.buffer.write(line);sys.stdout.buffer.flush()
116
144
 
117
145
  async def stop_child(self):
@@ -124,18 +152,28 @@ class BackendProxy:
124
152
  if self.output_task: await self.output_task
125
153
 
126
154
  async def forward(self,value):
155
+ if self.permission_configuration and value.get('method') in {'thread/resume','turn/start'}:
156
+ sys.stdout.buffer.write(canonical({'id':value.get('id'),'error':{'code':-32000,'message':'Owned permission configuration in progress'}})+b'\n')
157
+ sys.stdout.buffer.flush();return
158
+ if value.get('method') in {'thread/resume','turn/start'} and (pathlib.Path(self.runtime)/'permissions-operation.json').exists():
159
+ sys.stdout.buffer.write(canonical({'id':value.get('id'),'error':{'code':-32000,
160
+ 'message':'Unresolved native permission operation requires reconciliation before execution'}})+b'\n')
161
+ sys.stdout.buffer.flush();return
127
162
  if self.quiesced:
128
163
  if 'id' in value:
129
164
  sys.stdout.buffer.write(canonical({'id':value['id'],'error':{'code':-32000,'message':'Pack switch in progress; reconnecting backend'}})+b'\n');sys.stdout.buffer.flush()
130
165
  return
131
166
  if self.execution and value.get('method') in {'thread/resume','turn/start'} and (value.get('params') or {}).get('threadId') in self.execution.roles:
132
167
  try:
168
+ await self.qualify_organization((value.get('params') or {})['threadId'])
133
169
  await asyncio.wait_for(self.catalog_ready.wait(),10)
134
170
  value=self.execution.prepare(value)
135
171
  except (PackError,asyncio.TimeoutError) as exc:
136
172
  sys.stdout.buffer.write(canonical({'id':value.get('id'),'error':{'code':-32000,'message':str(exc) or 'Native model catalog unavailable'}})+b'\n');sys.stdout.buffer.flush();return
137
173
  if 'id' in value and 'method' in value:self.pending.add(str(value['id']))
174
+ if self.approvals and 'method' not in value and ('result' in value or 'error' in value):self.approvals.sending(value)
138
175
  self.child.stdin.write(canonical(value)+b'\n');await self.child.stdin.drain()
176
+ if self.approvals and 'method' not in value and ('result' in value or 'error' in value):self.approvals.settled(value)
139
177
  if self.execution and value.get('method')=='initialized':
140
178
  await self.request_catalog()
141
179
 
@@ -145,6 +183,273 @@ class BackendProxy:
145
183
  'params':{'includeHidden':True,**({'cursor':cursor} if cursor else {})}})+b'\n')
146
184
  await self.child.stdin.drain()
147
185
 
186
+ async def call_native(self,method,params):
187
+ identity='topolo-relay-'+uuid.uuid4().hex
188
+ request={'id':identity,'method':method,'params':params}
189
+ if method in {'thread/resume','turn/start'}:
190
+ if not self.permission_configuration and (pathlib.Path(self.runtime)/'permissions-operation.json').exists():
191
+ raise PackError('Unresolved native permission operation requires reconciliation before execution')
192
+ await self.qualify_organization(params['threadId'])
193
+ await asyncio.wait_for(self.catalog_ready.wait(),10)
194
+ if self.permission_configuration:
195
+ if self._permission_configuration_inputs is None:
196
+ raise PackError('No owned permission configuration snapshot; native settings were not sent')
197
+ self.configuration_current(*self._permission_configuration_inputs)
198
+ request=self.execution.prepare(request)
199
+ future=asyncio.get_running_loop().create_future();self.native_calls[identity]=future;self.pending.add(identity)
200
+ try:
201
+ self.child.stdin.write(canonical(request)+b'\n');await self.child.stdin.drain()
202
+ reply=await asyncio.wait_for(future,15)
203
+ except BaseException:
204
+ self.native_calls.pop(identity,None);self.pending.discard(identity);raise
205
+ reply=self.execution.reply(reply) if self.execution else reply
206
+ if 'error' in reply:
207
+ raise PackError('Native intake relay refused; original request remains pending')
208
+ return reply['result']
209
+
210
+ async def qualify_organization(self,thread_id):
211
+ role=self.execution.roles.get(thread_id) if self.execution else None
212
+ if role and not self.permission_configuration and (pathlib.Path(self.runtime)/'permissions-operation.json').exists():
213
+ raise PackError('Unresolved native permission operation requires reconciliation before owned execution or grants')
214
+ if not role or not role.get('installationConsent'):
215
+ if role and role['settings']['sandbox_mode']=='danger-full-access':
216
+ raise PackError('Full access requires a fresh authenticated organization ceiling')
217
+ return
218
+ from . import topolo
219
+ consent=role['installationConsent']
220
+ agents=self.lib.load(consent['pack'])[0]['agents']
221
+ matches=[a for a in agents if '/'.join([a['pack'],a['set'],a['id']])==role['key'] and a['threadId']==role['threadId']]
222
+ if len(matches)!=1:
223
+ raise PackError('Current consent has no unique live owned role')
224
+ if role['settings']!=permission_settings(matches[0],role['settings'],consent):
225
+ raise PackError('Effective native permission intent differs from the current consent profile')
226
+ if self.permission_batch and self.permission_batch['consent']==consent:
227
+ if time.monotonic()>=self.permission_batch['deadline']:
228
+ raise PackError('Qualified native permission batch expired; reconcile without replay')
229
+ role['organizationPolicyProof']=self.permission_batch['proof'];return
230
+ _,proof=await asyncio.to_thread(topolo.organization_execution_policy,
231
+ topolo.Client(consent['environment'],consent['contextKey']),consent,agents,consent.get('agentWorkspaceId'))
232
+ from .permissions import authorize_native_scope
233
+ authorize_native_scope(proof)
234
+ role['organizationPolicyProof']=proof
235
+
236
+ def existing_runtime_intent(self):
237
+ """Full existing composition only; never use a new composition as migration."""
238
+ active=self.lib.active()
239
+ components=active.get('components',[])
240
+ if str(active.get('runtime'))!=str(self.runtime) or not components:
241
+ raise PackError('Selected runtime has no complete owned source roster')
242
+ document=prepare_runtime(self.lib,components,self.runtime)
243
+ roster=read_json(pathlib.Path(self.runtime)/'agents.json')['agents']
244
+ def identities(rows):
245
+ values=[('/'.join([r['pack'],r['set'],r['id']]),r['threadId']) for r in rows]
246
+ if len(set(values))!=len(values):
247
+ raise PackError('Duplicate runtime roster membership')
248
+ return set(values)
249
+ declared={(r['key'],r['threadId']) for r in document['roles']}
250
+ with closing(sqlite3.connect('file:'+str(pathlib.Path(self.runtime)/'home/state_5.sqlite')+'?mode=ro',uri=True)) as db:
251
+ native_ids=[r[0] for r in db.execute('SELECT id FROM threads')]
252
+ if len(native_ids)!=len(declared) or set(native_ids)!={tid for _,tid in declared}:
253
+ raise PackError('Native runtime membership differs from full selected composition')
254
+ if identities(roster)!=declared:
255
+ raise PackError('Runtime roster differs from selected source membership')
256
+ for component in components:
257
+ current,revision=self.lib.load(component['sourcePack'])
258
+ if revision!=component['revision'] or (component['key'],component['threadId']) not in identities(current['agents']):
259
+ raise PackError('Current source revision or role identity differs from selected runtime')
260
+ return document
261
+
262
+ def preservation(self,roles):
263
+ paths={pathlib.Path(self.runtime)/'agents.json'}
264
+ for role in roles:
265
+ cwd=pathlib.Path(role['cwd'])
266
+ if not (cwd/'AGENTS.md').is_file():
267
+ raise PackError('Owned role starting context is missing')
268
+ paths.update(cwd.rglob('*.md'))
269
+ home=pathlib.Path(self.runtime)/'home'
270
+ for name in ['sessions','archived_sessions']:
271
+ paths.update((home/name).rglob('*.jsonl'))
272
+ for path in paths:
273
+ if path.is_symlink() or not path.is_file():
274
+ raise PackError('Context and transcript preservation requires regular owned files')
275
+ return {str(p):hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(paths)}
276
+
277
+ async def read_catalog(self):
278
+ rows=[];cursors=set();models=set();cursor=None
279
+ while True:
280
+ result=await self.call_native('model/list',{'includeHidden':True,**({'cursor':cursor} if cursor else {})})
281
+ for row in result['data']:
282
+ if not isinstance(row.get('model'),str) or not row['model'] or row['model'] in models:
283
+ raise PackError('Native model catalog identity is unqualified')
284
+ models.add(row['model']);rows.append(row)
285
+ cursor=result.get('nextCursor')
286
+ if not cursor:return {'data':rows}
287
+ if not isinstance(cursor,str) or cursor in cursors or len(cursors)>=20:
288
+ raise PackError('Native model catalog pagination is unqualified')
289
+ cursors.add(cursor)
290
+
291
+ async def owned_roster_read(self,params):
292
+ """Finite token-authenticated metadata reads, not an arbitrary RPC dispatcher."""
293
+ if params or self.permission_configuration or self.quiesced or not self.initialized or self.active_turns or self.pending or self.notifications:
294
+ raise PackError('Owned read requires an idle initialized connection and no caller overrides')
295
+ self.permission_configuration=True
296
+ try:
297
+ document=self.existing_runtime_intent();rows=[]
298
+ protocol=qualify_protocol(self.real)
299
+ for role in document['roles']:
300
+ state=await self.call_native('thread/read',{'threadId':role['threadId'],'includeTurns':False})
301
+ thread=state.get('thread',{})
302
+ if thread.get('id')!=role['threadId'] or thread.get('cwd')!=role['cwd']:
303
+ raise PackError('Native role identity differs from owned roster')
304
+ rows.append({'key':role['key'],'threadId':role['threadId'],'cwd':role['cwd'],
305
+ 'status':thread.get('status',{}).get('type')})
306
+ catalog=await self.read_catalog()
307
+ return {'state':'native-owned-roster-read','roles':rows,
308
+ 'models':[{'model':r['model'],'supportedReasoningEfforts':r['supportedReasoningEfforts']} for r in catalog['data']],
309
+ 'protocol':protocol,'classification':'native metadata and account catalog; role settings remain intent',
310
+ 'effectiveSettingsVerified':False,'modelTurnsStarted':False}
311
+ finally:self.permission_configuration=False
312
+
313
+ def qualification_inputs(self,roles):
314
+ """Cheap current owned inputs; never cache a changed selection or consent."""
315
+ from . import permissions
316
+ active=self.lib.active();sources={}
317
+ for name in sorted({c['sourcePack'] for c in active.get('components',[])}):
318
+ manifest,revision=self.lib.load(name)
319
+ sources[name]={'revision':revision,'consent':permissions.load(self.lib,name,manifest['agents'])}
320
+ with closing(sqlite3.connect('file:'+str(pathlib.Path(self.runtime)/'home/state_5.sqlite')+'?mode=ro',uri=True)) as db:
321
+ native_cwds=sorted(db.execute('SELECT id,cwd FROM threads'))
322
+ return json.loads(canonical({'selected':{'runtime':active.get('runtime'),'components':active.get('components')},
323
+ 'childPid':self.child.pid,'ownedRuntime':str(self.runtime),
324
+ 'sources':sources,'runtimeRoster':read_json(pathlib.Path(self.runtime)/'agents.json'),
325
+ 'nativeMembershipAndCwds':native_cwds,'contextTranscriptHashes':self.preservation(roles)}))
326
+
327
+ def configuration_current(self,inputs,roles):
328
+ if (self.quiesced or self.closed or not self.child or getattr(self.child,'returncode',None) is not None or
329
+ self.active_turns or self.pending or self.notifications or (self.approvals and self.approvals.pending)):
330
+ raise PackError('Native work or approval arrived during configuration; reconcile pending requests and any recorded effects')
331
+ if inputs is not None and self.qualification_inputs(roles)!=inputs:
332
+ raise PackError('Selected runtime, source, consent, context or transcript changed during qualification; reconcile any recorded effects')
333
+
334
+ async def configure_permissions(self,pack):
335
+ """Explicit legacy adoption or transition on the SAME existing owned child."""
336
+ from . import permissions
337
+ if self.permission_configuration or self.quiesced or self.active_turns or self.pending or self.notifications or (not self.execution and not self.initialized):
338
+ raise PackError('Owned native connection must be initialized, idle and settled')
339
+ marker=pathlib.Path(self.runtime)/'permissions-operation.json'
340
+ if marker.exists():
341
+ raise PackError('Unresolved native permissions operation requires read-only reconciliation')
342
+ if self.approvals and self.approvals.pending:
343
+ raise PackError('Native approvals remain pending; preserve their owning connection')
344
+ self.permission_configuration=True
345
+ legacy=self.execution is None;preservation=None;inputs=None
346
+ try:
347
+ if legacy:
348
+ if (pathlib.Path(self.runtime)/'execution-policy.json').exists():
349
+ raise PackError('Existing policy must be qualified; not a legacy adoption')
350
+ document=self.existing_runtime_intent()
351
+ roles=document['roles'];preservation=self.preservation(roles)
352
+ inputs=self.qualification_inputs(roles)
353
+ provenance='reviewed source-role adoption intent; qualified only by native resume acknowledgements'
354
+ else:
355
+ roles=list(self.execution.roles.values())
356
+ provenance='preserved owned execution-policy model and effort'
357
+ manifest,_=self.lib.load(pack);consent=permissions.load(self.lib,pack,manifest['agents'])
358
+ if not consent:
359
+ raise PackError('Explicit organization-bound installation consent required')
360
+ wanted=[];by_key={r['key']:r for r in roles}
361
+ for agent in manifest['agents']:
362
+ key='/'.join([agent['pack'],agent['set'],agent['id']]);old=by_key.get(key)
363
+ if not old or old['threadId']!=agent['threadId']:
364
+ raise PackError('Complete pack must match the live owned roster')
365
+ wanted.append({**old,'settings':permission_settings(agent,old['settings'],consent),
366
+ 'installationConsent':consent,'intakeKey':consent['intakeRoutes'][key],'contextKey':consent['contextKey']})
367
+ if legacy and len(wanted)!=len(roles):
368
+ raise PackError('Legacy adoption requires the full selected composition')
369
+ changed={r['threadId']:r for r in wanted}
370
+ candidate=ExecutionGate({'format':'topolo-codex-runtime-execution','schemaVersion':1,
371
+ 'roles':[changed.get(r['threadId'],r) for r in roles]})
372
+ # Capture original native requests BEFORE the first awaited legacy read.
373
+ # Existing pending request generation/body custody always stays on this router.
374
+ routing=ApprovalRouter(pathlib.Path(self.runtime)/'approval-inbox',list(candidate.roles.values()),uuid.uuid4().hex)
375
+ if not self.approvals:self.approvals=routing
376
+ self._permission_configuration_inputs=(inputs,roles)
377
+ if legacy:
378
+ self.protocol=qualify_protocol(self.real)
379
+ candidate_catalog=await self.read_catalog()
380
+ self.configuration_current(inputs,roles)
381
+ candidate.set_catalog(candidate_catalog);self.catalog_ready.set()
382
+ else:candidate.catalog=self.execution.catalog.copy()
383
+ for role in wanted:
384
+ state=await self.call_native('thread/read',{'threadId':role['threadId'],'includeTurns':False})
385
+ self.configuration_current(inputs,roles)
386
+ thread=state.get('thread',{})
387
+ if thread.get('id')!=role['threadId'] or thread.get('cwd')!=role['cwd'] or thread.get('status',{}).get('type') not in {'idle','notLoaded'}:
388
+ raise PackError('Native role identity or idle status differs')
389
+ if role['settings']['model_reasoning_effort'] not in candidate.catalog.get(role['settings']['model'],set()):
390
+ raise PackError('Native account lacks the preserved model/effort')
391
+ self.configuration_current(inputs,roles)
392
+ operation={'pack':pack,'before':roles,'requested':wanted,'modelEffortProvenance':provenance,
393
+ 'beforeSettingsClassification':'source intent only' if legacy else 'owned execution policy intent',
394
+ 'protocol':self.protocol,'contextTranscriptHashes':preservation,'qualificationInputs':inputs}
395
+ atomic_json(marker,{**operation,'state':'prepared'})
396
+ self.execution=candidate
397
+ await self.qualify_organization(wanted[0]['threadId'])
398
+ self.configuration_current(inputs,roles)
399
+ self.permission_batch={'consent':consent,'proof':candidate.roles[wanted[0]['threadId']]['organizationPolicyProof'],
400
+ 'deadline':time.monotonic()+45}
401
+ atomic_json(marker,{**operation,'state':'may-have-effects'})
402
+ for role in wanted:
403
+ self.configuration_current(inputs,roles)
404
+ await self.call_native('thread/resume',{'threadId':role['threadId']})
405
+ # First persist ACK progress, then refuse late work/drift. Do not lose a final ACK.
406
+ atomic_json(marker,{**operation,'state':'may-have-effects',
407
+ 'verifiedThreadIds':sorted(candidate.verified)})
408
+ self.configuration_current(inputs,roles)
409
+ self.configuration_current(inputs,roles)
410
+ document={'format':'topolo-codex-runtime-execution','schemaVersion':1,'roles':list(candidate.roles.values()),
411
+ 'applied':False,'modelTurnsStarted':False}
412
+ atomic_json(pathlib.Path(self.runtime)/'execution-policy.json',document)
413
+ # No await between the final custody fence and committing the routing graph.
414
+ # Preserve the physical connection's current router and generation.
415
+ self.approvals.roles=routing.roles;self.approvals.by_key=routing.by_key
416
+ result={'state':'native-permission-resumes-verified','pack':pack,'verifiedAgents':len(wanted),
417
+ 'modelTurnsStarted':False,'historyChanged':False,'appToolApprovalReadbackVerified':False,
418
+ 'intakeRoutes':len(wanted),'nativeApprovalTransport': 'owned-live-connection',
419
+ 'modelEffortProvenance':provenance,'legacyRuntimeAdopted':legacy,
420
+ 'contextTranscriptPreservationVerified':preservation is not None}
421
+ atomic_json(marker,{**result,'verifiedThreadIds':sorted(candidate.verified)})
422
+ atomic_json(pathlib.Path(self.runtime)/'permissions-proof.json',result);marker.unlink()
423
+ return result
424
+ finally:
425
+ self._permission_configuration_inputs=None
426
+ self.permission_configuration=False;self.permission_batch=None
427
+
428
+ def schedule_notification(self,record):
429
+ if (self.permission_configuration or record['key'] in self.notifications or record['notificationState'] not in
430
+ {'not-delivered','awaiting-idle-intake'}):return
431
+ self.notifications.add(record['key'])
432
+ async def deliver():
433
+ try:
434
+ state=await self.call_native('thread/read',{'threadId':record['intakeThreadId'],'includeTurns':False})
435
+ if state.get('thread',{}).get('status',{}).get('type') not in {'idle','notLoaded'}:
436
+ self.approvals.notified(record['requestId'],False);return
437
+ if record['intakeThreadId'] not in self.execution.verified:
438
+ await self.call_native('thread/resume',{'threadId':record['intakeThreadId']})
439
+ self.approvals.live(record['key'],record['intakeThreadId'],record['fingerprint'])
440
+ # A lost turn/start reply cannot be retried on the next idle event.
441
+ # Persist possible delivery before dispatch, including process death.
442
+ self.approvals.notification_state(record['requestId'],'may-have-effects')
443
+ await self.call_native('turn/start',{'threadId':record['intakeThreadId'],
444
+ 'input':[{'type':'text','text':self.approvals.notification(record),'text_elements':[]}]})
445
+ self.approvals.notified(record['requestId'],True)
446
+ except Exception:
447
+ entry=self.approvals.pending.get(str(record['requestId']))
448
+ uncertain=entry and entry['record']['notificationState']=='may-have-effects'
449
+ self.approvals.notification_state(record['requestId'],'delivery-uncertain' if uncertain else 'delivery-failed')
450
+ finally:self.notifications.discard(record['key'])
451
+ asyncio.create_task(deliver())
452
+
148
453
  def status(self):
149
454
  return {'pid':os.getpid(),'childPid':self.child.pid if self.child else None,'desktopPid':os.getppid(),'runtime':self.runtime,
150
455
  'quiesced':self.quiesced,'initialized':self.initialized,
@@ -158,14 +463,42 @@ class BackendProxy:
158
463
  if not secrets.compare_digest(message.get('token',''),self.token): raise PackError('Live bridge authentication failed')
159
464
  op=message.get('operation')
160
465
  if op=='quiesce':
466
+ if self.permission_configuration:
467
+ raise PackError('Permission configuration in progress; preserve the owning native connection')
161
468
  if self.active_turns: raise PackError('Outgoing agents have active turns. Wait for their current turns; Codex can stay open.')
162
469
  if self.pending: raise PackError('Backend requests are in flight. Retry the switch; Codex can stay open.')
163
470
  self.quiesced=True
164
471
  await self.stop_child()
165
472
  elif op=='resume':
166
473
  if self.quiesced: raise PackError('Use the desktop reconnect action to resume a quiesced bridge')
474
+ elif op=='owned-roster-read':
475
+ result=await self.owned_roster_read(message.get('params') or {})
476
+ elif op=='permissions-configure':
477
+ result=await self.configure_permissions((message.get('params') or {})['pack'])
478
+ elif op in {'approval-list','approval-details','approval-respond'}:
479
+ if not self.approvals:
480
+ raise PackError('This connection has no qualified intake routing')
481
+ params=message.get('params') or {}
482
+ if op=='approval-respond' and self.permission_configuration:
483
+ raise PackError('Permission configuration in progress; original native request remains pending')
484
+ if op=='approval-list':result={'requests':self.approvals.records()}
485
+ else:
486
+ entry=self.approvals.live(params['key'],params['intakeThreadId'],params['fingerprint'])
487
+ if op=='approval-details':result={'request':entry['message'],'record':entry['record']}
488
+ else:
489
+ if params.get('humanDecisionRecorded') is not True:
490
+ raise PackError('Recorded human decision required; never auto-approve')
491
+ reply=self.approvals.response(params['key'],params['intakeThreadId'],params['fingerprint'],params['result'])
492
+ if reply['result'].get('decision')=='accept':
493
+ if not self.execution:
494
+ raise PackError('Native approval requires qualified owned execution policy; original request remains pending')
495
+ await self.qualify_organization(entry['record']['sourceThreadId'])
496
+ self.approvals.live(params['key'],params['intakeThreadId'],params['fingerprint'])
497
+ self.approvals.sending(reply)
498
+ self.child.stdin.write(canonical(reply)+b'\n');await self.child.stdin.drain();self.approvals.settled(reply)
499
+ result={'state':'native-response-forwarded','operationEffectVerified':False}
167
500
  elif op!='status': raise PackError('Unknown live bridge operation')
168
- result=self.status()
501
+ if op not in {'approval-list','approval-details','approval-respond','permissions-configure','owned-roster-read'}:result=self.status()
169
502
  except Exception as exc: result={'error':str(exc)}
170
503
  writer.write(canonical(result)+b'\n');await writer.drain();writer.close();await writer.wait_closed()
171
504
 
@@ -0,0 +1,167 @@
1
+ """Organization-bound installation consent, distinct from managed enforcement.
2
+
3
+ Consent is machine local and never shipped in a portable pack. Native managed
4
+ requirements remain authoritative. This file is not an organization admin API.
5
+ """
6
+ import copy
7
+ import pathlib
8
+ import hashlib
9
+ import json
10
+ import re
11
+
12
+ from .storage import PackError, atomic_json, component_key, read_json, safe_id
13
+
14
+ PROFILES = {'manual': 0, 'role-default': 1, 'full-access': 2}
15
+ POLICY_FIELDS = ('allowedProfiles', 'maximumProfile', 'defaultProfile', 'fullAccessEnabled',
16
+ 'allowedToolScopes', 'allowedActionScopes', 'allowedIntakes')
17
+
18
+
19
+ def organization_policy(envelope, context, organization):
20
+ """Validate an authenticated action result; a local JSON never grants authority."""
21
+ required = set(POLICY_FIELDS) | {'contextKey', 'organizationId', 'revision', 'configured',
22
+ 'policyDigest', 'updatedAt', 'updatedByUserId', 'decisionReference'}
23
+ if (not isinstance(envelope, dict) or set(envelope) != {'success', 'contractVersion', 'policy'} or
24
+ envelope['success'] is not True or envelope['contractVersion'] not in {'1.0.0', '1.1.0'}):
25
+ raise PackError('Organization policy contract unavailable or unsupported; no offline authority')
26
+ fields = list(POLICY_FIELDS)
27
+ if envelope['contractVersion'] == '1.1.0':
28
+ required.add('scopePolicyMode'); fields.insert(4, 'scopePolicyMode')
29
+ p = envelope['policy']
30
+ if (not isinstance(p, dict) or set(p) != required or p.get('contextKey') != context or
31
+ p.get('organizationId') != organization or context != 'org:' + organization or
32
+ not isinstance(p.get('revision'), str) or not re.fullmatch(r'0|[1-9][0-9]*', p['revision']) or
33
+ int(p['revision']) > 9007199254740991 or type(p.get('configured')) is not bool or
34
+ type(p.get('fullAccessEnabled')) is not bool):
35
+ raise PackError('Organization policy identity, revision or shape differs')
36
+ if 'scopePolicyMode' in required and p['scopePolicyMode'] not in {'allowlist', 'unrestricted'}:
37
+ raise PackError('Organization scope policy mode is invalid')
38
+ for field, maximum in [('allowedProfiles', 3), ('allowedToolScopes', 500),
39
+ ('allowedActionScopes', 500), ('allowedIntakes', 200)]:
40
+ values = p[field]
41
+ pattern = (r'[a-z0-9][a-z0-9-]*/intakes/[a-z0-9][a-z0-9-]*' if field == 'allowedIntakes'
42
+ else r'[A-Za-z0-9][A-Za-z0-9_.:/-]*')
43
+ if (not isinstance(values, list) or len(values) > maximum or
44
+ any(not isinstance(v, str) or not 1 <= len(v) <= 255 or
45
+ not re.fullmatch(pattern, v) for v in values) or
46
+ len(set(values)) != len(values)):
47
+ raise PackError('Organization policy has invalid exact scope identifiers')
48
+ if p.get('scopePolicyMode') == 'unrestricted' and (p['allowedToolScopes'] or p['allowedActionScopes']):
49
+ raise PackError('Unrestricted mode must not contain ambiguous scope allowlists')
50
+ allowed = p['allowedProfiles']
51
+ if (not allowed or any(v not in PROFILES for v in allowed) or
52
+ p['maximumProfile'] not in PROFILES or p['defaultProfile'] not in allowed or
53
+ any(PROFILES[v] > PROFILES[p['maximumProfile']] for v in allowed) or
54
+ ('full-access' in allowed or p['maximumProfile'] == 'full-access') and not p['fullAccessEnabled']):
55
+ raise PackError('Organization policy profile ceiling is inconsistent')
56
+ if (p['updatedAt'] is not None and (type(p['updatedAt']) is not int or p['updatedAt'] < 0) or
57
+ any(p[field] is not None and (not isinstance(p[field], str) or not 1 <= len(p[field]) <= 255)
58
+ for field in ['updatedByUserId', 'decisionReference'])):
59
+ raise PackError('Organization policy audit fields are invalid')
60
+ if not p['configured'] and (p['revision'] != '0' or allowed != ['manual'] or
61
+ p['maximumProfile'] != 'manual' or p['defaultProfile'] != 'manual' or p['fullAccessEnabled'] or
62
+ any(p[v] for v in ['allowedToolScopes', 'allowedActionScopes', 'allowedIntakes']) or
63
+ any(p[v] is not None for v in ['updatedAt', 'updatedByUserId', 'decisionReference'])):
64
+ raise PackError('Absent organization policy is not the restrictive default')
65
+ if not p['configured'] and p.get('scopePolicyMode', 'allowlist') != 'allowlist':
66
+ raise PackError('Absent organization scope policy must deny scoped use')
67
+ if p['configured'] and (p['revision'] == '0' or any(p[v] is None for v in
68
+ ['updatedAt', 'updatedByUserId', 'decisionReference'])):
69
+ raise PackError('Configured organization policy lacks server audit metadata')
70
+ body = {field: sorted(p[field]) if isinstance(p[field], list) else p[field] for field in fields}
71
+ digest = 'sha256:' + hashlib.sha256(json.dumps(body, separators=(',', ':'), ensure_ascii=False).encode()).hexdigest()
72
+ if p['policyDigest'] != digest:
73
+ raise PackError('Organization policy digest differs')
74
+ return copy.deepcopy(p)
75
+
76
+
77
+ def authorize_profile(consent, agents, policy):
78
+ consent = validate(consent, consent['pack'], agents)
79
+ if policy['contextKey'] != consent['contextKey']:
80
+ raise PackError('Installation and organization policy contexts differ')
81
+ for agent in agents:
82
+ key = component_key(agent)
83
+ selected = consent.get('overrides', {}).get(key, consent['profile'])
84
+ if selected not in policy['allowedProfiles'] or PROFILES[selected] > PROFILES[policy['maximumProfile']]:
85
+ raise PackError('Organization policy prohibits the requested profile: ' + key)
86
+ if consent['intakeRoutes'][key] not in policy['allowedIntakes']:
87
+ raise PackError('Organization policy does not allow the owning intake: ' + key)
88
+ return {'contextKey': policy['contextKey'], 'revision': policy['revision'],
89
+ 'policyDigest': policy['policyDigest'], 'profileCeilingVerified': True,
90
+ 'toolActionScopeEnforcementVerified': False,
91
+ 'scopePolicyMode': policy.get('scopePolicyMode', 'allowlist'),
92
+ 'toolActionRestrictionRequested': policy.get('scopePolicyMode', 'allowlist') == 'allowlist'}
93
+
94
+
95
+ def authorize_native_scope(proof):
96
+ if proof.get('scopePolicyMode') != 'unrestricted' or proof.get('toolActionRestrictionRequested') is not False:
97
+ raise PackError('Native tool/action allowlist enforcement is unavailable; requested organization policy cannot be activated')
98
+
99
+
100
+ def validate(document, pack, agents):
101
+ keys = {component_key(a) for a in agents}
102
+ if (not isinstance(document, dict) or document.get('format') != 'topolo-pack-installation-consent'
103
+ or document.get('schemaVersion') != 1 or document.get('pack') != pack
104
+ or document.get('authority') != 'local-installation-consent'
105
+ or not isinstance(document.get('contextKey'), str)
106
+ or not document['contextKey'].startswith('org:') or len(document['contextKey']) <= 4
107
+ or not document.get('principalId') or document.get('profile') not in PROFILES
108
+ or document.get('approved') is not True):
109
+ raise PackError('Invalid organization-bound installation consent')
110
+ routes = document.get('intakeRoutes', {})
111
+ intake_keys = {component_key(a) for a in agents if a['set'] == 'intakes'}
112
+ if (not isinstance(routes, dict) or set(routes) != keys or
113
+ any(v not in intake_keys for v in routes.values())):
114
+ raise PackError('Every role requires an owned intake route')
115
+ overrides = document.get('overrides', {})
116
+ if not isinstance(overrides, dict) or set(overrides) - keys:
117
+ raise PackError('Permission override names an unowned role')
118
+ if any(v not in PROFILES or PROFILES[v] > PROFILES[document['profile']] for v in overrides.values()):
119
+ raise PackError('Role override widens installation consent')
120
+ return copy.deepcopy(document)
121
+
122
+
123
+ def load(lib, pack, agents):
124
+ path = lib.root / 'permissions' / (safe_id(pack) + '.json')
125
+ if not path.exists():
126
+ return None
127
+ if path.is_symlink() or not path.is_file():
128
+ raise PackError('Installation consent must be a regular owned file')
129
+ return validate(read_json(path), pack, agents)
130
+
131
+
132
+ def configure(lib, pack, profile, routes, environment, context, client, workspace=None):
133
+ from .topolo import identity
134
+ manifest, revision = lib.load(pack)
135
+ actor = identity(client)
136
+ if context != 'org:' + actor['organizationId']:
137
+ raise PackError('Authenticated organization differs from installation context')
138
+ document = validate({'format': 'topolo-pack-installation-consent', 'schemaVersion': 1,
139
+ 'authority': 'local-installation-consent', 'pack': pack, 'sourceRevision': revision,
140
+ 'contextKey': context, 'environment': environment, 'principalId': actor['userId'],
141
+ 'profile': profile, 'approved': True, 'intakeRoutes': routes, 'overrides': {},
142
+ 'agentWorkspaceId': workspace}, pack, manifest['agents'])
143
+ atomic_json(lib.root / 'permissions' / (safe_id(pack) + '.json'), document)
144
+ return {'state': 'installation-consent-recorded-not-native-applied', 'pack': pack,
145
+ 'profile': profile, 'roles': len(manifest['agents']), 'contextKey': context,
146
+ 'organizationManagedPolicyVerified': False, 'nativeApplied': False}
147
+
148
+
149
+ def apply_settings(agent, harness, native, document):
150
+ native = copy.deepcopy(native)
151
+ if document is None:
152
+ return native
153
+ key = component_key(agent)
154
+ if key not in document['intakeRoutes']:
155
+ raise PackError('Role has no approved intake route')
156
+ selected = document.get('overrides', {}).get(key, document['profile'])
157
+ if selected == 'role-default':
158
+ return native
159
+ if harness == 'codex':
160
+ native['approval_policy'] = 'never' if selected == 'full-access' else 'on-request'
161
+ native['sandbox_mode'] = 'danger-full-access' if selected == 'full-access' else 'read-only'
162
+ native['apps'] = {'_default': {'default_tools_approval_mode': 'approve' if selected == 'full-access' else 'prompt'}}
163
+ elif harness == 'claude':
164
+ native['permissions'] = {'defaultMode': 'bypassPermissions' if selected == 'full-access' else 'default'}
165
+ else:
166
+ raise PackError('Unqualified permission adapter')
167
+ return native