@topolo/cli 0.13.0 → 0.13.1

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.
@@ -339,3 +339,157 @@ unresolved for explicit reconciliation. An uploaded but unregistered artifact
339
339
  remains stored in Bytes and is reported separately. An uncertain connection can
340
340
  be retried with the identical immutable connect request. Switching does not
341
341
  reset a remote binding or erase Agent memory or Bytes files.
342
+
343
+
344
+ ## Claude Desktop preview
345
+
346
+ Claude Code Desktop 2.1.289 is qualified for native session import through
347
+ `--desktop --resume`. Each fresh pack role has its own named sidebar session,
348
+ CLAUDE.md, portable context files and persistent Claude session ID. No old Codex
349
+ conversation is copied and import submits no new task.
350
+
351
+ ```sh
352
+ topolo-agent-pack add topolo-clean --harness claude --import-only
353
+ topolo-agent-pack add topolo-clean --harness claude --group intakes --import-only
354
+ topolo-agent-pack add topolo-clean --harness claude --agent topolo-clean/intakes/engineering --import-only
355
+ topolo-agent-pack status --harness claude
356
+ ```
357
+
358
+ The public CLI source also dispatches `topolo agent-packs add ... --harness claude`;
359
+ this source change has not been published to the registry.
360
+
361
+ The CLI import handoff does not control outgoing membership, groups or pins.
362
+ Switching through that handoff refuses before changes. The experimental native
363
+ connection below provides a separate source implementation for those operations;
364
+ it still requires independent live and customer installation proof.
365
+ The adapter never edits Claude's live metadata, account state or permissions.
366
+ `recover` reads actual Desktop adoption records and reports incomplete imports;
367
+ repeating `add` resumes pending adoption with the same IDs. Workspaces and
368
+ transcripts live under the selected pack library and Claude config respectively.
369
+ Default Claude auto-cleanup still applies to transcripts; this preview is not a
370
+ portable backup of accumulated Claude history. Existing Codex switching is
371
+ unchanged. Role-identity inference, hosted Agent/Bytes shared memory and clean
372
+ machine installation require separate acceptance.
373
+
374
+ ## Agent execution policy
375
+
376
+ `topolo agent-packs policy topolo-clean` renders the portable model, reasoning,
377
+ approval and sidebar intent for every role. Use `--group`, `--agent`,
378
+ `--harness codex|claude` and `--output /absolute/path/review.json` to select or
379
+ save a review copy. The command never changes existing sessions or permissions.
380
+ An agent can declare `executionProfile` as `intake`, `analysis`, `review`,
381
+ `build` or `operator`; otherwise the adapter resolves its role default.
382
+
383
+ The initial Claude policy uses Opus 5.5 for reasoning roles and Sonnet 5.5 for
384
+ execution. Haiku 5.5 is not selected for the qualified 2.1.289 host, because it
385
+ requires 2.1.293 or later. Codex uses GPT-6 Astra for reasoning roles and
386
+ GPT-6.1 Sol for execution. These are desired settings, not proof of account
387
+ availability or live session selection.
388
+
389
+ Codex sandboxing and Claude permission modes have separate mappings. Claude
390
+ Manual does not enforce a read-only filesystem sandbox. The policy records
391
+ that extra confinement requirement instead of treating the two controls as
392
+ equivalent. Platform mutations still require the existing scoped authorization.
393
+
394
+ Role context and relevant memory should be loaded on demand, with bounded tool
395
+ results and one evidence-linked handoff. An idle pack should generate no model
396
+ turns or background polling. No token savings are claimed without usage evidence.
397
+
398
+ Claude session adoption is currently verified independently from complete pack
399
+ activation. The external native import command does not provide live group,
400
+ pin, model or outgoing-roster controls. `activationReady` and
401
+ `modelAndPermissionSettingsVerified` remain false until a qualified backend
402
+ transport applies and reads back those settings. UI automation is not part of
403
+ the adapter. Import status is deliberately separate from native activation proof.
404
+ ### Experimental Claude live configuration
405
+
406
+ The native import is not pack activation. `configure` can preflight and apply
407
+ an already adopted roster through the running Claude main process. This is an
408
+ internal compatibility adapter qualified for **Claude Desktop 2.31226.1** and
409
+ the exact native module hashes in `src/lib/claude-pack-backend.ts`; it refuses
410
+ other builds. It invokes native session-manager APIs and acknowledged sidebar
411
+ commands. It does not automate the UI or edit cached desktop records.
412
+
413
+ ```sh
414
+ topolo agent-packs policy topolo-clean --harness claude --output /absolute/review.json
415
+ topolo agent-packs configure topolo-clean --harness claude \
416
+ --policy /absolute/review.json --pid <Claude-main-PID> \
417
+ --endpoint ws://127.0.0.1:<port>/<inspector-UUID>
418
+ # After reviewing the preflight, repeat the same command with --apply.
419
+ ```
420
+
421
+ The endpoint must be enabled separately with the machine owner's explicit
422
+ authorization: a main-process debugger allows local code execution. The
423
+ adapter does not enable debugging, signal/restart Claude, patch its app bundle,
424
+ or leave a background service. Close a temporarily enabled debugger after the
425
+ operation. This experimental connection is not a released customer setup path.
426
+ The inspected 2.31226.1 build disables Electron's `nodeCliInspect` fuse, so
427
+ startup flags and `SIGUSR1` do not enable its inspector. Its native Developer
428
+ Mode setup requires a one-time restart, followed by the Developer menu's
429
+ `Enable Main Process Debugger` setting. Any such setup is separately authorized;
430
+ do not patch fuses or assume a restart flag opens the endpoint.
431
+
432
+ All selected sessions must be idle, have matching titles/workspaces, and use
433
+ models from the native account catalog. Settings use Manual or Accept edits;
434
+ no bypass permission is accepted. An fsynced journal records the baseline and
435
+ each attempted native effect before dispatch. On a disconnect or failed
436
+ acknowledgement, use the explicit read-only native recovery command below before
437
+ retrying; do not assume failure means no effects. Application independently
438
+ reads back groups, pins, models, effort and permission modes and hashes role
439
+ charters/transcripts before and after. It does not claim an inference test,
440
+ read-only filesystem confinement or hosted memory verification. Those remain
441
+ separate acceptance boundaries.
442
+
443
+ ### Experimental native Claude switching and recovery
444
+
445
+ First configure and verify the initial adopted pack. Prepare/import the target
446
+ pack independently and render its complete reviewed policy. Then preflight:
447
+
448
+ ```sh
449
+ topolo agent-packs switch personal --harness claude \
450
+ --policy /absolute/personal-policy.json --pid <Claude-main-PID> \
451
+ --endpoint ws://127.0.0.1:<port>/<inspector-UUID>
452
+ # Repeat with --apply after reviewing the result.
453
+ topolo agent-packs recover personal --harness claude \
454
+ --policy /absolute/personal-policy.json --pid <Claude-main-PID> \
455
+ --endpoint ws://127.0.0.1:<port>/<inspector-UUID>
456
+ ```
457
+
458
+ Switching archives only the explicit previously verified outgoing roster and
459
+ unarchives the existing target identities. It does not delete sessions, clean
460
+ worktrees, create model turns or touch unrelated sessions. Live work, remote or
461
+ linked workspaces and nonempty archive cascades refuse before effects. Role
462
+ files and transcripts are hashed inside the native operation and independently
463
+ by the caller. Python imports and native commands share the same library lock;
464
+ the native manager also fences competing operations across libraries.
465
+
466
+ An interrupted native operation blocks further imports or native operations.
467
+ Recovery reads the exact sealed operation plan and native state; it never
468
+ replays effects. A completely verified target can finalize an interrupted
469
+ commit. If the journal proves no effect was attempted, recovery releases that
470
+ operation without changing the previous active composition. Partial effects,
471
+ changed context and a still-running operation remain recovery-required; they
472
+ need supported owner reconciliation. No expiry or PID observation clears a
473
+ pending operation. `status --harness claude` distinguishes adoption readback,
474
+ the last verified active roster and pending recovery; cached proof is not
475
+ reported as current native settings. Use explicit native recovery above rather
476
+ than retrying an uncertain apply.
477
+
478
+ ### Managed Codex role execution
479
+
480
+ New managed compositions carry a closed per-role execution policy. The owned
481
+ proxy qualifies the installed executable's exported protocol offline and reads
482
+ its account model catalog. On native `thread/resume` it supplies the selected
483
+ role's model, effort, approval reviewer and local sandbox without starting a
484
+ turn. An effective-settings mismatch blocks subsequent model work. Conflicting
485
+ frontend overrides are reported; the adapter never silently substitutes models
486
+ or expands permissions. Update reviewed policy explicitly to change it.
487
+ Read-only roles have no command network access; workspace writers are confined
488
+ to their role workspace with temporary-directory roots excluded. Platform tool
489
+ authorization remains separate from local command sandboxing.
490
+
491
+ Managed bridge status reports requested policy and which resumes have current
492
+ native readback. It does not claim an inference test or independent app-tool
493
+ approval readback. Existing runtimes without this policy report not-configured
494
+ until explicitly rebuilt. Source fixtures do not establish live desktop or
495
+ three-environment shared-memory acceptance.
@@ -0,0 +1,265 @@
1
+ """Versioned Claude Code Desktop session import. Never rewrites live app records.
2
+
3
+ Fresh role files and CLI transcripts are owned by this adapter. Desktop adopts
4
+ them through its native resume handoff. There is no external archive API in
5
+ that handoff; full roster replacement is therefore refused, not simulated.
6
+ """
7
+ import argparse
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import pathlib
12
+ import pty
13
+ import select as io_select
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ import uuid
19
+
20
+ from .storage import Library, PackError, atomic_json, canonical, now, safe_id, select
21
+
22
+
23
+ def find_binary():
24
+ root = pathlib.Path.home() / 'Library/Application Support/Claude/claude-code'
25
+ candidates = list(root.glob('*/*/claude.app/Contents/MacOS/claude'))
26
+ if candidates:
27
+ return str(max(candidates, key=lambda p: tuple(int(x) for x in p.relative_to(root).parts[0].split('.'))))
28
+ return shutil.which('claude')
29
+
30
+
31
+ def handoff(binary, directory, session_id):
32
+ """Native --desktop requires a TTY. No prompt, model call or shell is used."""
33
+ if not binary:
34
+ raise PackError('Claude Code 2.1.285+ is required; install Claude Desktop or Claude Code.')
35
+ version = subprocess.run([binary, '--version'], capture_output=True, text=True, timeout=10)
36
+ if version.returncode or not version.stdout.startswith('2.1.289 '):
37
+ raise PackError('This native import adapter is qualified for Claude Code 2.1.289; selected binary: ' + version.stdout.strip())
38
+ master, slave = pty.openpty()
39
+ process = None
40
+ output = bytearray()
41
+ try:
42
+ process = subprocess.Popen([binary, '--desktop', '--resume', session_id],
43
+ cwd=directory, stdin=slave, stdout=slave, stderr=slave)
44
+ os.close(slave)
45
+ slave = None
46
+ deadline = time.monotonic() + 20
47
+ while process.poll() is None:
48
+ if time.monotonic() >= deadline:
49
+ raise PackError('Claude Desktop handoff timed out; run recover to reconcile imports.')
50
+ if io_select.select([master], [], [], .1)[0]:
51
+ try:
52
+ chunk = os.read(master, 65536)
53
+ except OSError:
54
+ break
55
+ output.extend(chunk)
56
+ if len(output) > 65536:
57
+ raise PackError('Claude Desktop handoff exceeded its output limit.')
58
+ process.wait(timeout=2)
59
+ if process.returncode or ('Opening session ' + session_id) not in output.decode(errors='replace'):
60
+ raise PackError('Claude refused the native Desktop handoff: ' + output.decode(errors='replace')[-2000:])
61
+ finally:
62
+ if process is not None and process.poll() is None:
63
+ process.terminate()
64
+ process.wait(timeout=5)
65
+ if slave is not None:
66
+ os.close(slave)
67
+ os.close(master)
68
+
69
+
70
+ def project_directory(config, directory):
71
+ # The native CLI's current path-key codec. A versioned compatibility boundary.
72
+ import re
73
+ key = re.sub(r'[^a-zA-Z0-9]', '-', str(directory))
74
+ if len(key) > 200:
75
+ raise PackError('Claude project path exceeds the qualified path-key boundary (200 characters).')
76
+ return config / 'projects' / key
77
+
78
+
79
+ def write_new(path, data):
80
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
81
+ if path.exists() or path.is_symlink():
82
+ if path.is_symlink() or not path.is_file() or path.read_bytes() != data:
83
+ raise PackError('Refusing to overwrite existing context or transcript: ' + str(path))
84
+ return
85
+ with path.open('xb') as stream:
86
+ stream.write(data)
87
+ stream.flush()
88
+ os.fsync(stream.fileno())
89
+ path.chmod(0o600)
90
+
91
+
92
+ def materialize(lib, name, selected, config):
93
+ manifest, revision = lib.load(name)
94
+ if manifest.get('startingContext', {}).get('kind') != 'fresh':
95
+ raise PackError('Claude import requires a fresh pack with reviewed role files; old Codex history is not translated.')
96
+ records = []
97
+ root = lib.root / 'claude'
98
+ for agent in selected:
99
+ safe_id(agent['set'])
100
+ safe_id(agent['id'])
101
+ key = name + '/' + agent['set'] + '/' + agent['id']
102
+ identity = root / 'identities' / (hashlib.sha256(key.encode()).hexdigest() + '.json')
103
+ if identity.exists():
104
+ record = json.loads(identity.read_text())
105
+ if record['sourceRevision'] != revision:
106
+ raise PackError('Existing Claude agent uses a different revision; build a new named pack to preserve its context.')
107
+ if record['importState'] != 'preparing':
108
+ records.append(record)
109
+ continue
110
+ else:
111
+ session_id = str(uuid.uuid4())
112
+ workspace = root / 'workspaces' / name / agent['set'] / agent['id']
113
+ record = {'key': key, 'pack': name, 'set': agent['set'], 'agent': agent['id'],
114
+ 'title': agent['title'], 'pinnedRequested': agent.get('pinned', False),
115
+ 'sourceRevision': revision, 'sourceCodexThreadId': agent['threadId'],
116
+ 'cliSessionId': session_id, 'desktopSessionId': 'local_' + session_id,
117
+ 'workspace': str(workspace),
118
+ 'transcript': str(project_directory(config, workspace) / (session_id + '.jsonl')),
119
+ 'importState': 'preparing', 'createdAt': now(), 'bootstrapUuid': str(uuid.uuid4())}
120
+ # Save allocation before any workspace/transcript write. Retrying a
121
+ # interrupted preparation uses the same IDs and compares old bytes.
122
+ atomic_json(identity, record)
123
+ original = pathlib.Path(manifest['threads'][agent['threadId']]['cwd'])
124
+ workspace = pathlib.Path(record['workspace'])
125
+ if workspace.is_symlink():
126
+ raise PackError('Claude workspace must not be a symlink')
127
+ workspace.mkdir(parents=True, exist_ok=True, mode=0o700)
128
+ resources = [r for r in manifest['resources'] if pathlib.Path(r['original']).is_relative_to(original)]
129
+ instructions = next((r for r in resources if pathlib.Path(r['original']).relative_to(original).as_posix() == 'AGENTS.md'), None)
130
+ if instructions is None:
131
+ raise PackError('Missing reviewed role instructions for ' + key)
132
+ for resource in resources:
133
+ relative = pathlib.Path(resource['original']).relative_to(original)
134
+ if '..' in relative.parts or any(p.is_symlink() for p in (workspace / relative).parents if p != lib.root.parent):
135
+ raise PackError('Unsafe context path: ' + str(relative))
136
+ # Never copy runtime state, prior tasks, scripts or credentials into a role.
137
+ if relative.suffix != '.md' or relative.as_posix() == 'context/TEAM.md':
138
+ continue
139
+ if relative.as_posix() == 'AGENTS.md':
140
+ relative = pathlib.Path('CLAUDE.md')
141
+ write_new(workspace / relative, lib.object(resource['content']).read_bytes())
142
+ session_id = record['cliSessionId']
143
+ transcript = pathlib.Path(record['transcript'])
144
+ note = (agent['title'] + '. You are this Topolo Agent Pack role in Claude Desktop. '
145
+ 'Read CLAUDE.md and relevant context/ files before work. '
146
+ 'This is starting context only; no assignment, claim or prior conversation is inherited. '
147
+ 'Await a new human request. Coordination requires human authorization.')
148
+ rows = [{'parentUuid': None, 'isSidechain': False, 'userType': 'external',
149
+ 'cwd': str(workspace), 'sessionId': session_id, 'version': '2.1.289',
150
+ 'type': 'user', 'message': {'role': 'user', 'content': note},
151
+ 'uuid': record['bootstrapUuid'], 'timestamp': record['createdAt']},
152
+ {'type': 'custom-title', 'sessionId': session_id, 'customTitle': agent['title']}]
153
+ write_new(transcript, b''.join(canonical(row) + b'\n' for row in rows))
154
+ record['importState'] = 'prepared'
155
+ atomic_json(identity, record)
156
+ records.append(record)
157
+ team = ('# Claude pack roster\n\nThese are Claude Desktop session identities. '
158
+ 'No task, claim, messaging authorization or hosted connection is granted by this roster.\n\n' +
159
+ '\n'.join('- ' + a['key'] + ' | ' + a['title'] + ' | ' + a['desktopSessionId'] for a in records) + '\n')
160
+ for record in records:
161
+ context = pathlib.Path(record['workspace']) / 'context'
162
+ write_new(context / ('TEAM-' + hashlib.sha256(team.encode()).hexdigest()[:16] + '.md'), team.encode())
163
+ pointer = context / 'TEAM.md'
164
+ if not pointer.exists():
165
+ write_new(pointer, b'# Claude team identities\n\nRead TEAM-*.md for immutable Claude roster snapshots. Use topolo-agent-pack status --harness claude for current membership. Only human-authorized coordination is permitted.\n')
166
+ return records
167
+
168
+
169
+ def readback(records, app_data):
170
+ results = []
171
+ for record in records:
172
+ files = list((app_data / 'claude-code-sessions').glob('*/*/' + record['desktopSessionId'] + '.json'))
173
+ if len(files) != 1:
174
+ results.append({'key': record['key'], 'verified': False, 'reason': 'unique Desktop adoption record missing'})
175
+ continue
176
+ state = json.loads(files[0].read_text())
177
+ ok = (state.get('cliSessionId') == record['cliSessionId'] and
178
+ state.get('title') == record['title'] and
179
+ state.get('cwd') == record['workspace'] and not state.get('isArchived'))
180
+ results.append({'key': record['key'], 'desktopSessionId': record['desktopSessionId'],
181
+ 'verified': ok, 'completedTurns': state.get('completedTurns') or 0,
182
+ 'record': str(files[0])})
183
+ return results
184
+
185
+
186
+ def parser():
187
+ p = argparse.ArgumentParser(description='Claude Code Desktop pack sessions (native sidebar import; full switch is not yet supported).')
188
+ p.add_argument('--store', default=os.environ.get('CODEX_PACK_HOME', str(pathlib.Path.home() / 'AgentPacks')))
189
+ p.add_argument('--claude-config', default=str(pathlib.Path.home() / '.claude'))
190
+ p.add_argument('--claude-data', default=str(pathlib.Path.home() / 'Library/Application Support/Claude'))
191
+ sub = p.add_subparsers(dest='command', required=True)
192
+ for action in ['add', 'switch', 'replace']:
193
+ q = sub.add_parser(action)
194
+ q.add_argument('source')
195
+ q.add_argument('--set', '--group', dest='sets', action='append')
196
+ q.add_argument('--agent', dest='agents', action='append')
197
+ q.add_argument('--catalog', action='store_true')
198
+ q.add_argument('--no-open', action='store_true')
199
+ q.add_argument('--import-only', action='store_true', help='Explicit incomplete native session import; does not activate pack settings')
200
+ q.add_argument('--claude-bin', default=find_binary())
201
+ for action in ['status', 'doctor', 'recover', 'list']:
202
+ sub.add_parser(action)
203
+ return p
204
+
205
+
206
+ def main(argv):
207
+ args = parser().parse_args(argv)
208
+ try:
209
+ lib = Library(args.store)
210
+ app_data = pathlib.Path(args.claude_data).expanduser().resolve()
211
+ state_path = lib.root / 'claude/state.json'
212
+ with lib.lock():
213
+ state = json.loads(state_path.read_text()) if state_path.exists() else {'schemaVersion': 1, 'harness': 'claude', 'agents': []}
214
+ if args.command in {'status', 'doctor', 'recover', 'list'}:
215
+ proof = readback(state['agents'], app_data)
216
+ active_path = lib.root / 'claude/active.json'
217
+ pending_path = lib.root / 'claude/native-operation.json'
218
+ result = {**state, 'adoptionProof': proof, 'verifiedAgents': sum(p['verified'] for p in proof),
219
+ 'fullPackSwitchSupported': False, 'sidebarGroupsAndPinsVerified': False,
220
+ 'nativeRecordsRewritten': False, 'modelIdentityResponseVerified': False,
221
+ 'activationReady': False, 'modelAndPermissionSettingsVerified': False,
222
+ 'lastVerifiedActiveRoster': json.loads(active_path.read_text()) if active_path.exists() else None,
223
+ 'nativeRecoveryRequired': pending_path.exists(),
224
+ 'nativeRecoveryReplayed': False,
225
+ 'verificationBoundary': 'read-only adoption records; use the explicit native connection for current settings or recovery'}
226
+ else:
227
+ if (lib.root / 'claude/native-operation.json').exists():
228
+ raise PackError('An unresolved native Claude operation blocks imports and preparation. Use topolo agent-packs recover with the explicit native connection; no effects are replayed.')
229
+ manifest, revision = lib.load(args.source)
230
+ chosen = select(manifest, args.sets, args.agents)
231
+ keys = {args.source + '/' + a['set'] + '/' + a['id'] for a in chosen}
232
+ outgoing = {a['key'] for a in state['agents']} - keys
233
+ if args.command in {'switch', 'replace'} and outgoing:
234
+ raise PackError('Claude full-pack replacement is not qualified: native --desktop --resume cannot archive the outgoing roster. No changes were made. Use add for sidebar import.')
235
+ if not args.no_open and not args.import_only:
236
+ raise PackError('Claude pack activation requires a qualified live backend for groups, pins, model and permission settings. No changes were made. --no-open prepares context; --import-only explicitly imports sessions without completing pack activation.')
237
+ records = materialize(lib, args.source, chosen, pathlib.Path(args.claude_config).expanduser().resolve())
238
+ combined = {a['key']: a for a in state['agents']}
239
+ combined.update({a['key']: a for a in records})
240
+ state = {**state, 'agents': list(combined.values()), 'updatedAt': now()}
241
+ # Durable recovery plan before asking Desktop to adopt a session.
242
+ atomic_json(state_path, state)
243
+ if not args.no_open:
244
+ for record in records:
245
+ if not readback([record], app_data)[0]['verified']:
246
+ handoff(args.claude_bin, record['workspace'], record['cliSessionId'])
247
+ deadline = time.monotonic() + 15
248
+ while not readback([record], app_data)[0]['verified']:
249
+ if time.monotonic() > deadline:
250
+ raise PackError('Desktop adoption not verified for ' + record['key'] + '; run recover.')
251
+ time.sleep(.1)
252
+ record['importState'] = 'desktop-adopted'
253
+ atomic_json(state_path, state)
254
+ proof = readback(records, app_data)
255
+ result = {'harness': 'claude', 'state': 'prepared' if args.no_open else 'desktop-adopted',
256
+ 'selectedAgents': len(records), 'verifiedAgents': sum(p['verified'] for p in proof),
257
+ 'adoptionProof': proof, 'fullPackSwitchSupported': False,
258
+ 'previousCodexHistoryCopied': False, 'nativeRecordsRewritten': False,
259
+ 'sidebarGroupsAndPinsVerified': False, 'activationReady': False,
260
+ 'modelAndPermissionSettingsVerified': False}
261
+ print(json.dumps(result, indent=2))
262
+ return 0
263
+ except (PackError, OSError, ValueError, KeyError, subprocess.SubprocessError) as exc:
264
+ print('topolo-agent-pack (Claude): ' + str(exc), file=sys.stderr)
265
+ return 2
@@ -15,7 +15,7 @@ import time
15
15
  import uuid
