@topolo/cli 0.13.11 → 0.13.13

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/README.md CHANGED
@@ -448,3 +448,13 @@ remain unqualified. Native CLI effects therefore refuse with
448
448
  `native_cli_startup_unqualified` before client/helper/native access. Private MCP
449
449
  startup retains its existing refusal. Version 1 plans stay readable and do not
450
450
  silently become version 2 CLI plans.
451
+
452
+ ### Preserving private pack runtime configuration
453
+
454
+ `topolo agent-packs --store /owned/AgentPacks runtime-adoption plan --manifest /review/adoption.json` produces a nonwriting configuration plan. Review its physical targets and digest, then use `runtime-adoption apply --manifest /review/adoption.json --plan-digest <sha256> --confirm` for one configuration-only attempt.
455
+
456
+ The closed owner manifest binds the actual consumer set and effective local Topolo transport origins, physical file preimages, shared aliases, private profile metadata, one of the two reviewed immutable consumer tuples (CLI 0.13.11/MCP 0.14.26/SDK 0.23.21 or CLI 0.13.12/MCP 0.14.27/SDK 0.23.22) and the separately pinned successor Python adapter. Consumer tuple versions are independent of the driver package version; mixed tuples and other versions refuse. It must include every server in each physical config as either a targeted transport or an explicitly untouched server. Empty or incomplete manifests refuse. The command never installs skills, rebinds a home, changes existing instructions/permissions, starts a consumer or probes Auth.
457
+
458
+ A successful apply means configuration is prepared. It does not mean existing processes reloaded it, the private account is healthy, permissions were granted or either native harness completed a task. Starting or refreshing consumers is a separate owner-authorized step and can contact private Auth and renew the selected private profile. Public credential-store repair is not a blanket prerequisite for independent private profiles. See [the manifest and effect boundaries](agent-packs/README.md#private-runtime-adoption).
459
+
460
+ The protocol SDK pin addresses [GHSA-6qxp-vccf-f47h](https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/GHSA-6qxp-vccf-f47h). The CLI doctor's protocol transport is stdio. Separate HTTP OAuth consumers still need appropriate issuer binding for legacy credentials and bundled providers; this source update neither inspects nor migrates credentials.
@@ -699,3 +699,26 @@ contracts are unavailable. Effects stay closed with
699
699
  `native_cli_tool_result_unqualified`. Input references, hashes or JSON flags do
700
700
  not grant admission. Private MCP is a separate unsupported transport and is never
701
701
  a fallback. Source fixtures do not activate either native harness.
702
+
703
+ ## Private runtime adoption
704
+
705
+ The public CLI forwards `agent-packs runtime-adoption plan|apply` to the bundled successor adapter. `plan` performs no writes. `apply` requires the exact reviewed `--plan-digest` and `--confirm`; it never calls `prepare`, `bind`, `reconnect`, skill installation or a native/Auth client. The selected immutable public CLI/MCP cohort is separate from the successor Python adapter source. Do not edit an installed immutable runtime to add the successor hooks.
706
+
707
+ The manifest is a local owner-reviewed configuration scope, not native provenance, a permission grant or a health certificate. Its exact top-level fields are:
708
+
709
+ - `schemaVersion: 1`, a fresh UUID `operationId`, and the absolute physical `library`.
710
+ - `ownerGeneration: {path, sha256}`: the owner's current bounded nonsecret effective-transport/generation record, inside that library. The owner must establish its native currency separately.
711
+ - `consumers`: the complete unique existing native IDs in scope. Target rows must account for every ID; no new native IDs are created.
712
+ - `profile: {path, publicPath, metadata, environment, contextKey, principalId}`: absolute private and public credential-file references, explicit organization context, and metadata only. Files must be physically distinct; the private profile must be owned, single-link, regular and mode 0600. Neither profile's contents are read or hashed. A renewal that changes private file metadata invalidates the descriptor and requires owner reconciliation.
713
+ - `runtime: {node, cli, mcp, cohort, pythonSource, adapterFiles, osEnvironment}`. Each executable/entry and each `cohort` value is `{path, sha256}`. Cohort keys `cli`, `mcp`, `sdk` pin their package JSON; the exact reviewed consumer tuples are CLI 0.13.11/MCP 0.14.26/SDK 0.23.21 and CLI 0.13.12/MCP 0.14.27/SDK 0.23.22. Each tuple is coupled: mixed or unreviewed versions refuse, and both CLI and MCP must depend on that tuple's exact SDK version. The consumer tuple is independent of the driver package version. CLI and MCP entries must be their pinned package's `dist/index.js`. `pythonSource` is the physical successor source directory; `adapterFiles` pins its `topolo_agent_pack/{cli,live,topolo}.py`. Verify the complete installed cohort independently before admitting its pins. `osEnvironment` permits only HOME/PATH and optional USER/LOGNAME/LANG/LC_ALL/TMPDIR, with no credential, service-origin, Node or installation overrides.
714
+ - `targets`: one row per unique physical configuration file, with `{path, before, aliases, format, rootKey, consumers, transports, untouchedServers}`. Supported formats are JSON `mcpServers` and simple TOML `mcp_servers` tables. Each transport is `{name, origin, consumers}` and must already exist as a local command; URL, inherited `env_vars`, installation-mode, ambiguous names or unsupported TOML layouts refuse. `origin` is the exact physical file. The complete physical server map must equal the targeted names plus `untouchedServers`. The owner must independently bind all effective native layers; a zero-table query or a guessed server name is insufficient.
715
+ - `launcher: {path, before}`: exactly the owned generated `launchers/topolo-agent-pack-backend`; its interpreter, owner and mode are preserved.
716
+ - `preserve`: nonsecret `{path, sha256}` references to existing roster, model/effort, permission, charter/context, routing and lifecycle records. Include every protected fixed and dynamic record in the approved scope. These may not overlap destinations. No transcript or credential contents belong in a manifest.
717
+
718
+ A physical `before` snapshot contains `device`, `inode`, `uid`, numeric permission `mode`, `bytes`, `mtimeNs`, `ctimeNs`, `parents` (absolute path/device/inode triples), and `sha256`. Profile `metadata` uses the same shape without `sha256`. Each alias is `{path, linkTarget, metadata}`, where `metadata` is the symlink's device/inode/uid/full mode/size/mtimeNs/ctimeNs tuple. The plan emits actual beforeimages for review; effect admission must independently establish the owner, complete source set, physical sharing and current generation. It cannot silently authorize another consumer sharing a file.
719
+
720
+ Only existing owned physical destinations inside the library can change. Shared desktop/backend config symlinks remain intact and their common file changes once. The generated private descriptor and isolated Python MCP launcher are operation-specific. The isolated MCP launcher validates successor bytes before import, then current profile/generation/preservation pins before replacing itself with the explicit Node/MCP pair and closed private environment. This retains the native host's PID, stdio and cancellation custody. Existing host agent names are retained as a closed wrapper argument. Managed default helper clients use the exact Node/CLI pair regardless of PATH. Unmanaged clients retain their ordinary behavior. No parent credential/service-origin/installation/Node environment is copied into those Topolo children. An intentional installation credential mode needs its own separately explicit contract.
721
+
722
+ Apply reserves `launchers/adoption-<UUID>.json` exclusively. The journal starts in `may-have-config-effects`, records each verified replacement, and becomes `configuration-prepared-native-adoption-unverified` only after all readbacks. Every write rechecks generation, source, profile metadata, preservation pins and destination custody. An interrupted or conflicting apply retains the journal and any uncertain temporary file; it never automatically replays or rolls back. Reconcile exact effects with the original owner.
723
+
724
+ No apply result proves native reload, identity, current RBAC, model/effort, loaded context, original-intake permission routing or a native tool result. Hosts may react to configuration changes; admit the actual affected owner/generation and normal request settlement before applying in a live host. Later ordinary startup can renew the private credential and load identity. A previous 429/403 or scoped recovery hold remains separately applicable and elapsed time grants no retry. Actual Codex and Claude same-query context, permission, nonce result, cancellation/settlement and own retirement require independent native evidence.
@@ -290,6 +290,9 @@ def parser():
290
290
  p.add_argument('--version',action='version',version=__version__)
291
291
  p.add_argument('--store',default=os.environ.get('CODEX_PACK_HOME',str(pathlib.Path.home()/'AgentPacks')))
292
292
  sub=p.add_subparsers(dest='command',required=True)
293
+ q=sub.add_parser('runtime-adoption',help='Configuration-only private runtime plan/apply; no consumer startup or authentication')
294
+ q.add_argument('operation',choices=['plan','apply']);q.add_argument('--manifest',required=True)
295
+ q.add_argument('--plan-digest');q.add_argument('--confirm',action='store_true')
293
296
  def selection(q):
294
297
  q.add_argument('--set',dest='sets',action='append');q.add_argument('--agent',dest='agents',action='append')
295
298
  def activation(q):
@@ -374,7 +377,7 @@ def main(argv=None):
374
377
  elif command_args and command_args[0].startswith('--store='):command_args=command_args[1:]
375
378
  lane_command=bool(command_args and command_args[0]=='lane')
376
379
  cloud_command=bool(command_args and command_args[0] in {'connect','remote-context','remember','artifact','artifact-get','remote-recover'})
377
- if harness == 'claude' and not policy_command and not cloud_command and not lane_command:
380
+ if harness == 'claude' and not policy_command and not cloud_command and not lane_command and not (command_args and command_args[0]=='runtime-adoption'):
378
381
  from . import claude
379
382
  return claude.main(argv)
380
383
  if harness not in {'codex', 'claude'}:
@@ -383,6 +386,11 @@ def main(argv=None):
383
386
  args=parser().parse_args(argv)
384
387
  args.harness=harness
385
388
  try:
389
+ if args.command=='runtime-adoption':
390
+ if harness!='codex':
391
+ raise PackError('Runtime adoption is one explicit cross-host owner manifest, not a harness override')
392
+ emit(live.private_runtime_adoption(args.store,args.manifest,args.operation,args.plan_digest,args.confirm))
393
+ return 0
386
394
  lib=Library(args.store)
387
395
  if args.command in {'connect','remote-context','remember','artifact','artifact-get','remote-recover'}:
388
396
  from . import topolo
@@ -1990,6 +1990,364 @@ def bind(lib,runtime):
1990
1990
  dest.symlink_to(expected)
1991
1991
 
1992
1992
 
1993
+ def adoption_require(value,message):
1994
+ if not value:raise PackError('Runtime adoption refused: '+message)
1995
+
1996
+
1997
+ def adoption_keys(value,keys):
1998
+ adoption_require(isinstance(value,dict) and set(value)==set(keys),'closed fields required: '+','.join(keys))
1999
+
2000
+
2001
+ def adoption_json(raw):
2002
+ def unique(pairs):
2003
+ adoption_require(len(pairs)==len({key for key,_ in pairs}),'duplicate JSON fields')
2004
+ return dict(pairs)
2005
+ return json.loads(raw,object_pairs_hook=unique)
2006
+
2007
+
2008
+ def adoption_snapshot(path,read=True,missing=False,profile=None):
2009
+ """Single-file custody. Credential profiles use read=False exclusively."""
2010
+ import stat
2011
+ if read and profile is not None:adoption_read_guard(profile,[path])
2012
+ path=pathlib.Path(path)
2013
+ adoption_require(path.is_absolute() and path==path.resolve(),'absolute physical path required')
2014
+ parents=[]
2015
+ for parent in path.parents:
2016
+ st=parent.lstat()
2017
+ adoption_require(stat.S_ISDIR(st.st_mode) and st.st_uid in {0,os.getuid()},'unowned parent')
2018
+ parents.append([str(parent),st.st_dev,st.st_ino])
2019
+ try:st=path.lstat()
2020
+ except FileNotFoundError:
2021
+ adoption_require(missing,'required file absent')
2022
+ return None,None
2023
+ adoption_require(stat.S_ISREG(st.st_mode) and st.st_nlink==1 and st.st_uid==os.getuid(),'owned single regular file required')
2024
+ value={'device':st.st_dev,'inode':st.st_ino,'uid':st.st_uid,'mode':st.st_mode&0o777,
2025
+ 'bytes':st.st_size,'mtimeNs':st.st_mtime_ns,'ctimeNs':st.st_ctime_ns,'parents':parents}
2026
+ raw=None
2027
+ if read:
2028
+ adoption_require(st.st_size<=128*1024*1024,'file exceeds limit')
2029
+ fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW)
2030
+ with os.fdopen(fd,'rb') as stream:
2031
+ opened=os.fstat(stream.fileno())
2032
+ adoption_require((opened.st_dev,opened.st_ino,opened.st_uid,opened.st_mode,opened.st_nlink,opened.st_size,opened.st_mtime_ns,opened.st_ctime_ns)==
2033
+ (st.st_dev,st.st_ino,st.st_uid,st.st_mode,st.st_nlink,st.st_size,st.st_mtime_ns,st.st_ctime_ns),'file changed before content read')
2034
+ raw=stream.read()
2035
+ after=path.lstat()
2036
+ adoption_require((after.st_dev,after.st_ino,after.st_size,after.st_mtime_ns,after.st_ctime_ns)==
2037
+ (st.st_dev,st.st_ino,st.st_size,st.st_mtime_ns,st.st_ctime_ns),'file changed while reading')
2038
+ value['sha256']=hashlib.sha256(raw).hexdigest()
2039
+ return value,raw
2040
+
2041
+
2042
+ def adoption_pin(value,profile=None):
2043
+ adoption_keys(value,['path','sha256'])
2044
+ snapshot,raw=adoption_snapshot(value['path'],profile=profile)
2045
+ adoption_require(snapshot['sha256']==value['sha256'],'source or preservation pin changed')
2046
+ return raw
2047
+
2048
+
2049
+ def adoption_profile(profile):
2050
+ adoption_keys(profile,['path','publicPath','metadata','environment','contextKey','principalId'])
2051
+ adoption_require(profile['environment'] in {'production','staging','development'} and
2052
+ isinstance(profile['contextKey'],str) and re.fullmatch(r'org:[A-Za-z0-9_-]+',profile['contextKey']) and
2053
+ isinstance(profile['principalId'],str) and re.fullmatch(r'[A-Za-z0-9_-]+',profile['principalId']), 'explicit private identity tuple required')
2054
+ current,_=adoption_snapshot(profile['path'],read=False)
2055
+ adoption_require(current==profile['metadata'] and current['mode']==0o600,'private profile metadata changed')
2056
+ public,_=adoption_snapshot(profile['publicPath'],read=False,missing=True)
2057
+ adoption_require(profile['path']!=profile['publicPath'] and (public is None or
2058
+ (public['device'],public['inode'])!=(current['device'],current['inode'])),'private/public profile alias')
2059
+
2060
+
2061
+ def adoption_read_guard(profile,paths):
2062
+ """Classify every declared readable input before any non-manifest content read."""
2063
+ adoption_profile(profile)
2064
+ protected=[(str(pathlib.Path(profile[key]).resolve()),adoption_snapshot(profile[key],read=False,missing=True)[0])
2065
+ for key in ['path','publicPath']]
2066
+ for path in paths:
2067
+ current,_=adoption_snapshot(path,read=False,missing=True)
2068
+ adoption_require(all(str(pathlib.Path(path).resolve())!=name and
2069
+ (current is None or saved is None or (current['device'],current['inode'])!=(saved['device'],saved['inode']))
2070
+ for name,saved in protected),'readable input aliases protected profile; no contents read')
2071
+
2072
+
2073
+ def adoption_runtime_paths(runtime):
2074
+ return [runtime[key]['path'] for key in ['node','cli','mcp']]+[pin['path'] for pin in runtime['cohort'].values()]+[pin['path'] for pin in runtime['adapterFiles']]
2075
+
2076
+
2077
+ def adoption_read_inputs(value):
2078
+ paths=[value['ownerGeneration']['path'],*adoption_runtime_paths(value['runtime']),*[pin['path'] for pin in value['preserve']],
2079
+ *[target['path'] for target in value.get('targets',[])]]
2080
+ if 'launcher' in value:paths.append(value['launcher']['path'])
2081
+ adoption_read_guard(value['profile'],paths)
2082
+
2083
+
2084
+ def adoption_runtime(runtime,profile):
2085
+ adoption_keys(runtime,['node','cli','mcp','cohort','pythonSource','adapterFiles','osEnvironment'])
2086
+ adoption_read_guard(profile,adoption_runtime_paths(runtime))
2087
+ for key in ['node','cli','mcp']:adoption_pin(runtime[key],profile)
2088
+ adoption_keys(runtime['cohort'],['cli','mcp','sdk'])
2089
+ packages={}
2090
+ for key,name in [('cli','@topolo/cli'),('mcp','@topolo/mcp'),('sdk','@topolo/sdk')]:
2091
+ package=json.loads(adoption_pin(runtime['cohort'][key],profile))
2092
+ adoption_require(package.get('name')==name,'immutable target cohort differs')
2093
+ packages[key]=package
2094
+ versions=tuple(packages[key].get('version') for key in ['cli','mcp','sdk'])
2095
+ # Reviewed consumer tuples are independent of this adapter driver's version.
2096
+ # Membership is coupled: independently allowed versions would admit mixtures.
2097
+ adoption_require(versions in (('0.13.11','0.14.26','0.23.21'),('0.13.12','0.14.27','0.23.22')),
2098
+ 'immutable target cohort differs')
2099
+ for key in ['cli','mcp']:
2100
+ adoption_require(runtime[key]['path']==str(pathlib.Path(runtime['cohort'][key]['path']).parent/'dist/index.js') and
2101
+ packages[key].get('dependencies',{}).get('@topolo/sdk')==versions[2],'entry/dependency cohort differs')
2102
+ source=pathlib.Path(runtime['pythonSource'])
2103
+ adoption_require(source.is_absolute() and source==source.resolve(),'physical adapter source required')
2104
+ adoption_require(isinstance(runtime['adapterFiles'],list) and len(runtime['adapterFiles'])==3 and
2105
+ {p['path'] for p in runtime['adapterFiles']}=={str(source/'topolo_agent_pack'/n) for n in ['cli.py','live.py','topolo.py']},'exact successor adapter source pins required')
2106
+ for value in runtime['adapterFiles']:adoption_pin(value,profile)
2107
+ env=runtime['osEnvironment']
2108
+ adoption_require(isinstance(env,dict) and {'HOME','PATH'}<=set(env)<=
2109
+ {'HOME','PATH','USER','LOGNAME','LANG','LC_ALL','TMPDIR'} and
2110
+ all(isinstance(v,str) and v and len(v)<=4096 and not any(ord(c)<32 for c in v) for v in env.values()),'closed nonsecret OS environment required')
2111
+ env=dict(env)
2112
+ env.update(TOPOLO_CONFIG_PATH=profile['path'],TOPOLO_ENV=profile['environment'],TOPOLO_CONTEXT=profile['contextKey'],
2113
+ TOPOLO_API_KEY='',TOPOLO_ACCESS_TOKEN='')
2114
+ prefix='TOPOLO_'+profile['environment'].upper()
2115
+ env.update({prefix+'_API_KEY':'',prefix+'_ACCESS_TOKEN':''})
2116
+ return env
2117
+
2118
+
2119
+ def adoption_alias(alias,target):
2120
+ adoption_keys(alias,['path','linkTarget','metadata']);p=pathlib.Path(alias['path']);st=p.lstat()
2121
+ value=[st.st_dev,st.st_ino,st.st_uid,st.st_mode,st.st_size,st.st_mtime_ns,st.st_ctime_ns]
2122
+ adoption_require(p.is_symlink() and os.readlink(p)==alias['linkTarget'] and str(p.resolve())==target and
2123
+ st.st_uid==os.getuid() and value==alias['metadata'],'shared alias changed')
2124
+
2125
+
2126
+ def adoption_config(raw,target,mcp_launcher):
2127
+ """Registration-only transform; never installs skills or rewrites context."""
2128
+ import tomllib
2129
+ adoption_require(target['format'] in {'json','toml'} and target['rootKey'] in {'mcp_servers','mcpServers'},'unsupported configuration format')
2130
+ config=adoption_json(raw) if target['format']=='json' else tomllib.loads(raw.decode())
2131
+ servers=config.get(target['rootKey'])
2132
+ adoption_require(isinstance(servers,dict),'existing server map required')
2133
+ names=[v['name'] for v in target['transports']]
2134
+ adoption_require(isinstance(target['untouchedServers'],list) and all(isinstance(n,str) for n in target['untouchedServers']) and
2135
+ len(set(target['untouchedServers']))==len(target['untouchedServers']) and
2136
+ not set(names)&set(target['untouchedServers']) and set(servers)==set(names)|set(target['untouchedServers']), 'incomplete physical server map')
2137
+ for transport in target['transports']:
2138
+ name=transport['name']
2139
+ adoption_require(isinstance(name,str) and re.fullmatch(r'[A-Za-z0-9_-]+',name) and isinstance(servers.get(name),dict),'unambiguous existing local transport required')
2140
+ entry=servers[name]
2141
+ adoption_require(isinstance(entry.get('command'),str) and 'url' not in entry and 'env_vars' not in entry,'local explicit transport required')
2142
+ adoption_require(not entry.get('env',{}).get('TOPOLO_AGENT_INSTALLATION_ID'),'installation mode requires a separate explicit adoption')
2143
+ agent=entry.get('env',{}).get('TOPOLO_AGENT_NAME','')
2144
+ adoption_require(isinstance(agent,str) and (not agent or re.fullmatch(r'[A-Za-z0-9_-]+',agent)),'agent name differs')
2145
+ entry['command']=mcp_launcher;entry['args']=[agent] if agent else [];entry['env']={'TOPOLO_AGENT_NAME':agent} if agent else {}
2146
+ if target['format']=='json':return (json.dumps(config,indent=2)+'\n').encode()
2147
+ text=raw.decode()
2148
+ for transport in target['transports']:
2149
+ name=transport['name'];entry=servers[name]
2150
+ # Qualified simple tables only. Quoted/dotted/array-table variants refuse;
2151
+ # they cannot silently coexist with a new duplicate registration.
2152
+ section='[mcp_servers.'+name+']';environment='[mcp_servers.'+name+'.env]'
2153
+ adoption_require(target['rootKey']=='mcp_servers' and text.splitlines().count(section)==1 and
2154
+ text.splitlines().count(environment)<=1,'unsupported TOML registration layout')
2155
+ kept=[];skipping=False
2156
+ for line in text.splitlines():
2157
+ if line.strip() in {section,environment}:skipping=True
2158
+ elif line.lstrip().startswith('['):
2159
+ adoption_require(not line.strip().startswith('[mcp_servers.'+name+'.'),'unsupported nested transport table')
2160
+ skipping=False
2161
+ if not skipping:kept.append(line)
2162
+ values=[]
2163
+ for key,value in entry.items():
2164
+ if key=='env':continue
2165
+ adoption_require(re.fullmatch(r'[A-Za-z0-9_-]+',key) and
2166
+ (isinstance(value,(str,bool,int)) or isinstance(value,list) and all(isinstance(v,str) for v in value)), 'unsupported TOML transport value')
2167
+ values.append(key+' = '+json.dumps(value))
2168
+ text='\n'.join(kept).rstrip()+'\n\n'+section+'\n'+'\n'.join(values)+'\n\n'+environment+'\n'+\
2169
+ '\n'.join(key+' = '+json.dumps(value) for key,value in entry['env'].items())+'\n'
2170
+ adoption_require(tomllib.loads(text)==config,'TOML semantic preservation differs')
2171
+ return text.encode()
2172
+
2173
+
2174
+ def adoption_bootstrap(source,descriptor,runtime):
2175
+ """Check successor bytes before importing them, including the verifier itself."""
2176
+ pins=[(p['path'],p['sha256']) for p in runtime['runtime']['adapterFiles']]
2177
+ pins.append((str(descriptor),hashlib.sha256(canonical(runtime)+b'\n').hexdigest()))
2178
+ return 'import hashlib,os,pathlib,stat,sys\nsys.dont_write_bytecode=True\nfor name,digest in '+repr(pins)+':\n'+\
2179
+ ' p=pathlib.Path(name);s=p.lstat()\n'+\
2180
+ ' if p!=p.resolve() or not stat.S_ISREG(s.st_mode) or s.st_uid!=os.getuid() or s.st_nlink!=1:raise SystemExit("Runtime source custody changed")\n'+\
2181
+ ' with p.open("rb") as f: actual=hashlib.file_digest(f,"sha256").hexdigest()\n'+\
2182
+ ' if actual!=digest:raise SystemExit("Runtime source changed before import")\n'+\
2183
+ 'sys.path.insert(0,'+repr(source)+')\n'
2184
+
2185
+
2186
+ def adoption_known_launcher(raw,root,profile=None):
2187
+ import ast
2188
+ lines=raw.decode().splitlines();tail=['from topolo_agent_pack.live import proxy_main','raise SystemExit(proxy_main())']
2189
+ if len(lines)==5 and lines[1]=='import sys' and lines[2].startswith('sys.path.insert(0,'):
2190
+ return lines[0].startswith('#!/') and lines[-2:]==tail
2191
+ assignments=[line for line in lines if line.startswith('os.environ["TOPOLO_AGENT_PACK_RUNTIME"]=')]
2192
+ if len(assignments)!=1:return False
2193
+ descriptor=pathlib.Path(ast.literal_eval(assignments[0].split('=',1)[1]))
2194
+ adoption_require(descriptor.parent==root/'launchers' and descriptor.name.startswith('private-runtime-'),'unknown managed launcher descriptor')
2195
+ _,value=adoption_snapshot(descriptor,profile=profile);runtime=json.loads(value)
2196
+ expected=lines[0]+'\n'+adoption_bootstrap(runtime['runtime']['pythonSource'],descriptor,runtime)+assignments[0]+'\n'+'\n'.join(tail)+'\n'
2197
+ return raw==expected.encode()
2198
+
2199
+
2200
+ def adoption_preflight(store,manifest):
2201
+ adoption_keys(manifest,['schemaVersion','operationId','library','ownerGeneration','consumers','profile','runtime','targets','launcher','preserve'])
2202
+ root=pathlib.Path(store)
2203
+ adoption_require(manifest['schemaVersion']==1 and type(manifest['schemaVersion']) is int and
2204
+ isinstance(manifest['operationId'],str) and re.fullmatch(r'[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}',manifest['operationId']) and
2205
+ root.is_absolute() and str(root)==manifest['library'] and root==root.resolve() and root.stat().st_uid==os.getuid(),'owned library/operation required')
2206
+ def inside(path):
2207
+ adoption_require(pathlib.Path(path).is_relative_to(root),'destination outside owned library')
2208
+ adoption_read_inputs(manifest)
2209
+ inside(manifest['ownerGeneration']['path']);adoption_pin(manifest['ownerGeneration'],manifest['profile'])
2210
+ consumers=manifest['consumers']
2211
+ adoption_require(isinstance(consumers,list) and 1<=len(consumers)<=256 and all(isinstance(c,str) and c for c in consumers) and len(set(consumers))==len(consumers),'closed unique consumer set required')
2212
+ adoption_profile(manifest['profile']);adoption_runtime(manifest['runtime'],manifest['profile'])
2213
+ descriptor=root/'launchers'/('private-runtime-'+manifest['operationId']+'.json')
2214
+ mcp_launcher=root/'launchers'/('private-mcp-'+manifest['operationId'])
2215
+ adoption_require(isinstance(manifest['preserve'],list) and 1<=len(manifest['preserve'])<=2048,'preservation pins required')
2216
+ for value in manifest['preserve']:inside(value['path']);adoption_pin(value,manifest['profile'])
2217
+ targets=manifest['targets'];adoption_require(isinstance(targets,list) and 1<=len(targets)<=256,'nonempty effective transport targets required')
2218
+ writes=[];seen=set();pairs=set()
2219
+ for target in targets:
2220
+ adoption_keys(target,['path','before','aliases','format','rootKey','consumers','transports','untouchedServers'])
2221
+ inside(target['path']);before,raw=adoption_snapshot(target['path'],profile=manifest['profile'])
2222
+ adoption_require(before==target['before'] and target['path'] not in seen,'target preimage changed or duplicated')
2223
+ seen.add(target['path'])
2224
+ adoption_require(isinstance(target['consumers'],list) and target['consumers'] and set(target['consumers'])<=set(consumers),'out-of-scope shared consumer')
2225
+ for alias in target['aliases']:
2226
+ inside(alias['path']);adoption_alias(alias,target['path'])
2227
+ adoption_require(isinstance(target['transports'],list) and target['transports'],'effective transport rows required')
2228
+ for transport in target['transports']:
2229
+ adoption_keys(transport,['name','origin','consumers'])
2230
+ pair=(target['path'],transport['name'])
2231
+ adoption_require(pair not in pairs and transport['origin']==target['path'] and isinstance(transport['consumers'],list) and
2232
+ transport['consumers'] and set(transport['consumers'])<=set(target['consumers']),'transport origin/consumer/precedence conflict')
2233
+ pairs.add(pair)
2234
+ writes.append((target['path'],before,adoption_config(raw,target,str(mcp_launcher))))
2235
+ adoption_require(set(consumers)=={c for t in targets for v in t['transports'] for c in v['consumers']},'incomplete effective consumer manifest')
2236
+ launcher=manifest['launcher'];adoption_keys(launcher,['path','before'])
2237
+ adoption_require(launcher['path']==str(root/'launchers/topolo-agent-pack-backend') and launcher['path'] not in seen,'exact owned launcher required')
2238
+ before,raw=adoption_snapshot(launcher['path'],profile=manifest['profile']);adoption_require(before==launcher['before'],'launcher preimage changed')
2239
+ lines=raw.decode().splitlines()
2240
+ adoption_require(adoption_known_launcher(raw,root,manifest['profile']),'unrecognized launcher source')
2241
+ current,_=adoption_snapshot(descriptor,missing=True,profile=manifest['profile']);adoption_require(current is None,'runtime descriptor already exists; reconcile without replay')
2242
+ current,_=adoption_snapshot(mcp_launcher,missing=True,profile=manifest['profile']);adoption_require(current is None,'MCP launcher already exists; reconcile without replay')
2243
+ runtime={'schemaVersion':1,'library':str(root),'ownerGeneration':manifest['ownerGeneration'],'profile':manifest['profile'],
2244
+ 'runtime':manifest['runtime'],'preserve':manifest['preserve']}
2245
+ writes.append((str(descriptor),None,canonical(runtime)+b'\n'))
2246
+ source=manifest['runtime']['pythonSource']
2247
+ adoption_require(' ' not in lines[0],'MCP wrapper requires an absolute Python shebang without flags')
2248
+ mcp_code=lines[0]+' -I\n'+adoption_bootstrap(source,descriptor,runtime)+'from topolo_agent_pack.topolo import managed_mcp_main\nraise SystemExit(managed_mcp_main('+repr(str(descriptor))+',sys.argv[1:]))\n'
2249
+ writes.append((str(mcp_launcher),None,mcp_code.encode()))
2250
+ code=lines[0]+'\n'+adoption_bootstrap(source,descriptor,runtime)+'os.environ["TOPOLO_AGENT_PACK_RUNTIME"]='+repr(str(descriptor))+'\nfrom topolo_agent_pack.live import proxy_main\nraise SystemExit(proxy_main())\n'
2251
+ writes.append((launcher['path'],before,code.encode()))
2252
+ protected={manifest['profile']['path'],manifest['profile']['publicPath'],manifest['ownerGeneration']['path'],*[p['path'] for p in manifest['preserve']]}
2253
+ protected.update(pin['path'] for pin in [manifest['runtime'][k] for k in ['node','cli','mcp']]+
2254
+ list(manifest['runtime']['cohort'].values())+manifest['runtime']['adapterFiles'])
2255
+ adoption_require(not protected&{p for p,_,_ in writes},'destination overlaps protected profile/generation/context')
2256
+ return writes
2257
+
2258
+
2259
+ def private_runtime_adoption(store,manifest_path,operation,plan_digest=None,confirm=False):
2260
+ try:return _private_runtime_adoption(store,manifest_path,operation,plan_digest,confirm)
2261
+ except (KeyError,TypeError,AttributeError,SyntaxError,UnicodeError) as error:
2262
+ raise PackError('Runtime adoption refused: malformed typed manifest or owned launcher') from error
2263
+
2264
+
2265
+ def _private_runtime_adoption(store,manifest_path,operation,plan_digest=None,confirm=False):
2266
+ """Owner-reviewed configuration transaction, NOT native/Auth admission.
2267
+
2268
+ The closed manifest is the owner's effect scope. It does not certify a native
2269
+ inventory, current grants, health, startup or a successful tool result.
2270
+ """
2271
+ adoption_require(operation in {'plan','apply'} and (operation=='apply' and confirm is True and
2272
+ isinstance(plan_digest,str) and re.fullmatch(r'[a-f0-9]{64}',plan_digest) or
2273
+ operation=='plan' and plan_digest is None and confirm is False),'closed plan/confirmed apply required')
2274
+ manifest_before,raw=adoption_snapshot(manifest_path);adoption_require(len(raw)<=2*1024*1024,'manifest exceeds limit')
2275
+ manifest=adoption_json(raw);writes=adoption_preflight(store,manifest)
2276
+ journal=pathlib.Path(store)/'launchers'/('adoption-'+manifest['operationId']+'.json')
2277
+ adoption_read_guard(manifest['profile'],[journal,*[p+'.'+manifest['operationId']+'.tmp' for p,_,_ in writes]])
2278
+ adoption_require(not journal.exists() and not journal.is_symlink(),'operation already reserved; reconcile without replay')
2279
+ plan={'schemaVersion':1,'operationId':manifest['operationId'],'manifestDigest':hashlib.sha256(raw).hexdigest(),
2280
+ 'writes':[{'path':p,'before':before,'afterDigest':hashlib.sha256(value).hexdigest()} for p,before,value in writes],
2281
+ 'claim':'CONFIGURATION_ONLY_NO_NATIVE_IDENTITY_HEALTH_PERMISSION_OR_ADOPTION_ACK'}
2282
+ digest=hashlib.sha256(canonical(plan)).hexdigest()
2283
+ if operation=='plan':return {**plan,'planDigest':digest}
2284
+ adoption_require(digest==plan_digest,'reviewed plan changed')
2285
+ profile=manifest['profile']
2286
+ adoption_require(adoption_snapshot(manifest_path,profile=profile)[0]==manifest_before,'manifest changed before reservation')
2287
+ from contextlib import ExitStack
2288
+ with ExitStack() as custody:
2289
+ parent_fd=os.open(journal.parent,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW);custody.callback(os.close,parent_fd)
2290
+ fd=os.open(journal.name,os.O_RDWR|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600,dir_fd=parent_fd);custody.callback(os.close,fd)
2291
+ def generation(st):
2292
+ return (st.st_dev,st.st_ino,st.st_uid,st.st_mode,st.st_nlink,st.st_size,st.st_mtime_ns,st.st_ctime_ns)
2293
+ def parent_identity(st):
2294
+ return (st.st_dev,st.st_ino,st.st_uid,st.st_mode)
2295
+ parent_generation=parent_identity(os.fstat(parent_fd));journal_generation=generation(os.fstat(fd))
2296
+ journal_parents=adoption_snapshot(journal,read=False)[0]['parents']
2297
+ record={'operationId':manifest['operationId'],'planDigest':digest,'state':'may-have-config-effects','completed':[]}
2298
+ def journal_guard():
2299
+ try:
2300
+ current=adoption_snapshot(journal,read=False)[0];held=generation(os.fstat(fd));named=generation(journal.lstat())
2301
+ parent=parent_identity(os.fstat(parent_fd));named_parent=parent_identity(journal.parent.lstat())
2302
+ except OSError as error:
2303
+ raise PackError('Runtime adoption refused: journal custody changed; may have config effects; retain evidence') from error
2304
+ adoption_require(held==named==journal_generation and held[4]==1 and current['mode']==0o600 and
2305
+ parent==named_parent==parent_generation and current['parents']==journal_parents,
2306
+ 'journal custody changed; may have config effects; retain evidence')
2307
+ def checkpoint():
2308
+ nonlocal journal_generation
2309
+ journal_guard();data=canonical(record)+b'\n'
2310
+ os.lseek(fd,0,os.SEEK_SET);os.ftruncate(fd,0)
2311
+ adoption_require(os.write(fd,data)==len(data),'journal checkpoint incomplete; retain evidence');os.fsync(fd)
2312
+ os.lseek(fd,0,os.SEEK_SET)
2313
+ adoption_require(os.read(fd,len(data)+1)==data,'journal checkpoint differs; retain evidence')
2314
+ updated=generation(os.fstat(fd))
2315
+ adoption_require(updated[:5]==journal_generation[:5],'journal descriptor generation changed; retain evidence')
2316
+ journal_generation=updated;journal_guard()
2317
+ def revalidate():
2318
+ adoption_read_inputs(manifest)
2319
+ adoption_pin(manifest['ownerGeneration'],profile);adoption_profile(profile);adoption_runtime(manifest['runtime'],profile)
2320
+ for keep in manifest['preserve']:adoption_pin(keep,profile)
2321
+ for completed in record['completed']:adoption_require(adoption_snapshot(completed['path'],profile=profile)[0]==completed['after'],'completed file changed')
2322
+ for target in manifest['targets']:
2323
+ for alias in target['aliases']:adoption_alias(alias,target['path'])
2324
+ adoption_require(adoption_snapshot(manifest_path,profile=profile)[0]==manifest_before,'manifest changed during apply')
2325
+ journal_guard()
2326
+ checkpoint()
2327
+ # Recheck the whole completed set at both sides of each replacement,
2328
+ # including the last launcher; prepared is never inferred from one file.
2329
+ for path,before,value in writes:
2330
+ revalidate()
2331
+ current,_=adoption_snapshot(path,missing=True,profile=profile);adoption_require(current==before,'destination changed before write')
2332
+ temporary=path+'.'+manifest['operationId']+'.tmp'
2333
+ mode=before['mode'] if before else (0o700 if path.endswith('/private-mcp-'+manifest['operationId']) else 0o600)
2334
+ adoption_read_guard(profile,[temporary])
2335
+ handle=os.open(temporary,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,mode)
2336
+ with os.fdopen(handle,'wb') as stream:
2337
+ temporary_identity=os.fstat(stream.fileno());os.fchmod(stream.fileno(),mode)
2338
+ stream.write(value);stream.flush();os.fsync(stream.fileno())
2339
+ revalidate()
2340
+ temp,actual=adoption_snapshot(temporary,profile=profile)
2341
+ adoption_require((temp['device'],temp['inode'])==(temporary_identity.st_dev,temporary_identity.st_ino) and actual==value,'temporary custody changed; retain journal')
2342
+ adoption_require(adoption_snapshot(manifest_path,profile=profile)[0]==manifest_before,'manifest changed during apply')
2343
+ current,_=adoption_snapshot(path,missing=True,profile=profile);adoption_require(current==before,'destination changed during write; retained temp and journal')
2344
+ journal_guard();os.replace(temporary,path)
2345
+ after,actual=adoption_snapshot(path,profile=profile);adoption_require(actual==value,'afterimage differs; retain journal')
2346
+ record['completed'].append({'path':path,'after':after});revalidate();checkpoint()
2347
+ revalidate();record['state']='configuration-prepared-native-adoption-unverified';checkpoint();journal_guard()
2348
+ return {'operationId':manifest['operationId'],'planDigest':digest,'state':record['state'],'journal':str(journal),'nativeACK':False}
2349
+
2350
+
1993
2351
  def compile_native_cli_command(value,store,pack,input_path,environment):
