@topolo/cli 0.13.3 → 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 +13 -1
- package/agent-packs/src/topolo_agent_pack/cli.py +7 -1
- package/agent-packs/src/topolo_agent_pack/execution.py +12 -4
- package/agent-packs/src/topolo_agent_pack/live.py +183 -31
- package/agent-packs/src/topolo_agent_pack/topolo.py +252 -12
- package/package.json +2 -2
package/agent-packs/README.md
CHANGED
|
@@ -492,7 +492,19 @@ authorization remains separate from local command sandboxing.
|
|
|
492
492
|
Managed bridge status reports requested policy and which resumes have current
|
|
493
493
|
native readback. It does not claim an inference test or independent app-tool
|
|
494
494
|
approval readback. Existing runtimes without this policy report not-configured
|
|
495
|
-
until
|
|
495
|
+
until explicit `permissions ... --apply` adoption on the existing idle connection.
|
|
496
|
+
Adoption validates the complete selected composition and current source revision,
|
|
497
|
+
qualifies the installed protocol and native model catalog, and verifies original
|
|
498
|
+
thread identities and workspaces before native permission changes. Legacy model
|
|
499
|
+
and effort values are reviewed source-role intent, qualified only by native
|
|
500
|
+
resume responses. Existing execution-policy selections are preserved. The
|
|
501
|
+
operation compares charter and transcript inventories and retains partial or
|
|
502
|
+
uncertain effects for read-only reconciliation; it never recreates threads.
|
|
503
|
+
|
|
504
|
+
The private token-authenticated bridge also supports `owned-roster-read` with
|
|
505
|
+
no parameters. It sends only fixed native thread metadata and model-catalog
|
|
506
|
+
reads for the full selected roster. Its result explicitly does not establish
|
|
507
|
+
effective permissions, models or app-tool approval behavior. Source fixtures do not establish live desktop or
|
|
496
508
|
three-environment shared-memory acceptance.
|
|
497
509
|
|
|
498
510
|
### Organization consent and intake permission routing
|
|
@@ -335,6 +335,7 @@ def parser():
|
|
|
335
335
|
if command=='connect':
|
|
336
336
|
q.add_argument('--roles',required=True);q.add_argument('--agent-workspace',required=True);q.add_argument('--bytes-workspace',required=True)
|
|
337
337
|
q.add_argument('--ownership',choices=['user','organization'],required=True);q.add_argument('--bytes-scope',choices=['private','org'],required=True);q.add_argument('--instance')
|
|
338
|
+
q.add_argument('--reconcile-local-context',action='store_true',help='Explicitly reconcile a recorded partial context attachment; never replays cloud writes')
|
|
338
339
|
if command=='remember':
|
|
339
340
|
q.add_argument('--content',required=True);q.add_argument('--kind',choices=['instruction','knowledge','durable'],required=True)
|
|
340
341
|
q.add_argument('--provenance',required=True);q.add_argument('--visibility',choices=['private','shared'],default='private')
|
|
@@ -359,13 +360,18 @@ def main(argv=None):
|
|
|
359
360
|
harness = argv[at + 1]
|
|
360
361
|
del argv[at:at + 2]
|
|
361
362
|
policy_command = 'policy' in argv or 'permissions' in argv
|
|
362
|
-
|
|
363
|
+
command_args=list(argv)
|
|
364
|
+
if command_args and command_args[0]=='--store':command_args=command_args[2:]
|
|
365
|
+
elif command_args and command_args[0].startswith('--store='):command_args=command_args[1:]
|
|
366
|
+
cloud_command=bool(command_args and command_args[0] in {'connect','remote-context','remember','artifact','artifact-get','remote-recover'})
|
|
367
|
+
if harness == 'claude' and not policy_command and not cloud_command:
|
|
363
368
|
from . import claude
|
|
364
369
|
return claude.main(argv)
|
|
365
370
|
if harness not in {'codex', 'claude'}:
|
|
366
371
|
print('topolo-agent-pack: Unsupported harness: ' + harness, file=sys.stderr)
|
|
367
372
|
return 2
|
|
368
373
|
args=parser().parse_args(argv)
|
|
374
|
+
args.harness=harness
|
|
369
375
|
try:
|
|
370
376
|
lib=Library(args.store)
|
|
371
377
|
if args.command in {'connect','remote-context','remember','artifact','artifact-get','remote-recover'}:
|
|
@@ -17,16 +17,19 @@ from . import policy, permissions
|
|
|
17
17
|
from .storage import PackError, atomic_json, component_key, read_json
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
def
|
|
20
|
+
def prepare_runtime(lib, components, runtime):
|
|
21
|
+
"""Validate existing role intent without installing a policy or changing history."""
|
|
21
22
|
runtime = pathlib.Path(runtime)
|
|
22
23
|
with closing(sqlite3.connect('file:' + str(runtime / 'home/state_5.sqlite') + '?mode=ro', uri=True)) as db:
|
|
23
24
|
cwds = dict(db.execute('SELECT id,cwd FROM threads'))
|
|
24
|
-
rows = []
|
|
25
|
+
rows = []; keys = set(); identities = set()
|
|
25
26
|
for component in components:
|
|
26
27
|
manifest, revision = lib.load(component['sourcePack'], component['revision'])
|
|
27
28
|
matches = [a for a in manifest['agents'] if component_key(a) == component['key'] and a['threadId'] == component['threadId']]
|
|
28
|
-
if len(matches) != 1 or component['threadId'] not in cwds
|
|
29
|
+
if (len(matches) != 1 or component['threadId'] not in cwds or revision != component['revision'] or
|
|
30
|
+
component['key'] in keys or component['threadId'] in identities):
|
|
29
31
|
raise PackError('Execution policy has no unique owned role: ' + component['key'])
|
|
32
|
+
keys.add(component['key']); identities.add(component['threadId'])
|
|
30
33
|
consent = permissions.load(lib, matches[0]['pack'], manifest['agents']) if hasattr(lib, 'root') else None
|
|
31
34
|
rows.append({'key': component['key'], 'threadId': component['threadId'],
|
|
32
35
|
'sourceRevision': revision, 'cwd': cwds[component['threadId']],
|
|
@@ -38,7 +41,12 @@ def compile_runtime(lib, components, runtime):
|
|
|
38
41
|
'roles': rows, 'applied': False, 'modelTurnsStarted': False}
|
|
39
42
|
# Construct/validate fully before installing intent in a staged runtime.
|
|
40
43
|
ExecutionGate(document)
|
|
41
|
-
|
|
44
|
+
return document
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def compile_runtime(lib, components, runtime):
|
|
48
|
+
document = prepare_runtime(lib, components, runtime)
|
|
49
|
+
atomic_json(pathlib.Path(runtime) / 'execution-policy.json', document)
|
|
42
50
|
return document
|
|
43
51
|
|
|
44
52
|
|
|
@@ -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,7 @@ 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 ExecutionGate, load_gate, qualify_protocol
|
|
28
|
+
from .execution import ExecutionGate, load_gate, prepare_runtime, qualify_protocol
|
|
28
29
|
from .approvals import ApprovalRouter
|
|
29
30
|
|
|
30
31
|
def permission_settings(agent, current, consent):
|
|
@@ -45,7 +46,7 @@ def request(lib,operation,params=None):
|
|
|
45
46
|
record=read_json(lib.root/'live-bridge.json',{})
|
|
46
47
|
if not record: raise PackError('This desktop profile has no live bridge. Open it through topolo-agent-pack first.')
|
|
47
48
|
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as connection:
|
|
48
|
-
connection.settimeout(3 if operation=='status' else 60 if operation
|
|
49
|
+
connection.settimeout(3 if operation=='status' else 60 if operation in {'permissions-configure','owned-roster-read'} else 20)
|
|
49
50
|
try: connection.connect(record['socket'])
|
|
50
51
|
except OSError as exc: raise PackError('Managed live bridge is unavailable: '+str(exc)) from exc
|
|
51
52
|
connection.sendall(canonical({'token':record['token'],'operation':operation,**({'params':params} if params is not None else {})})+b'\n')
|
|
@@ -83,6 +84,7 @@ class BackendProxy:
|
|
|
83
84
|
self.approvals=None;self.native_calls={};self.notifications=set()
|
|
84
85
|
self.permission_configuration=False
|
|
85
86
|
self.permission_batch=None
|
|
87
|
+
self._permission_configuration_inputs=None
|
|
86
88
|
|
|
87
89
|
async def start_child(self):
|
|
88
90
|
active=self.lib.active()
|
|
@@ -153,6 +155,10 @@ class BackendProxy:
|
|
|
153
155
|
if self.permission_configuration and value.get('method') in {'thread/resume','turn/start'}:
|
|
154
156
|
sys.stdout.buffer.write(canonical({'id':value.get('id'),'error':{'code':-32000,'message':'Owned permission configuration in progress'}})+b'\n')
|
|
155
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
|
|
156
162
|
if self.quiesced:
|
|
157
163
|
if 'id' in value:
|
|
158
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()
|
|
@@ -181,8 +187,14 @@ class BackendProxy:
|
|
|
181
187
|
identity='topolo-relay-'+uuid.uuid4().hex
|
|
182
188
|
request={'id':identity,'method':method,'params':params}
|
|
183
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')
|
|
184
192
|
await self.qualify_organization(params['threadId'])
|
|
185
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)
|
|
186
198
|
request=self.execution.prepare(request)
|
|
187
199
|
future=asyncio.get_running_loop().create_future();self.native_calls[identity]=future;self.pending.add(identity)
|
|
188
200
|
try:
|
|
@@ -221,68 +233,200 @@ class BackendProxy:
|
|
|
221
233
|
authorize_native_scope(proof)
|
|
222
234
|
role['organizationPolicyProof']=proof
|
|
223
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
|
+
|
|
224
334
|
async def configure_permissions(self,pack):
|
|
225
|
-
"""
|
|
335
|
+
"""Explicit legacy adoption or transition on the SAME existing owned child."""
|
|
226
336
|
from . import permissions
|
|
227
|
-
if self.permission_configuration or self.quiesced or
|
|
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):
|
|
228
338
|
raise PackError('Owned native connection must be initialized, idle and settled')
|
|
229
339
|
marker=pathlib.Path(self.runtime)/'permissions-operation.json'
|
|
230
340
|
if marker.exists():
|
|
231
341
|
raise PackError('Unresolved native permissions operation requires read-only reconciliation')
|
|
232
342
|
if self.approvals and self.approvals.pending:
|
|
233
343
|
raise PackError('Native approvals remain pending; preserve their owning connection')
|
|
234
|
-
manifest,_=self.lib.load(pack);consent=permissions.load(self.lib,pack,manifest['agents'])
|
|
235
|
-
if not consent:
|
|
236
|
-
raise PackError('Explicit organization-bound installation consent required')
|
|
237
|
-
roles=list(self.execution.roles.values());wanted=[]
|
|
238
|
-
by_key={r['key']:r for r in roles}
|
|
239
|
-
for agent in manifest['agents']:
|
|
240
|
-
key='/'.join([agent['pack'],agent['set'],agent['id']]);old=by_key.get(key)
|
|
241
|
-
if not old or old['threadId']!=agent['threadId']:
|
|
242
|
-
raise PackError('Complete pack must match the live owned roster')
|
|
243
|
-
row={**old,'settings':permission_settings(agent,old['settings'],consent),
|
|
244
|
-
'installationConsent':consent,'intakeKey':consent['intakeRoutes'][key],'contextKey':consent['contextKey']}
|
|
245
|
-
wanted.append(row)
|
|
246
|
-
changed={r['threadId']:r for r in wanted}
|
|
247
|
-
candidate=ExecutionGate({'format':'topolo-codex-runtime-execution','schemaVersion':1,
|
|
248
|
-
'roles':[changed.get(r['threadId'],r) for r in roles]})
|
|
249
|
-
candidate.catalog=self.execution.catalog.copy()
|
|
250
|
-
# Complete ownership/idle qualification precedes the first native effect.
|
|
251
344
|
self.permission_configuration=True
|
|
345
|
+
legacy=self.execution is None;preservation=None;inputs=None
|
|
252
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()
|
|
253
383
|
for role in wanted:
|
|
254
384
|
state=await self.call_native('thread/read',{'threadId':role['threadId'],'includeTurns':False})
|
|
385
|
+
self.configuration_current(inputs,roles)
|
|
255
386
|
thread=state.get('thread',{})
|
|
256
387
|
if thread.get('id')!=role['threadId'] or thread.get('cwd')!=role['cwd'] or thread.get('status',{}).get('type') not in {'idle','notLoaded'}:
|
|
257
388
|
raise PackError('Native role identity or idle status differs')
|
|
258
389
|
if role['settings']['model_reasoning_effort'] not in candidate.catalog.get(role['settings']['model'],set()):
|
|
259
390
|
raise PackError('Native account lacks the preserved model/effort')
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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'})
|
|
263
396
|
self.execution=candidate
|
|
264
397
|
await self.qualify_organization(wanted[0]['threadId'])
|
|
398
|
+
self.configuration_current(inputs,roles)
|
|
265
399
|
self.permission_batch={'consent':consent,'proof':candidate.roles[wanted[0]['threadId']]['organizationPolicyProof'],
|
|
266
400
|
'deadline':time.monotonic()+45}
|
|
267
|
-
atomic_json(marker,{'state':'may-have-effects'
|
|
401
|
+
atomic_json(marker,{**operation,'state':'may-have-effects'})
|
|
268
402
|
for role in wanted:
|
|
403
|
+
self.configuration_current(inputs,roles)
|
|
269
404
|
await self.call_native('thread/resume',{'threadId':role['threadId']})
|
|
270
|
-
|
|
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',
|
|
271
407
|
'verifiedThreadIds':sorted(candidate.verified)})
|
|
408
|
+
self.configuration_current(inputs,roles)
|
|
409
|
+
self.configuration_current(inputs,roles)
|
|
272
410
|
document={'format':'topolo-codex-runtime-execution','schemaVersion':1,'roles':list(candidate.roles.values()),
|
|
273
411
|
'applied':False,'modelTurnsStarted':False}
|
|
274
412
|
atomic_json(pathlib.Path(self.runtime)/'execution-policy.json',document)
|
|
275
|
-
|
|
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
|
|
276
416
|
result={'state':'native-permission-resumes-verified','pack':pack,'verifiedAgents':len(wanted),
|
|
277
417
|
'modelTurnsStarted':False,'historyChanged':False,'appToolApprovalReadbackVerified':False,
|
|
278
|
-
'intakeRoutes':len(wanted),'nativeApprovalTransport': 'owned-live-connection'
|
|
418
|
+
'intakeRoutes':len(wanted),'nativeApprovalTransport': 'owned-live-connection',
|
|
419
|
+
'modelEffortProvenance':provenance,'legacyRuntimeAdopted':legacy,
|
|
420
|
+
'contextTranscriptPreservationVerified':preservation is not None}
|
|
279
421
|
atomic_json(marker,{**result,'verifiedThreadIds':sorted(candidate.verified)})
|
|
280
422
|
atomic_json(pathlib.Path(self.runtime)/'permissions-proof.json',result);marker.unlink()
|
|
281
423
|
return result
|
|
282
|
-
finally:
|
|
424
|
+
finally:
|
|
425
|
+
self._permission_configuration_inputs=None
|
|
426
|
+
self.permission_configuration=False;self.permission_batch=None
|
|
283
427
|
|
|
284
428
|
def schedule_notification(self,record):
|
|
285
|
-
if (record['key'] in self.notifications or record['notificationState'] not in
|
|
429
|
+
if (self.permission_configuration or record['key'] in self.notifications or record['notificationState'] not in
|
|
286
430
|
{'not-delivered','awaiting-idle-intake'}):return
|
|
287
431
|
self.notifications.add(record['key'])
|
|
288
432
|
async def deliver():
|
|
@@ -319,18 +463,24 @@ class BackendProxy:
|
|
|
319
463
|
if not secrets.compare_digest(message.get('token',''),self.token): raise PackError('Live bridge authentication failed')
|
|
320
464
|
op=message.get('operation')
|
|
321
465
|
if op=='quiesce':
|
|
466
|
+
if self.permission_configuration:
|
|
467
|
+
raise PackError('Permission configuration in progress; preserve the owning native connection')
|
|
322
468
|
if self.active_turns: raise PackError('Outgoing agents have active turns. Wait for their current turns; Codex can stay open.')
|
|
323
469
|
if self.pending: raise PackError('Backend requests are in flight. Retry the switch; Codex can stay open.')
|
|
324
470
|
self.quiesced=True
|
|
325
471
|
await self.stop_child()
|
|
326
472
|
elif op=='resume':
|
|
327
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 {})
|
|
328
476
|
elif op=='permissions-configure':
|
|
329
477
|
result=await self.configure_permissions((message.get('params') or {})['pack'])
|
|
330
478
|
elif op in {'approval-list','approval-details','approval-respond'}:
|
|
331
479
|
if not self.approvals:
|
|
332
480
|
raise PackError('This connection has no qualified intake routing')
|
|
333
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')
|
|
334
484
|
if op=='approval-list':result={'requests':self.approvals.records()}
|
|
335
485
|
else:
|
|
336
486
|
entry=self.approvals.live(params['key'],params['intakeThreadId'],params['fingerprint'])
|
|
@@ -340,13 +490,15 @@ class BackendProxy:
|
|
|
340
490
|
raise PackError('Recorded human decision required; never auto-approve')
|
|
341
491
|
reply=self.approvals.response(params['key'],params['intakeThreadId'],params['fingerprint'],params['result'])
|
|
342
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')
|
|
343
495
|
await self.qualify_organization(entry['record']['sourceThreadId'])
|
|
344
496
|
self.approvals.live(params['key'],params['intakeThreadId'],params['fingerprint'])
|
|
345
497
|
self.approvals.sending(reply)
|
|
346
498
|
self.child.stdin.write(canonical(reply)+b'\n');await self.child.stdin.drain();self.approvals.settled(reply)
|
|
347
499
|
result={'state':'native-response-forwarded','operationEffectVerified':False}
|
|
348
500
|
elif op!='status': raise PackError('Unknown live bridge operation')
|
|
349
|
-
if op not in {'approval-list','approval-details','approval-respond','permissions-configure'}:result=self.status()
|
|
501
|
+
if op not in {'approval-list','approval-details','approval-respond','permissions-configure','owned-roster-read'}:result=self.status()
|
|
350
502
|
except Exception as exc: result={'error':str(exc)}
|
|
351
503
|
writer.write(canonical(result)+b'\n');await writer.drain();writer.close();await writer.wait_closed()
|
|
352
504
|
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import re,contextlib,fcntl,base64,hashlib,json,os,pathlib,shutil,subprocess,tempfile,uuid
|
|
3
3
|
from .storage import PackError,atomic_json,canonical,read_json,safe_id
|
|
4
4
|
|
|
5
|
+
class ConnectionSourceRefused(PackError):
|
|
6
|
+
pass
|
|
7
|
+
|
|
5
8
|
class Client:
|
|
6
9
|
def __init__(self,environment,context,binary=None):
|
|
7
10
|
if environment not in {'development','staging','production'}:
|
|
@@ -35,6 +38,8 @@ class Client:
|
|
|
35
38
|
plan=self.run(['actions','plan',*common,'--confirm'])
|
|
36
39
|
if not plan.get('executable'):
|
|
37
40
|
raise PackError('Topolo plan is not executable')
|
|
41
|
+
if action=='app_topolo_agent.agent_packs.connect' and getattr(self,'_pack_connect_scope',None) is not None:
|
|
42
|
+
qualify_connection_scope(self._pack_connect_scope)
|
|
38
43
|
if upload:
|
|
39
44
|
result=self.run(['actions','upload',*common,'--file',str(upload),'--content-type',input.get('type','application/octet-stream'),'--confirm'])
|
|
40
45
|
else:result=self.run(['actions','call',*common,'--detail',*(['--confirm'] if write else [])])
|
|
@@ -123,9 +128,194 @@ def attach_runtime(lib,runtime):
|
|
|
123
128
|
with os.fdopen(fd,'w') as f:f.write(text);f.flush();os.fsync(f.fileno())
|
|
124
129
|
os.chmod(temp,0o600);os.replace(temp,guide)
|
|
125
130
|
|
|
131
|
+
def context_file(path,root,optional=False):
|
|
132
|
+
"""Only regular owned files within a qualified local context root."""
|
|
133
|
+
path=pathlib.Path(path);root=pathlib.Path(root).resolve()
|
|
134
|
+
if not path.is_absolute() or not path.is_relative_to(root):
|
|
135
|
+
raise PackError('Context file is outside its owned root')
|
|
136
|
+
parent=path.parent
|
|
137
|
+
while parent.is_relative_to(root):
|
|
138
|
+
if parent.is_symlink() or not parent.is_dir() or parent.stat().st_uid!=os.getuid():
|
|
139
|
+
raise PackError('Context parent is not an owned regular directory')
|
|
140
|
+
if parent==root:break
|
|
141
|
+
parent=parent.parent
|
|
142
|
+
if path.is_symlink():
|
|
143
|
+
raise PackError('Context file must not be a symlink')
|
|
144
|
+
if not path.exists() and optional:return None
|
|
145
|
+
if not path.is_file() or path.stat().st_uid!=os.getuid() or path.stat().st_nlink!=1:
|
|
146
|
+
raise PackError('Context file must be an owned regular file')
|
|
147
|
+
return path.read_bytes()
|
|
148
|
+
|
|
149
|
+
def claude_context_plan(lib,name,manifest,revision):
|
|
150
|
+
from .claude import project_directory
|
|
151
|
+
safe_id(name);root=lib.root/'claude';state_path=root/'state.json'
|
|
152
|
+
state_bytes=context_file(state_path,lib.root);state=json.loads(state_bytes)
|
|
153
|
+
if state.get('schemaVersion')!=1 or state.get('harness')!='claude' or not isinstance(state.get('agents'),list):
|
|
154
|
+
raise PackError('Existing Claude roster required')
|
|
155
|
+
expected={name+'/'+a['set']+'/'+a['id']:a for a in manifest['agents']}
|
|
156
|
+
records=[r for r in state['agents'] if r.get('pack')==name]
|
|
157
|
+
if len(records)!=len(expected) or {r.get('key') for r in records}!=set(expected):
|
|
158
|
+
raise PackError('Complete existing Claude pack must match the captured roster')
|
|
159
|
+
ids=set();workspaces=set();rows=[];preserved={str(state_path):hashlib.sha256(state_bytes).hexdigest()}
|
|
160
|
+
config=pathlib.Path(os.environ.get('CLAUDE_CONFIG_DIR',str(pathlib.Path.home()/'.claude'))).expanduser().resolve()
|
|
161
|
+
for record in records:
|
|
162
|
+
key=record['key'];agent=expected[key];safe_id(agent['set']);safe_id(agent['id'])
|
|
163
|
+
workspace=root/'workspaces'/name/agent['set']/agent['id']
|
|
164
|
+
session=record.get('cliSessionId','')
|
|
165
|
+
try:session_ok=str(uuid.UUID(session))==session
|
|
166
|
+
except (ValueError,AttributeError,TypeError):session_ok=False
|
|
167
|
+
fields={'key':key,'pack':name,'set':agent['set'],'agent':agent['id'],'sourceRevision':revision,
|
|
168
|
+
'sourceCodexThreadId':agent['threadId'],'workspace':str(workspace),'cliSessionId':session,'desktopSessionId':'local_'+session,
|
|
169
|
+
'transcript':str(project_directory(config,workspace)/(session+'.jsonl'))}
|
|
170
|
+
if not session_ok or any(record.get(k)!=v for k,v in fields.items()) or session in ids or str(workspace) in workspaces:
|
|
171
|
+
raise PackError('Claude native identity, revision or workspace differs')
|
|
172
|
+
ids.add(session);workspaces.add(str(workspace))
|
|
173
|
+
identity_path=root/'identities'/(hashlib.sha256(key.encode()).hexdigest()+'.json')
|
|
174
|
+
identity_bytes=context_file(identity_path,lib.root);identity_record=json.loads(identity_bytes)
|
|
175
|
+
if any(identity_record.get(k)!=v for k,v in fields.items()):
|
|
176
|
+
raise PackError('Claude durable identity differs from its roster')
|
|
177
|
+
preserved[str(identity_path)]=hashlib.sha256(identity_bytes).hexdigest()
|
|
178
|
+
guide=workspace/'CLAUDE.md';body=context_file(guide,lib.root)
|
|
179
|
+
descriptor=workspace/'topolo-connection.json';old_descriptor=context_file(descriptor,lib.root,optional=True)
|
|
180
|
+
for file in workspace.rglob('*.md'):
|
|
181
|
+
if file!=guide:preserved[str(file)]=hashlib.sha256(context_file(file,lib.root)).hexdigest()
|
|
182
|
+
transcript=pathlib.Path(fields['transcript'])
|
|
183
|
+
preserved[str(transcript)]=hashlib.sha256(context_file(transcript,config/'projects')).hexdigest()
|
|
184
|
+
rows.append({'key':key,'logicalId':agent['set']+'/'+agent['id'],'nativeThreadId':record['desktopSessionId'],
|
|
185
|
+
'guide':str(guide),'descriptor':str(descriptor),'beforeGuide':hashlib.sha256(body).hexdigest(),
|
|
186
|
+
'beforeDescriptor':hashlib.sha256(old_descriptor).hexdigest() if old_descriptor is not None else None,
|
|
187
|
+
'prefixHash':hashlib.sha256(body.split(b'\n<!-- topolo-continuity -->')[0]).hexdigest()})
|
|
188
|
+
return {'name':name,'revision':revision,'rows':rows,'preserved':preserved}
|
|
189
|
+
|
|
190
|
+
def atomic_context(path,data):
|
|
191
|
+
path=pathlib.Path(path);fd,temporary=tempfile.mkstemp(dir=path.parent,prefix='.continuity-')
|
|
192
|
+
try:
|
|
193
|
+
with os.fdopen(fd,'wb') as stream:stream.write(data);stream.flush();os.fsync(stream.fileno())
|
|
194
|
+
os.chmod(temporary,0o600);os.replace(temporary,path)
|
|
195
|
+
directory=os.open(path.parent,os.O_RDONLY)
|
|
196
|
+
try:os.fsync(directory)
|
|
197
|
+
finally:os.close(directory)
|
|
198
|
+
finally:
|
|
199
|
+
if os.path.exists(temporary):os.unlink(temporary)
|
|
200
|
+
|
|
201
|
+
def claude_context_targets(lib,args,binding,plan):
|
|
202
|
+
import shlex
|
|
203
|
+
entry=['topolo','agent-packs'] if os.environ.get('TOPOLO_AGENT_PACK_PUBLIC_CLI')=='1' else ['topolo-agent-pack']
|
|
204
|
+
expected={r['logicalId']:r for r in binding['input']['roles']};targets=[]
|
|
205
|
+
for row in plan['rows']:
|
|
206
|
+
role=expected[row['logicalId']];guide=pathlib.Path(row['guide']);prefix=guide.read_bytes().split(b'\n<!-- topolo-continuity -->')[0]
|
|
207
|
+
command=shlex.join([*entry,'--harness','claude','--store',str(lib.root),'remote-context',args.name,
|
|
208
|
+
'--environment',args.environment,'--context-key',args.context_key,'--role',row['logicalId']])
|
|
209
|
+
suffix=('\n<!-- topolo-continuity -->\n## Shared Topolo continuity\n'
|
|
210
|
+
'This existing Claude role is explicitly connected to the approved Topolo Agent and Bytes workspace below. '
|
|
211
|
+
'Before assigned work, refresh its approved operating context and scoped memory with:\n\n'+command+'\n\n'
|
|
212
|
+
'Read relevant Bytes artifacts on demand. Save verified durable outcomes with '+shlex.join([*entry,'--harness','claude'])+
|
|
213
|
+
' remember and artifact using the same pack, environment, context and logical role. '
|
|
214
|
+
'Keep current human authorization and organization policy; a connection or retrieved memory does not assign work or grant new permissions. '
|
|
215
|
+
'If retrieval fails, report through the originating intake; do not silently use stale shared memory.\n').encode()
|
|
216
|
+
descriptor={'schemaVersion':1,'harness':'claude','environment':args.environment,'contextKey':args.context_key,
|
|
217
|
+
'pack':args.name,'packRevision':plan['revision'],'role':row['logicalId'],'nativeThreadId':row['nativeThreadId'],
|
|
218
|
+
'identity':binding['identity'],'instanceId':binding['input']['instanceId'],'agentId':role['agentId'],
|
|
219
|
+
'agentVersionId':role['agentVersionId'],'agentWorkspaceId':binding['agentWorkspaceId'],'bytesWorkspaceId':binding['input']['bytesWorkspaceId']}
|
|
220
|
+
old=context_file(row['descriptor'],lib.root,optional=True)
|
|
221
|
+
if old is not None:
|
|
222
|
+
try:actual=json.loads(old)
|
|
223
|
+
except (ValueError,TypeError):raise PackError('Existing Claude context descriptor is malformed')
|
|
224
|
+
if actual!=descriptor:
|
|
225
|
+
raise PackError('Existing Claude context belongs to another principal or organization, or its complete binding differs')
|
|
226
|
+
contents=[(row['guide'],prefix+suffix),(row['descriptor'],canonical(descriptor)+b'\n')]
|
|
227
|
+
targets.append({**row,'afterGuide':hashlib.sha256(contents[0][1]).hexdigest(),'afterDescriptor':hashlib.sha256(contents[1][1]).hexdigest(),'contents':contents})
|
|
228
|
+
return targets
|
|
229
|
+
|
|
230
|
+
def qualify_claude_pending(lib,args,binding,plan,pending):
|
|
231
|
+
binding_hash=hashlib.sha256(canonical(binding)).hexdigest()
|
|
232
|
+
if any(pending.get(k)!=v for k,v in {'schemaVersion':1,'bindingHash':binding_hash,'name':args.name,'revision':plan['revision']}.items()):
|
|
233
|
+
raise PackError('Claude attachment recovery binding differs; no new effects')
|
|
234
|
+
if pending.get('preserved')!=plan['preserved']:
|
|
235
|
+
raise PackError('Claude preserved context changed; no new effects')
|
|
236
|
+
targets=claude_context_targets(lib,args,binding,plan);old_targets=pending.get('targets')
|
|
237
|
+
if not isinstance(old_targets,list) or len(old_targets)!=len(targets) or any(any(old.get(k)!=new[k] for k in ['key','logicalId','nativeThreadId','guide','descriptor','prefixHash','afterGuide','afterDescriptor']) for old,new in zip(old_targets,targets)):
|
|
238
|
+
raise PackError('Claude attachment target identity differs; no new effects')
|
|
239
|
+
verified=pending.get('verifiedKeys');keys=[row['key'] for row in targets]
|
|
240
|
+
if not isinstance(verified,list) or verified!=keys[:len(verified)]:
|
|
241
|
+
raise PackError('Claude partial verification progress differs')
|
|
242
|
+
for old,current in zip(old_targets,targets):
|
|
243
|
+
if current['beforeGuide'] not in [old.get('beforeGuide'),old['afterGuide']] or current['beforeDescriptor'] not in [old.get('beforeDescriptor'),old['afterDescriptor']] or (current['key'] in verified and (current['beforeGuide']!=old['afterGuide'] or current['beforeDescriptor']!=old['afterDescriptor'])):
|
|
244
|
+
raise PackError('Claude partial target progress differs; no new effects')
|
|
245
|
+
return targets
|
|
246
|
+
|
|
247
|
+
def attach_claude(lib,args,binding,plan):
|
|
248
|
+
import shlex
|
|
249
|
+
path=binding_path(lib,args.name,args.environment,args.context_key)
|
|
250
|
+
journal=path.with_suffix('.claude-context.pending.json');receipt=path.with_suffix('.claude-context.verified.json')
|
|
251
|
+
binding_hash=hashlib.sha256(canonical(binding)).hexdigest()
|
|
252
|
+
pending=read_json(journal,{})
|
|
253
|
+
if pending and not getattr(args,'reconcile_local_context',False):
|
|
254
|
+
raise PackError('Partial Claude context attachment requires explicit --reconcile-local-context; no replay')
|
|
255
|
+
current_manifest,current_revision=lib.load(args.name)
|
|
256
|
+
current=claude_context_plan(lib,args.name,current_manifest,current_revision)
|
|
257
|
+
if current!=plan:
|
|
258
|
+
raise PackError('Claude context changed during connection qualification')
|
|
259
|
+
targets=claude_context_targets(lib,args,binding,plan)
|
|
260
|
+
stable={'schemaVersion':1,'bindingHash':binding_hash,'name':args.name,'revision':plan['revision'],'preserved':plan['preserved'],
|
|
261
|
+
'targets':[{k:v for k,v in row.items() if k!='contents'} for row in targets]}
|
|
262
|
+
result={'state':'local-context-verified','harness':'claude','verifiedAgents':len(targets),'historyChanged':False,
|
|
263
|
+
'nativeSettingsVerified':False,'nativeIdleVerified':False,'modelTurnsStarted':False,'bindingHash':binding_hash}
|
|
264
|
+
if not pending and all(row['beforeGuide']==row['afterGuide'] and row['beforeDescriptor']==row['afterDescriptor'] for row in targets):
|
|
265
|
+
if claude_context_plan(lib,args.name,*lib.load(args.name))!=plan:
|
|
266
|
+
raise PackError('Claude context changed before unchanged attachment receipt')
|
|
267
|
+
atomic_json(receipt,{**stable,**result,'verifiedKeys':[row['key'] for row in targets]})
|
|
268
|
+
return result
|
|
269
|
+
if pending:
|
|
270
|
+
if any(pending.get(k)!=stable[k] for k in ['schemaVersion','bindingHash','name','revision','preserved']):
|
|
271
|
+
raise PackError('Claude attachment recovery binding or preserved context differs')
|
|
272
|
+
old_targets=pending['targets']
|
|
273
|
+
if len(old_targets)!=len(targets) or any(any(old.get(k)!=new[k] for k in ['key','logicalId','nativeThreadId','guide','descriptor','prefixHash','afterGuide','afterDescriptor']) for old,new in zip(old_targets,targets)):
|
|
274
|
+
raise PackError('Claude attachment target identity differs')
|
|
275
|
+
stable['targets']=old_targets
|
|
276
|
+
else:atomic_json(journal,{**stable,'state':'prepared','verifiedKeys':[]})
|
|
277
|
+
verified=[]
|
|
278
|
+
for row,baseline in zip(targets,stable['targets']):
|
|
279
|
+
manifest,revision=lib.load(args.name);fresh=claude_context_plan(lib,args.name,manifest,revision)
|
|
280
|
+
if revision!=stable['revision'] or fresh['preserved']!=stable['preserved'] or [r['key'] for r in fresh['rows']]!=[r['key'] for r in plan['rows']]:
|
|
281
|
+
raise PackError('Claude source, identities, context or transcript changed during attachment')
|
|
282
|
+
for file,data in row['contents']:
|
|
283
|
+
old=context_file(file,lib.root,optional=file==row['descriptor']);digest=hashlib.sha256(old).hexdigest() if old is not None else None
|
|
284
|
+
before=baseline['beforeGuide'] if file==row['guide'] else baseline['beforeDescriptor']
|
|
285
|
+
after=baseline['afterGuide'] if file==row['guide'] else baseline['afterDescriptor']
|
|
286
|
+
if digest not in [before,after]:
|
|
287
|
+
raise PackError('Claude target changed; partial attachment retained for explicit reconciliation')
|
|
288
|
+
if digest!=after:atomic_context(file,data)
|
|
289
|
+
if hashlib.sha256(context_file(file,lib.root)).hexdigest()!=after:
|
|
290
|
+
raise PackError('Claude attachment read-back differs; retain partial journal')
|
|
291
|
+
verified.append(row['key']);atomic_json(journal,{**stable,'state':'partial','verifiedKeys':verified})
|
|
292
|
+
final=claude_context_plan(lib,args.name,*lib.load(args.name))
|
|
293
|
+
if final['preserved']!=stable['preserved']:
|
|
294
|
+
raise PackError('Claude preserved context or history changed before final receipt')
|
|
295
|
+
for row in stable['targets']:
|
|
296
|
+
if any(hashlib.sha256(context_file(row[file],lib.root)).hexdigest()!=row[after] for file,after in [('guide','afterGuide'),('descriptor','afterDescriptor')]):
|
|
297
|
+
raise PackError('Claude final attachment differs; retain partial journal')
|
|
298
|
+
atomic_json(receipt,{**stable,**result,'verifiedKeys':verified});journal.unlink()
|
|
299
|
+
return result
|
|
300
|
+
|
|
301
|
+
def qualify_connection_scope(scope):
|
|
302
|
+
lib,args,options,manifest_bytes,revision,roles_bytes,roles,claude_plan=scope
|
|
303
|
+
try:
|
|
304
|
+
current_manifest,current_revision=lib.load(args.name)
|
|
305
|
+
if vars(args)!=options or current_revision!=revision or canonical(current_manifest)!=manifest_bytes or pathlib.Path(args.roles).read_bytes()!=roles_bytes:
|
|
306
|
+
raise PackError('Pack source or request inputs changed')
|
|
307
|
+
# Manifest equality and Library.object's content hash bind instructions.
|
|
308
|
+
# Re-read each actual immutable object; missing/corrupt content refuses.
|
|
309
|
+
for agent in current_manifest['agents']:role_guide(lib,current_manifest,agent)
|
|
310
|
+
if claude_plan is not None and claude_context_plan(lib,args.name,current_manifest,current_revision)!=claude_plan:
|
|
311
|
+
raise PackError('Claude source, identities, context or history changed')
|
|
312
|
+
except (PackError,OSError,ValueError,TypeError) as error:
|
|
313
|
+
raise ConnectionSourceRefused('Pack source or native context changed before cloud dispatch; no mutating call') from error
|
|
314
|
+
|
|
126
315
|
def connect(lib,args,client):
|
|
127
316
|
manifest,revision=lib.load(args.name)
|
|
128
|
-
|
|
317
|
+
claude_plan=claude_context_plan(lib,args.name,manifest,revision) if getattr(args,'harness','codex')=='claude' else None
|
|
318
|
+
roles_bytes=pathlib.Path(args.roles).read_bytes();selected=json.loads(roles_bytes)
|
|
129
319
|
expected={a['set']+'/'+a['id']:a for a in manifest['agents']}
|
|
130
320
|
if not isinstance(selected,dict) or set(selected)!=set(expected):
|
|
131
321
|
raise PackError('Roles file must map every logical set/agent exactly once')
|
|
@@ -136,6 +326,16 @@ def connect(lib,args,client):
|
|
|
136
326
|
raise PackError('Each role requires only agentId and agentVersionId')
|
|
137
327
|
roles.append({'logicalId':key,**remote,'instructions':role_guide(lib,manifest,a)})
|
|
138
328
|
path=binding_path(lib,args.name,args.environment,args.context_key)
|
|
329
|
+
local_pending={}
|
|
330
|
+
if claude_plan is not None:
|
|
331
|
+
local_journals=list((lib.root/'connections'/args.name).rglob('*.claude-context.pending.json'))
|
|
332
|
+
expected_journal=path.with_suffix('.claude-context.pending.json')
|
|
333
|
+
if local_journals:
|
|
334
|
+
if len(local_journals)!=1 or local_journals[0]!=expected_journal:
|
|
335
|
+
raise PackError('Unresolved Claude attachment belongs to another environment or context; no new effects')
|
|
336
|
+
local_pending=json.loads(context_file(expected_journal,lib.root))
|
|
337
|
+
if not getattr(args,'reconcile_local_context',False):
|
|
338
|
+
raise PackError('Partial Claude context attachment requires explicit --reconcile-local-context; no replay')
|
|
139
339
|
old=read_json(path,{})
|
|
140
340
|
journal=path.with_suffix('.pending.json')
|
|
141
341
|
pending=read_json(journal,{})
|
|
@@ -143,24 +343,60 @@ def connect(lib,args,client):
|
|
|
143
343
|
payload={'instanceId':instance,'packId':manifest.get('startingContext',{}).get('templateId',manifest['packId']),
|
|
144
344
|
'packVersion':manifest.get('startingContext',{}).get('templateVersion','1.0.0'),
|
|
145
345
|
'ownershipScope':args.ownership,'bytesWorkspaceId':args.bytes_workspace,'bytesScope':args.bytes_scope,'roles':roles}
|
|
346
|
+
scope=(lib,args,dict(vars(args)),canonical(manifest),revision,roles_bytes,roles,claude_plan)
|
|
146
347
|
owner=identity(client)
|
|
348
|
+
qualify_connection_scope(scope)
|
|
147
349
|
journal=path.with_suffix('.pending.json')
|
|
148
350
|
if journal.exists():
|
|
149
351
|
pending=read_json(journal)
|
|
150
352
|
if pending['input']!=payload or pending['identity']!=owner or pending.get('agentWorkspaceId')!=args.agent_workspace:
|
|
151
353
|
raise PackError('Unresolved connection receipt; reconcile before changing the request')
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
354
|
+
if claude_plan is not None and pending:
|
|
355
|
+
raise PackError('Unresolved cloud connection requires remote-recover before Claude attachment; no cloud replay')
|
|
356
|
+
if old and old.get('identity')!=owner:
|
|
357
|
+
raise PackError('Existing connection principal differs')
|
|
358
|
+
reuse=bool(old and old.get('input')==payload and old.get('agentWorkspaceId')==args.agent_workspace and old.get('packRevision')==revision)
|
|
359
|
+
proposed={'schemaVersion':1,'environment':args.environment,'contextKey':args.context_key,'identity':owner,
|
|
360
|
+
'agentWorkspaceId':args.agent_workspace,'input':payload,'packRevision':revision}
|
|
361
|
+
if claude_plan is not None:
|
|
362
|
+
claude_context_targets(lib,args,proposed,claude_plan)
|
|
363
|
+
if local_pending:
|
|
364
|
+
if not reuse or any(old.get(k)!=v for k,v in proposed.items()):
|
|
365
|
+
raise PackError('Claude attachment recovery binding differs; no new connection or binding replacement')
|
|
366
|
+
qualify_claude_pending(lib,args,old,claude_plan,local_pending)
|
|
367
|
+
if reuse:
|
|
368
|
+
remote=client.action('app_topolo_agent.agent_packs.get',{'instanceId':instance},args.agent_workspace)['connection'];readback=remote
|
|
369
|
+
else:
|
|
370
|
+
prepared={'input':payload,'identity':owner,'revision':revision,'agentWorkspaceId':args.agent_workspace}
|
|
371
|
+
if getattr(client,'_pack_connect_scope',None) is not None:
|
|
372
|
+
raise PackError('A trusted connection dispatch scope is already active')
|
|
373
|
+
atomic_json(journal,prepared)
|
|
374
|
+
client._pack_connect_scope=scope
|
|
375
|
+
try:
|
|
376
|
+
qualify_connection_scope(scope)
|
|
377
|
+
result=client.action('app_topolo_agent.agent_packs.connect',payload,args.agent_workspace,write=True)
|
|
378
|
+
except ConnectionSourceRefused:
|
|
379
|
+
if not pending and read_json(journal)==prepared:journal.unlink()
|
|
380
|
+
raise
|
|
381
|
+
finally:client._pack_connect_scope=None
|
|
382
|
+
remote=result['connection'];readback=client.action('app_topolo_agent.agent_packs.get',{'instanceId':instance},args.agent_workspace)['connection']
|
|
155
383
|
if remote!=readback or any(readback.get(k)!=v for k,v in payload.items()) or readback.get('organizationId')!=owner['organizationId'] or readback.get('workspaceId')!=args.agent_workspace:
|
|
156
384
|
raise PackError('Pack connection read-back mismatch; preserve pending receipt')
|
|
157
385
|
binding={'schemaVersion':1,'environment':args.environment,'contextKey':args.context_key,'identity':owner,
|
|
158
386
|
'agentWorkspaceId':args.agent_workspace,'input':payload,'connection':readback,'packRevision':revision}
|
|
159
|
-
|
|
160
|
-
|
|
387
|
+
if local_pending:
|
|
388
|
+
if binding!=old:
|
|
389
|
+
raise PackError('Claude attachment verified remote binding changed; no local replay')
|
|
390
|
+
else:
|
|
391
|
+
atomic_json(path,binding)
|
|
392
|
+
if journal.exists():journal.unlink()
|
|
393
|
+
atomic_json(path.parent.parent/'active.json',{'environment':args.environment,'contextKey':args.context_key})
|
|
161
394
|
active=lib.active()
|
|
162
|
-
if
|
|
163
|
-
|
|
395
|
+
if claude_plan is not None:attachment=attach_claude(lib,args,binding,claude_plan)
|
|
396
|
+
else:
|
|
397
|
+
if active.get('runtime'):attach_runtime(lib,active['runtime'])
|
|
398
|
+
attachment=None
|
|
399
|
+
return {'state':'connected','binding':str(path),'instanceId':instance,'roles':len(roles),'environment':args.environment,'localAttachment':attachment}
|
|
164
400
|
|
|
165
401
|
def remote_context(lib,args,client):
|
|
166
402
|
_,binding=open_binding(lib,args,client)
|
|
@@ -266,10 +502,13 @@ def recover_connection(lib,args,client):
|
|
|
266
502
|
"""Read/reconcile receipts. Never replay a memory create with an unknown outcome."""
|
|
267
503
|
path=binding_path(lib,args.name,args.environment,args.context_key)
|
|
268
504
|
pending_path=path.with_suffix('.pending.json')
|
|
269
|
-
if
|
|
505
|
+
if pending_path.exists():
|
|
270
506
|
pending=read_json(pending_path)
|
|
271
507
|
if identity(client)!=pending['identity']:
|
|
272
508
|
raise PackError('Pending connection principal/context mismatch')
|
|
509
|
+
older=read_json(path,{})
|
|
510
|
+
if older and (older.get('schemaVersion')!=1 or older.get('environment')!=args.environment or older.get('contextKey')!=args.context_key or older.get('identity')!=pending['identity'] or older.get('agentWorkspaceId')!=pending['agentWorkspaceId'] or older.get('input',{}).get('instanceId')!=pending['input']['instanceId']):
|
|
511
|
+
raise PackError('Existing connection custody differs from the pending original request')
|
|
273
512
|
remote=client.action('app_topolo_agent.agent_packs.get',{'instanceId':pending['input']['instanceId']},pending['agentWorkspaceId'])['connection']
|
|
274
513
|
if any(remote.get(k)!=v for k,v in pending['input'].items()) or remote.get('organizationId')!=pending['identity']['organizationId'] or remote.get('workspaceId')!=pending['agentWorkspaceId']:
|
|
275
514
|
raise PackError('Pending connection read-back mismatch; retain receipt')
|
|
@@ -278,7 +517,7 @@ def recover_connection(lib,args,client):
|
|
|
278
517
|
pending_path.unlink()
|
|
279
518
|
atomic_json(path.parent.parent/'active.json',{'environment':args.environment,'contextKey':args.context_key})
|
|
280
519
|
active=lib.active()
|
|
281
|
-
if active.get('runtime'):attach_runtime(lib,active['runtime'])
|
|
520
|
+
if active.get('runtime') and getattr(args,'harness','codex')!='claude':attach_runtime(lib,active['runtime'])
|
|
282
521
|
path,binding=open_binding(lib,args,client);reports=[]
|
|
283
522
|
memory=path.with_suffix('.memory-pending.json')
|
|
284
523
|
journals=([memory] if memory.exists() else [])+list(path.with_suffix('').glob('artifact-*.json'))
|
|
@@ -305,8 +544,9 @@ def recover_connection(lib,args,client):
|
|
|
305
544
|
state='artifact-and-memory-verified';atomic_json(journal,{'state':state,'artifact':receipt['artifact'],'memoryId':matches[0]['id'],'recovered':True})
|
|
306
545
|
else:state='unresolved-memory-outcome'
|
|
307
546
|
reports.append({'receipt':str(journal),'state':state})
|
|
308
|
-
return {'state':'reconciled' if not path.with_suffix('.pending.json').exists() and all(r['state'] in ['memory-verified','artifact-and-memory-verified'] for r in reports) else 'recovery-required',
|
|
309
|
-
'receipts':reports,'connectionPending':path.with_suffix('.pending.json').exists()
|
|
547
|
+
return {'state':'reconciled' if not path.with_suffix('.pending.json').exists() and not path.with_suffix('.claude-context.pending.json').exists() and all(r['state'] in ['memory-verified','artifact-and-memory-verified'] for r in reports) else 'recovery-required',
|
|
548
|
+
'receipts':reports,'connectionPending':path.with_suffix('.pending.json').exists(),
|
|
549
|
+
'localContextRecoveryRequired':path.with_suffix('.claude-context.pending.json').exists(),'localContextReplayed':False}
|
|
310
550
|
|
|
311
551
|
@contextlib.contextmanager
|
|
312
552
|
def connection_lock(lib,args):
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@topolo/cli",
|
|
3
3
|
"packageManager": "pnpm@10.17.1",
|
|
4
|
-
"version": "0.13.
|
|
5
|
-
"topoloPublishedVersion": "0.13.
|
|
4
|
+
"version": "0.13.4",
|
|
5
|
+
"topoloPublishedVersion": "0.13.4",
|
|
6
6
|
"description": "Public command-line interface for Topolo customer organizations, developers, and agents.",
|
|
7
7
|
"license": "UNLICENSED",
|
|
8
8
|
"author": "Topolo",
|