16
16
 
17
17
  from . import __version__
18
- from . import native, live, fresh, blueprint
18
+ from . import native, live, fresh, blueprint, execution
19
19
  from .storage import Library, PackError, atomic_json, canonical, component_key, now, read_json, references, safe_id, select, sync_tree
20
20
 
21
21
  def emit(value):
@@ -234,6 +234,7 @@ def apply_plan(lib,plan,args):
234
234
  if local_config.exists():
235
235
  shutil.copyfile(local_config,runtime/'home'/'config.toml');(runtime/'home'/'config.toml').chmod(0o600)
236
236
  if old_runtime and old_runtime!=runtime and (old_runtime/'desktop-view.json').exists():shutil.copyfile(old_runtime/'desktop-view.json',runtime/'desktop-view.json')
237
+ execution.compile_runtime(lib, target_components, runtime)
237
238
  journal['phase']='staged';atomic_json(jpath,journal)
238
239
  if old_runtime and old_runtime!=runtime and (old_runtime/'home/auth.json').exists():
239
240
  shutil.copyfile(old_runtime/'home/auth.json',runtime/'home/auth.json');(runtime/'home/auth.json').chmod(0o600)
@@ -313,6 +314,8 @@ def parser():
313
314
  if action=='replace': q.add_argument('--target')
