@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
|
@@ -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
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
|
151
|
-
|
|
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
|
|
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):
|