@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.
@@ -46,7 +46,7 @@ def role_profile(agent):
46
46
  return 'operator'
47
47
 
48
48
 
49
- def settings(agent, harness):
49
+ def settings(agent, harness, consent=None):
50
50
  if harness not in MODELS:
51
51
  raise PackError('Execution settings adapter is not qualified: ' + harness)
52
52
  profile = role_profile(agent)
@@ -62,22 +62,27 @@ def settings(agent, harness):
62
62
  else:
63
63
  native = {'model': model, 'effortLevel': spec['effort'],
64
64
  'permissions': {'defaultMode': 'acceptEdits' if profile == 'build' else 'default'}}
65
+ from .permissions import apply_settings
66
+ native = apply_settings(agent, harness, native, consent)
67
+ full = native.get('sandbox_mode') == 'danger-full-access' or native.get('permissions', {}).get('defaultMode') == 'bypassPermissions'
65
68
  return {'profile': profile, 'nativeSettings': native,
66
- 'localAccessIntent': spec['localAccess'], 'applied': False,
69
+ 'localAccessIntent': 'full-access' if full else native.get('sandbox_mode', spec['localAccess']), 'applied': False,
67
70
  # Claude Manual is an approval mode, not a read-only filesystem sandbox.
68
- 'requiresAdditionalLocalConfinement': harness == 'claude' and spec['localAccess'] == 'read-only'}
71
+ 'requiresAdditionalLocalConfinement': harness == 'claude' and not full and spec['localAccess'] == 'read-only'}
69
72
 
70
73
 
71
- def render(manifest, revision, agents, harnesses=('codex', 'claude')):
74
+ def render(manifest, revision, agents, harnesses=('codex', 'claude'), consent=None):
72
75
  rows = []
73
76
  for agent in agents:
74
77
  rows.append({'key': component_key(agent), 'title': agent['title'],
75
78
  'sidebar': {'group': GROUPS.get(agent['set'], agent['set']),
76
79
  'pinned': agent.get('pinned', False)},
77
- 'harnesses': {h: settings(agent, h) for h in harnesses}})
80
+ 'intake': consent['intakeRoutes'][component_key(agent)] if consent else None,
81
+ 'harnesses': {h: settings(agent, h, consent) for h in harnesses}})
78
82
  return {'format': 'topolo-agent-execution-policy', 'schemaVersion': 1,
79
83
  'pack': manifest['packId'], 'sourceRevision': revision, 'agents': rows,
80
- 'applied': False,
84
+ 'applied': False, 'installationConsent': copy.deepcopy(consent),
85
+ 'organizationManagedPolicyVerified': False,
81
86
  'contextPolicy': {'load': ['role charter', 'current task', 'relevant retrieved memory'],
82
87
  'sharedMemory': 'retrieve by task and user or organization scope',
83
88
  'history': 'preserve, do not copy between roles',
@@ -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 [])])
@@ -49,6 +54,18 @@ def identity(client):
49
54
  raise PackError('Verified user and organization context are required by the Agent contract')
50
55
  return {'userId':user['id'],'organizationId':org['id']}
51
56
 
57
+ def organization_execution_policy(client, consent, agents, workspace):
58
+ """Fresh authenticated organization ceiling, distinct from native acknowledgement."""
59
+ from .permissions import organization_policy, authorize_profile
60
+ actor=identity(client)
61
+ if actor['userId']!=consent['principalId'] or client.context!=consent['contextKey']:
62
+ raise PackError('Current installation principal/context differs')
63
+ if not workspace:
64
+ raise PackError('Explicit verified Agent workspace required for policy retrieval')
65
+ envelope=client.action('app_topolo_agent.organization_execution_policy.get',{},workspace)
66
+ policy=organization_policy(envelope,client.context,actor['organizationId'])
67
+ return policy,authorize_profile(consent,agents,policy)
68
+
52
69
  def binding_path(lib,name,environment,context):
53
70
  safe_id(name)
54
71
  return lib.root/'connections'/name/environment/(hashlib.sha256(context.encode()).hexdigest()+'.json')
@@ -111,9 +128,194 @@ def attach_runtime(lib,runtime):
111
128
  with os.fdopen(fd,'w') as f:f.write(text);f.flush();os.fsync(f.fileno())
112
129
  os.chmod(temp,0o600);os.replace(temp,guide)
113
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
+
114
315
  def connect(lib,args,client):
115
316
  manifest,revision=lib.load(args.name)
