@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.
- package/agent-packs/README.md +81 -3
- package/agent-packs/coverage_changed.py +122 -0
- package/agent-packs/src/topolo_agent_pack/approvals.py +134 -0
- package/agent-packs/src/topolo_agent_pack/cli.py +49 -5
- package/agent-packs/src/topolo_agent_pack/execution.py +27 -9
- package/agent-packs/src/topolo_agent_pack/live.py +338 -5
- package/agent-packs/src/topolo_agent_pack/permissions.py +167 -0
- package/agent-packs/src/topolo_agent_pack/policy.py +11 -6
- package/agent-packs/src/topolo_agent_pack/topolo.py +264 -12
- package/dist/{claude-pack-backend-IDQQX3DG.js → claude-pack-backend-5DWMIDRA.js} +442 -10
- package/dist/index.js +48 -14
- package/package.json +3 -3
|
@@ -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
|