1994
2352
  """Compile a fixed native tool command; no shell/model text is accepted."""
1995
2353
  import shlex
@@ -5,6 +5,36 @@ from .storage import PackError,atomic_json,canonical,read_json,safe_id
5
5
  class ConnectionSourceRefused(PackError):
6
6
  pass
7
7
 
8
+ def managed_runtime_selection(path):
9
+ """Configuration custody only, never a health or native permission ACK."""
10
+ from . import live
11
+ before,raw=live.adoption_snapshot(path)
12
+ live.adoption_require(before['mode']==0o600 and len(raw)<=2*1024*1024,'private runtime descriptor required')
13
+ value=json.loads(raw)
14
+ live.adoption_keys(value,['schemaVersion','library','ownerGeneration','profile','runtime','preserve'])
15
+ live.adoption_require(value['schemaVersion']==1 and type(value['schemaVersion']) is int and
16
+ pathlib.Path(path).parent==pathlib.Path(value['library'])/'launchers','owned runtime descriptor location required')
17
+ live.adoption_read_inputs(value)
18
+ live.adoption_pin(value['ownerGeneration'],value['profile'])
19
+ env=live.adoption_runtime(value['runtime'],value['profile'])
20
+ for pin in value['preserve']:live.adoption_pin(pin,value['profile'])
21
+ live.adoption_require(live.adoption_snapshot(path,profile=value['profile'])[0]==before,'runtime descriptor changed')
22
+ return value,env
23
+
24
+
25
+ def managed_mcp_main(path,args=()):
26
+ from .live import adoption_require
27
+ adoption_require(len(args)<=1 and all(isinstance(v,str) and re.fullmatch(r'[A-Za-z0-9_-]+',v) for v in args),'closed MCP agent name required')
28
+ value,env=managed_runtime_selection(path)
29
+ if args:env['TOPOLO_AGENT_NAME']=args[0]
30
+ runtime=value['runtime']
31
+ # Only a later owner-admitted ordinary native startup invokes this wrapper.
32
+ # Credential resolution/refresh/identity are real private effects, not preflight.
33
+ # Replace this wrapper, retaining the native host's PID/stdin/cancellation
34
+ # custody instead of leaving an intermediate parent that can orphan MCP.
35
+ return os.execve(runtime['node']['path'],[runtime['node']['path'],runtime['mcp']['path']],env)
36
+
37
+
8
38
  class Client:
9
39
  def __init__(self,environment,context,binary=None,env=None,node=None):
10
40
  if environment not in {'development','staging','production'}:
@@ -12,6 +42,12 @@ class Client:
12
42
  if not context or any(c in context for c in '\n\r'):
13
43
  raise PackError('Explicit Topolo context required')
14
44
  selected=os.environ if env is None else env
45
+ descriptor=selected.get('TOPOLO_AGENT_PACK_RUNTIME')
46
+ if descriptor:
47
+ value,selected=managed_runtime_selection(descriptor)
48
+ if (environment!=value['profile']['environment'] or context!=value['profile']['contextKey'] or binary is not None or node is not None):
49
+ raise ConnectionSourceRefused('Managed private runtime tuple or explicit override differs')
50
+ binary=value['runtime']['cli']['path'];node=value['runtime']['node']['path']
15
51
  if any(selected.get(k) for k in ['TOPOLO_API_KEY','TOPOLO_ACCESS_TOKEN','TOPOLO_'+environment.upper()+'_API_KEY','TOPOLO_'+environment.upper()+'_ACCESS_TOKEN']):
16
52
  raise PackError('Pinned connections require the CLI protected account context, not injected credentials')
17
53
  self.environment=environment;self.context=context