116
- selected=read_json(args.roles)
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)
117
319
  expected={a['set']+'/'+a['id']:a for a in manifest['agents']}
118
320
  if not isinstance(selected,dict) or set(selected)!=set(expected):
119
321
  raise PackError('Roles file must map every logical set/agent exactly once')
@@ -124,6 +326,16 @@ def connect(lib,args,client):
124
326
  raise PackError('Each role requires only agentId and agentVersionId')
125
327
  roles.append({'logicalId':key,**remote,'instructions':role_guide(lib,manifest,a)})
126
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')
127
339
  old=read_json(path,{})
128
340
  journal=path.with_suffix('.pending.json')
129
341
  pending=read_json(journal,{})
@@ -131,24 +343,60 @@ def connect(lib,args,client):
131
343
  payload={'instanceId':instance,'packId':manifest.get('startingContext',{}).get('templateId',manifest['packId']),
132
344
  'packVersion':manifest.get('startingContext',{}).get('templateVersion','1.0.0'),
133
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)
134
347
  owner=identity(client)
348
+ qualify_connection_scope(scope)
135
349
  journal=path.with_suffix('.pending.json')
136
350
  if journal.exists():
137
351
  pending=read_json(journal)
138
352
  if pending['input']!=payload or pending['identity']!=owner or pending.get('agentWorkspaceId')!=args.agent_workspace:
139
353
  raise PackError('Unresolved connection receipt; reconcile before changing the request')
140
- atomic_json(journal,{'input':payload,'identity':owner,'revision':revision,'agentWorkspaceId':args.agent_workspace})
141
- result=client.action('app_topolo_agent.agent_packs.connect',payload,args.agent_workspace,write=True)
142
- remote=result['connection'];readback=client.action('app_topolo_agent.agent_packs.get',{'instanceId':instance},args.agent_workspace)['connection']
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']
143
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:
144
384
  raise PackError('Pack connection read-back mismatch; preserve pending receipt')
145
385
  binding={'schemaVersion':1,'environment':args.environment,'contextKey':args.context_key,'identity':owner,
146
386
  'agentWorkspaceId':args.agent_workspace,'input':payload,'connection':readback,'packRevision':revision}
147
- atomic_json(path,binding);journal.unlink()
148
- atomic_json(path.parent.parent/'active.json',{'environment':args.environment,'contextKey':args.context_key})
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})
149
394
  active=lib.active()
150
- if active.get('runtime'):attach_runtime(lib,active['runtime'])
151
- return {'state':'connected','binding':str(path),'instanceId':instance,'roles':len(roles),'environment':args.environment}
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}
152
400
 
153
401
  def remote_context(lib,args,client):
154
402
  _,binding=open_binding(lib,args,client)
@@ -254,10 +502,13 @@ def recover_connection(lib,args,client):
254
502
  """Read/reconcile receipts. Never replay a memory create with an unknown outcome."""
255
503
  path=binding_path(lib,args.name,args.environment,args.context_key)
256
504
  pending_path=path.with_suffix('.pending.json')
257
- if not path.exists() and pending_path.exists():
505
+ if pending_path.exists():
258
506
  pending=read_json(pending_path)
259
507
  if identity(client)!=pending['identity']:
260
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')
261
512
  remote=client.action('app_topolo_agent.agent_packs.get',{'instanceId':pending['input']['instanceId']},pending['agentWorkspaceId'])['connection']
262
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']:
263
514
  raise PackError('Pending connection read-back mismatch; retain receipt')
@@ -266,7 +517,7 @@ def recover_connection(lib,args,client):
266
517
  pending_path.unlink()
267
518
  atomic_json(path.parent.parent/'active.json',{'environment':args.environment,'contextKey':args.context_key})
268
519
  active=lib.active()
269
- 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'])
270
521
  path,binding=open_binding(lib,args,client);reports=[]
271
522
  memory=path.with_suffix('.memory-pending.json')
272
523
  journals=([memory] if memory.exists() else [])+list(path.with_suffix('').glob('artifact-*.json'))
@@ -293,8 +544,9 @@ def recover_connection(lib,args,client):
293
544
  state='artifact-and-memory-verified';atomic_json(journal,{'state':state,'artifact':receipt['artifact'],'memoryId':matches[0]['id'],'recovered':True})
294
545
  else:state='unresolved-memory-outcome'
295
546
  reports.append({'receipt':str(journal),'state':state})
296
- 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',
297
- '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}
298
550
 
299
551
  @contextlib.contextmanager
300
552
  def connection_lock(lib,args):