314
315
  if action=='switch': q.add_argument('--fresh',action='store_true',help='Use the captured source instead of resuming a previously saved workbench')
315
316
  q=sub.add_parser('plan');q.add_argument('action',choices=['add','replace','switch']);q.add_argument('source');selection(q);q.add_argument('--target');q.add_argument('--fresh',action='store_true')
317
+ q=sub.add_parser('policy',help='Render portable role settings for review; never changes live sessions')
318
+ q.add_argument('source');selection(q);q.add_argument('--output')
316
319
  for command in ['connect','remote-context','remember','artifact','artifact-get','remote-recover']:
317
320
  q=sub.add_parser(command,help='Use public Topolo Agent continuity and Bytes artifact contracts')
318
321
  q.add_argument('name');q.add_argument('--environment',choices=['development','staging','production'],required=True)
@@ -335,6 +338,22 @@ def parser():
335
338
  return p
336
339
 
337
340
  def main(argv=None):
341
+ argv = list(sys.argv[1:] if argv is None else argv)
342
+ harness = 'codex'
343
+ explicit_harness = '--harness' in argv
344
+ if '--harness' in argv:
345
+ at = argv.index('--harness')
346
+ if at + 1 >= len(argv):
347
+ parser().error('--harness requires codex or claude')
348
+ harness = argv[at + 1]
349
+ del argv[at:at + 2]
350
+ policy_command = 'policy' in argv
351
+ if harness == 'claude' and not policy_command:
352
+ from . import claude
353
+ return claude.main(argv)
354
+ if harness not in {'codex', 'claude'}:
355
+ print('topolo-agent-pack: Unsupported harness: ' + harness, file=sys.stderr)
356
+ return 2
338
357
  args=parser().parse_args(argv)
339
358
  try:
340
359
  lib=Library(args.store)
@@ -344,7 +363,15 @@ def main(argv=None):
344
363
  emit(result)
345
364
  return 0
346
365
  with lib.lock():
347
- if args.command=='capture':
366
+ if args.command=='policy':
367
+ from . import policy
368
+ manifest,revision=lib.load(args.source)
369
+ result=policy.render(manifest,revision,select(manifest,args.sets,args.agents),
370
+ (harness,) if explicit_harness else ('codex','claude'))
371
+ if args.output:
372
+ from .claude import write_new
373
+ write_new(pathlib.Path(args.output).expanduser().absolute(), canonical(result)+b'\n')
374
+ elif args.command=='capture':
348
375
  safe_id(args.name)
349
376
  active=lib.active();template=None
350
377
  if args.source_home: home=pathlib.Path(args.source_home)
@@ -0,0 +1,154 @@
1
+ """Per-role defaults for the owned Codex proxy, with explicit native read-back.
2
+
3
+ No model turns are created here. A manifest is intent, never activation proof.
4
+ Conflicting frontend settings are rejected instead of silently broadening the
5
+ reviewed role. Changing that intent requires rebuilding the selected policy.
6
+ """
7
+ import copy
8
+ import hashlib
9
+ import json
10
+ import pathlib
11
+ import sqlite3
12
+ import subprocess
13
+ import tempfile
14
+ from contextlib import closing
15
+
16
+ from . import policy
17
+ from .storage import PackError, atomic_json, component_key, read_json
18
+
19
+
20
+ def compile_runtime(lib, components, runtime):
21
+ runtime = pathlib.Path(runtime)
22
+ with closing(sqlite3.connect('file:' + str(runtime / 'home/state_5.sqlite') + '?mode=ro', uri=True)) as db:
23
+ cwds = dict(db.execute('SELECT id,cwd FROM threads'))
24
+ rows = []
25
+ for component in components:
26
+ manifest, revision = lib.load(component['sourcePack'], component['revision'])
27
+ 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
+ raise PackError('Execution policy has no unique owned role: ' + component['key'])
30
+ rows.append({'key': component['key'], 'threadId': component['threadId'],
31
+ 'sourceRevision': revision, 'cwd': cwds[component['threadId']],
32
+ 'settings': policy.settings(matches[0], 'codex')['nativeSettings']})
33
+ document = {'format': 'topolo-codex-runtime-execution', 'schemaVersion': 1,
34
+ 'roles': rows, 'applied': False, 'modelTurnsStarted': False}
35
+ # Construct/validate fully before installing intent in a staged runtime.
36
+ ExecutionGate(document)
37
+ atomic_json(runtime / 'execution-policy.json', document)
38
+ return document
39
+
40
+
41
+ class ExecutionGate:
42
+ def __init__(self, document):
43
+ if document.get('format') != 'topolo-codex-runtime-execution' or document.get('schemaVersion') != 1:
44
+ raise PackError('Unqualified runtime execution policy')
45
+ self.roles = {}; self.pending = {}; self.verified = set(); self.catalog = {}
46
+ for role in document['roles']:
47
+ tid = role['threadId']; spec = role['settings']
48
+ if tid in self.roles or not pathlib.Path(role['cwd']).is_absolute():
49
+ raise PackError('Duplicate thread or non-absolute role workspace')
50
+ required = {'model', 'model_reasoning_effort', 'approval_policy', 'approvals_reviewer',
51
+ 'sandbox_mode', 'sandbox_workspace_write', 'apps'}
52
+ if (set(spec) != required or not isinstance(spec['model'], str) or not spec['model'] or
53
+ spec['model_reasoning_effort'] not in {'medium', 'high'} or
54
+ spec['approval_policy'] != 'on-request' or spec['approvals_reviewer'] != 'user' or
55
+ spec['sandbox_mode'] not in {'read-only', 'workspace-write'} or
56
+ spec['sandbox_workspace_write'] != {'network_access': False, 'writable_roots': [],
57
+ 'exclude_slash_tmp': True, 'exclude_tmpdir_env_var': True} or
58
+ spec['apps'] != {'_default': {'default_tools_approval_mode': 'writes'}}):
59
+ raise PackError('Unqualified role execution settings')
60
+ self.roles[tid] = copy.deepcopy(role)
61
+
62
+ def set_catalog(self, result):
63
+ self.catalog = {row['model']: {e['reasoningEffort'] for e in row['supportedReasoningEfforts']}
64
+ for row in result['data']}
65
+
66
+ def sandbox(self, role):
67
+ if role['settings']['sandbox_mode'] == 'read-only':
68
+ return {'type': 'readOnly', 'networkAccess': False}
69
+ return {'type': 'workspaceWrite', 'networkAccess': False, 'writableRoots': [role['cwd']],
70
+ 'excludeSlashTmp': True, 'excludeTmpdirEnvVar': True}
71
+
72
+ def prepare(self, message):
73
+ value = copy.deepcopy(message); method = value.get('method'); params = value.get('params') or {}
74
+ tid = params.get('threadId'); role = self.roles.get(tid)
75
+ if not role or method not in {'thread/resume', 'turn/start'}:
76
+ return value
77
+ if 'id' not in value or str(value['id']) in self.pending:
78
+ raise PackError('Managed execution requires a unique request identity')
79
+ spec = role['settings']
80
+ if spec['model_reasoning_effort'] not in self.catalog.get(spec['model'], set()):
81
+ raise PackError('Role model/effort is absent from the native account catalog; no fallback selected')
82
+ if any(params.get(key) is not None for key in ['permissions', 'path', 'history', 'collaborationMode', 'environments', 'runtimeWorkspaceRoots']):
83
+ raise PackError('Frontend supplied unreviewed permission, identity or execution overrides')
84
+ wanted = {'cwd': role['cwd'], 'model': spec['model'], 'approvalPolicy': spec['approval_policy'],
85
+ 'approvalsReviewer': spec['approvals_reviewer']}
86
+ if method == 'thread/resume':
87
+ wanted['sandbox'] = spec['sandbox_mode']
88
+ wanted['config'] = {'model_reasoning_effort': spec['model_reasoning_effort'],
89
+ 'sandbox_workspace_write': spec['sandbox_workspace_write'], 'apps': spec['apps']}
90
+ self.verified.discard(tid)
91
+ else:
92
+ if tid not in self.verified:
93
+ raise PackError('Role needs a verified native resume before starting a turn')
94
+ wanted.update(effort=spec['model_reasoning_effort'], sandboxPolicy=self.sandbox(role))
95
+ for key, expected in wanted.items():
96
+ if params.get(key) is not None and params[key] != expected:
97
+ raise PackError('Frontend ' + key + ' conflicts with the reviewed role policy')
98
+ params[key] = copy.deepcopy(expected)
99
+ value['params'] = params
100
+ if method == 'thread/resume': self.pending[str(value['id'])] = tid
101
+ return value
102
+
103
+ def reply(self, value):
104
+ tid = self.pending.pop(str(value.get('id')), None)
105
+ if tid is None: return value
106
+ self.verified.discard(tid)
107
+ if 'error' in value: return value
108
+ role = self.roles[tid]; spec = role['settings']; result = value.get('result', {})
109
+ expected = {'model': spec['model'], 'cwd': role['cwd'], 'approvalPolicy': spec['approval_policy'],
110
+ 'approvalsReviewer': spec['approvals_reviewer'], 'reasoningEffort': spec['model_reasoning_effort'],
111
+ 'sandbox': self.sandbox(role)}
112
+ sandbox = result.get('sandbox', {})
113
+ # Native responses can omit false read-only networkAccess defaults.
114
+ actual = copy.deepcopy(result)
115
+ if sandbox.get('type') == 'readOnly': actual['sandbox'] = {'type': 'readOnly', 'networkAccess': sandbox.get('networkAccess', False)}
116
+ if result.get('thread', {}).get('id') != tid or any(actual.get(k) != v for k, v in expected.items()):
117
+ return {'id': value['id'], 'error': {'code': -32000,
118
+ 'message': 'Native role settings differ from the reviewed policy; model work is blocked'}}
119
+ self.verified.add(tid)
120
+ return value
121
+
122
+ def status(self):
123
+ return {'configuredRoles': len(self.roles), 'nativeResumeVerifiedRoles': sorted(self.verified),
124
+ 'requested': [{'key': r['key'], 'threadId': tid, 'settings': r['settings']} for tid, r in self.roles.items()],
125
+ 'appToolApprovalReadbackVerified': False, 'modelIdentityResponseVerified': False}
126
+
127
+
128
+ def load_gate(runtime):
129
+ path = pathlib.Path(runtime) / 'execution-policy.json'
130
+ return ExecutionGate(read_json(path)) if path.exists() else None
131
+
132
+
133
+ def qualify_protocol(binary, run=subprocess.run):
134
+ """Generate schema offline from this executable; never initialize an app server."""
135
+ required = {'ThreadResumeParams': {'threadId', 'model', 'approvalPolicy', 'approvalsReviewer', 'sandbox', 'config'},
136
+ 'ThreadResumeResponse': {'thread', 'model', 'cwd', 'reasoningEffort', 'approvalPolicy', 'approvalsReviewer', 'sandbox'},
137
+ 'TurnStartParams': {'threadId', 'model', 'effort', 'approvalPolicy', 'approvalsReviewer', 'sandboxPolicy'},
138
+ 'ModelListParams': {'includeHidden', 'cursor'}, 'ModelListResponse': {'data', 'nextCursor'}}
139
+ with tempfile.TemporaryDirectory(prefix='topolo-codex-protocol-') as temp:
140
+ result = run([binary, 'app-server', 'generate-json-schema', '--experimental', '--out', temp],
141
+ capture_output=True, timeout=20, check=False)
142
+ if result.returncode:
143
+ raise PackError('Installed Codex protocol export refused; no backend started')
144
+ hashes = {}
145
+ for name, fields in required.items():
146
+ matches = list(pathlib.Path(temp).rglob(name + '.json'))
147
+ if len(matches) != 1:
148
+ raise PackError('Installed Codex protocol has no unique ' + name)
149
+ data = matches[0].read_bytes(); schema = json.loads(data)
150
+ if not fields <= set(schema.get('properties', {})):
151
+ raise PackError('Installed Codex protocol settings are incompatible: ' + name)
152
+ hashes[name] = hashlib.sha256(data).hexdigest()
153
+ return {'boundary': 'offline installed executable protocol export', 'schemas': hashes,
154
+ 'modelTurnsStarted': False}