@afu_context/afu-context 0.0.0-stage → 0.1.7

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.
@@ -0,0 +1,328 @@
1
+ """Install npm-hosted AFU extensions in Pi and DSH's normal discovery paths."""
2
+
3
+ import argparse
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from afu_context import native_install as fs
14
+ from afu_context.diagnostics import state_root
15
+ from afu_context.management import report, validate_policy
16
+
17
+
18
+ def run(*args, stdin=None):
19
+ result = subprocess.run(
20
+ args, input=stdin, capture_output=True, text=True, timeout=30
21
+ )
22
+ if result.returncode:
23
+ raise ValueError("Host configuration could not be loaded")
24
+ return result.stdout
25
+
26
+
27
+ def write_owned(file, content, marker):
28
+ if file.is_symlink():
29
+ raise ValueError("Refusing to replace an extension symlink")
30
+ if file.exists() and file.read_text() == content:
31
+ return
32
+ if file.exists() and not file.read_text().startswith(marker):
33
+ raise ValueError("Existing extension belongs to another installation")
34
+ fs.atomic(file, content)
35
+
36
+
37
+ def install_pi(root, adapter, settings, previous):
38
+ agent = (
39
+ Path(os.environ.get("PI_CODING_AGENT_DIR", "~/.pi/agent"))
40
+ .expanduser()
41
+ .resolve()
42
+ )
43
+ entry = agent / "extensions" / "afu-context.ts"
44
+ if previous and str(entry) != previous["entry"]:
45
+ raise ValueError(
46
+ "Remove the previous Pi agent registration before switching directories"
47
+ )
48
+ if (
49
+ entry.exists()
50
+ and previous
51
+ and hashlib.sha256(entry.read_bytes()).hexdigest() != previous["digest"]
52
+ ):
53
+ raise ValueError("Pi extension changed since installation; user file preserved")
54
+ marker = "// Managed by AFU " + root.as_uri() + "\n"
55
+ # The explicit launcher supplies its own extension; avoid installing hooks twice.
56
+ body = marker + "import register from " + json.dumps(str(adapter)) + ";\n"
57
+ body += "export default function(pi) {\n if (process.env.AFU_EXPLICIT_EXTENSION === 'pi' || (process.env.AFU_CONTEXT_COMPACT_WORKER_COMMAND && process.argv.some(a => a.endsWith('/afu_host_integrations/pi/dist/index.js')))) return;\n"
58
+ body += (
59
+ " Object.assign(process.env, "
60
+ + json.dumps(settings)
61
+ + ");\n register(pi);\n}\n"
62
+ )
63
+ write_owned(entry, body, marker)
64
+ return {"entry": str(entry), "digest": hashlib.sha256(body.encode()).hexdigest()}
65
+
66
+
67
+ def profile_manifest(binary, platform, profile):
68
+ args = [binary, "--profile", profile, "--dump-config"]
69
+ tree = run(*args)
70
+ info = json.loads(
71
+ run("node", platform / "scripts/dsh-web-probe.mjs", binary, profile)
72
+ )
73
+ return Path(info["profileDir"]) / "package.json", tree
74
+
75
+
76
+ def install_dsh(root, platform, adapter, settings, profile, previous):
77
+ binary = shutil.which("dsh")
78
+ manifest, tree = profile_manifest(binary, platform, profile)
79
+ if previous and previous["manifest"] != str(manifest):
80
+ raise ValueError(
81
+ "DSH profile directory changed; existing registration preserved"
82
+ )
83
+ profile_dir = manifest.parent
84
+ key = hashlib.sha256(str(profile_dir).encode()).hexdigest()[:20]
85
+ bundle_name = "afu-context-profile-" + key
86
+ bundle = root / "autoload" / key
87
+ entry = bundle / "provider.mjs"
88
+ link = profile_dir / "node_modules" / bundle_name
89
+ marker = "// Managed by AFU " + root.as_uri() + "\n"
90
+ original = manifest.read_text()
91
+ config = json.loads(original)
92
+ bundles = config["dsh"]["profile"]["bundles"]
93
+ deps = config.setdefault("dependencies", {})
94
+ owned_dep = "file:" + str(bundle)
95
+ if link.parent.is_symlink():
96
+ raise ValueError("Cannot register a bundle in a shared node_modules symlink")
97
+ if (link.is_symlink() and link.resolve() != bundle) or (
98
+ link.exists() and not link.is_symlink()
99
+ ):
100
+ raise ValueError("Existing DSH bundle directory conflicts with AFU")
101
+ if bundle_name in deps and deps[bundle_name] != owned_dep:
102
+ raise ValueError("DSH bundle dependency has been edited")
103
+ if bundle_name in bundles and deps.get(bundle_name) != owned_dep:
104
+ raise ValueError("DSH bundle is not owned by this installation")
105
+ bundle.mkdir(parents=True, exist_ok=True, mode=0o700)
106
+ # Bundle membership is read on host startup. Never edit watched cordis.patch.yml.
107
+ if bundle_name not in bundles:
108
+ patches = run(
109
+ "node",
110
+ platform / "scripts/dsh-overlay.mjs",
111
+ entry,
112
+ bundle,
113
+ binary,
114
+ stdin=tree,
115
+ )
116
+ preset = bundle / "presets.mjs"
117
+ if preset.exists():
118
+ old_arg = ", " + json.dumps(str(bundle)) + ");"
119
+ source = preset.read_text()
120
+ if old_arg not in source:
121
+ raise ValueError("Unsupported preset loader format")
122
+ cleanup = (
123
+ "import {mkdtempSync,rmSync} from 'node:fs';\n"
124
+ "import {tmpdir} from 'node:os';\nimport {join} from 'node:path';\n"
125
+ "const scratch=mkdtempSync(join(tmpdir(),'afu-dsh-presets-'));\n"
126
+ "process.once('exit',()=>rmSync(scratch,{recursive:true,force:true}));\n"
127
+ )
128
+ fs.atomic(preset, cleanup + source.replace(old_arg, ", scratch);"))
129
+ fs.atomic(bundle / "cordis.patch.yml", patches)
130
+ fs.write(
131
+ bundle / "package.json",
132
+ {
133
+ "name": bundle_name,
134
+ "version": "1.0.0",
135
+ "dsh": {"bundle": {"patch": "./cordis.patch.yml"}},
136
+ },
137
+ )
138
+ content = marker + "Object.assign(process.env," + json.dumps(settings) + ");\n"
139
+ content += (
140
+ "export default (await import(" + json.dumps(adapter.as_uri()) + ")).default;\n"
141
+ )
142
+ write_owned(entry, content, marker)
143
+ if not link.exists():
144
+ link.parent.mkdir(parents=True, exist_ok=True)
145
+ link.symlink_to(bundle, target_is_directory=True)
146
+ if bundle_name not in bundles:
147
+ bundles.append(bundle_name)
148
+ deps[bundle_name] = owned_dep
149
+ if manifest.read_text() != original:
150
+ raise ValueError("Concurrent DSH configuration change")
151
+ fs.write(manifest, config)
152
+ try:
153
+ composed = run(binary, "--profile", profile, "--dump-config")
154
+ if (
155
+ str(entry) not in composed
156
+ and str(bundle / "presets.mjs") not in composed
157
+ ):
158
+ raise ValueError("DSH did not resolve the AFU provider")
159
+ except Exception:
160
+ if fs.read(manifest) == config:
161
+ fs.atomic(manifest, original)
162
+ raise
163
+ return {"manifest": str(manifest), "bundle": str(bundle), "name": bundle_name}
164
+
165
+
166
+ def remove_record(host, record):
167
+ if host == "pi":
168
+ entry = Path(record["entry"])
169
+ if entry.exists():
170
+ if (
171
+ entry.is_symlink()
172
+ or hashlib.sha256(entry.read_bytes()).hexdigest() != record["digest"]
173
+ ):
174
+ raise ValueError(
175
+ "Pi extension changed since installation; user file preserved"
176
+ )
177
+ entry.unlink()
178
+ return
179
+ manifest, name = Path(record["manifest"]), record["name"]
180
+ config = fs.read(manifest)
181
+ if not config:
182
+ return
183
+ bundles = config["dsh"]["profile"]["bundles"]
184
+ deps = config.get("dependencies", {})
185
+ if name in deps and deps[name] != "file:" + record["bundle"]:
186
+ raise ValueError(
187
+ "DSH dependency changed since installation; user file preserved"
188
+ )
189
+ if name in bundles and name not in deps:
190
+ raise ValueError("DSH bundle ownership changed; user configuration preserved")
191
+ if name in bundles:
192
+ bundles.remove(name)
193
+ deps.pop(name, None)
194
+ fs.write(manifest, config)
195
+ # Keep bundle files for running sessions (some presets resolve lazily).
196
+ link = manifest.parent / "node_modules" / name
197
+ if link.is_symlink() and link.resolve() == Path(record["bundle"]):
198
+ link.unlink()
199
+
200
+
201
+ def main():
202
+ p = argparse.ArgumentParser()
203
+ p.add_argument("action", choices=["install", "upgrade", "remove", "status"])
204
+ p.add_argument("host", choices=["pi", "dsh"], nargs="?")
205
+ p.add_argument("--profile")
206
+ p.add_argument("--state", type=Path)
207
+ p.add_argument("--ratio")
208
+ p.add_argument("--context-window")
209
+ p.add_argument("--json", action="store_true")
210
+ args = p.parse_args()
211
+ root = (args.state or state_root()).expanduser().resolve()
212
+ host = args.host
213
+ if args.profile and (
214
+ host != "dsh"
215
+ or not re.fullmatch(r"[\w-]+", args.profile)
216
+ or args.profile == "node_modules"
217
+ ):
218
+ p.error("invalid DSH profile")
219
+ key = args.profile or ("web" if host == "dsh" else "user")
220
+ if args.action == "status":
221
+ data = report(host, root=root)
222
+ for install in data["installations"]:
223
+ name = install["host"]
224
+ receipt = fs.read(root / "integrations" / (name + ".json"))
225
+ if receipt.get("autoload"):
226
+ install.update(
227
+ mode="host",
228
+ launch=name if name == "pi" else "dsh --profile PROFILE",
229
+ registrations=sorted(receipt["autoload"]),
230
+ )
231
+ print(json.dumps(data))
232
+ return
233
+ path = root / "integrations" / (host + ".json")
234
+ with fs.lock(path.parent):
235
+ old = fs.read(path)
236
+ registrations = dict(old.get("autoload", {}))
237
+ previous = registrations.get(key, {})
238
+ if args.action == "remove":
239
+ for item in [key] if args.profile or host == "pi" else list(registrations):
240
+ if item in registrations:
241
+ remove_record(host, registrations.pop(item))
242
+ fs.write(
243
+ path,
244
+ {
245
+ **old,
246
+ "host": host,
247
+ "autoload": registrations,
248
+ "removed": not registrations,
249
+ },
250
+ )
251
+ else:
252
+ platform = Path(os.environ["AFU_INSTALL_ROOT"]).resolve()
253
+ adapter = platform / "src/afu_host_integrations" / host / "dist/index.js"
254
+ if not adapter.is_file():
255
+ raise ValueError("Adapter not installed")
256
+
257
+ def policy(raw, field, cast):
258
+ if raw is None:
259
+ return previous.get(
260
+ field, old.get(field) if not registrations else None
261
+ )
262
+ return None if raw == "auto" else cast(raw)
263
+
264
+ ratio = policy(args.ratio, "ratio", float)
265
+ window = policy(args.context_window, "window", int)
266
+ validate_policy(window, ratio)
267
+ if window and ratio and int(window * ratio) < 1:
268
+ raise ValueError("The configured threshold must be at least one token")
269
+ settings = {
270
+ "AFU_CONTEXT_COMPACT_WORKER_COMMAND": sys.executable,
271
+ "AFU_CONTEXT_COMPACT_WORKER_ARGS": json.dumps(
272
+ [
273
+ "-m",
274
+ "afu_context",
275
+ "compact",
276
+ "worker",
277
+ "--factory",
278
+ "afu_context.local_worker:create_plugin",
279
+ ]
280
+ ),
281
+ "AFU_CONTEXT_COMPACT_WORKER_CWD": str(platform),
282
+ "AFU_INSTALL_ROOT": str(platform),
283
+ "AFU_STATE_DIR": str(root),
284
+ "AFU_HOST_CONTEXT_WINDOW_OVERRIDE": str(window or "auto"),
285
+ }
286
+ if ratio is not None:
287
+ settings["AFU_TRIGGER_RATIO"] = str(ratio)
288
+ if host == "pi":
289
+ record = install_pi(root, adapter, settings, previous)
290
+ else:
291
+ record = install_dsh(root, platform, adapter, settings, key, previous)
292
+ registrations[key] = {**record, "window": window, "ratio": ratio}
293
+ fs.write(
294
+ path,
295
+ {
296
+ **old,
297
+ "host": host,
298
+ "mode": "host",
299
+ "removed": False,
300
+ "window": window,
301
+ "ratio": ratio,
302
+ "build": fs.read(platform / "release.json")["build"],
303
+ "autoload": registrations,
304
+ },
305
+ )
306
+ print(
307
+ json.dumps(
308
+ {
309
+ "host": host,
310
+ "installed": bool(registrations),
311
+ "upgraded": args.action == "upgrade",
312
+ "mode": "host",
313
+ "registrations": sorted(registrations),
314
+ "launch": "pi" if host == "pi" else "dsh --profile " + key,
315
+ }
316
+ )
317
+ )
318
+
319
+
320
+ if __name__ == "__main__":
321
+ try:
322
+ main()
323
+ except (OSError, ValueError, KeyError, subprocess.TimeoutExpired) as error:
324
+ sys.exit(
325
+ "AFU registration failed: " + str(error)
326
+ if type(error) is ValueError
327
+ else "AFU registration failed: " + type(error).__name__
328
+ )
@@ -0,0 +1,34 @@
1
+ import {spawnSync} from 'node:child_process';
2
+ import {accessSync,statSync,constants,mkdirSync,rmSync,mkdtempSync} from 'node:fs';
3
+ import {join,delimiter,resolve} from 'node:path';
4
+ import {homedir} from 'node:os';
5
+ import {createInterface} from 'node:readline/promises';
6
+ export function hostAvailable(host,env=process.env){
7
+ return (env.PATH??env.Path??'').split(delimiter).some(dir=>['',...(process.platform==='win32'?['.exe','.cmd']:[])].some(ext=>{try{accessSync(join(dir,host+ext),constants.X_OK);return statSync(join(dir,host+ext)).isFile();}catch{return false;}}));
8
+ }
9
+ export async function guide(args,env=process.env){
10
+ if(args[0]!=='install'&&args[0]!=='upgrade')return args;
11
+ const hosts=['claude','codex','pi','dsh'];let host=hosts.includes(args[1])?args[1]:null;
12
+ if(!host){
13
+ const available=hosts.filter(h=>hostAvailable(h,env));
14
+ if(!available.length)throw Error('HOST_NOT_FOUND: install and sign into your host first');
15
+ if(available.length===1)host=available[0];
16
+ else{
17
+ if(!process.stdin.isTTY)throw Error('HOST_SELECTION_REQUIRED: use install '+available.join('|'));
18
+ const input=createInterface({input:process.stdin,output:process.stderr});
19
+ try{console.error(available.map((h,i)=>`${i+1}. ${h}`).join('\n'));host=available[Number(await input.question('Select host: '))-1];}finally{input.close();}
20
+ if(!host)throw Error('INVALID_HOST_SELECTION');
21
+ }
22
+ args=[args[0],host,...args.slice(1)];
23
+ }
24
+ if(!hostAvailable(host,env))throw Error('HOST_NOT_FOUND: '+host);
25
+ if(process.platform==='win32'&&host==='codex')throw Error('WINDOWS_CODEX_NATIVE_UNSUPPORTED');
26
+ const at=args.indexOf('--state');const state=at>=0?resolve(args[at+1]):env.AFU_STATE_DIR??join(homedir(),'.local/share/afu-context');
27
+ mkdirSync(state,{recursive:true,mode:0o700});const check=mkdtempSync(join(state,'.write-check-'));rmSync(check,{recursive:true});
28
+ if(['claude','codex'].includes(host)&&!args.includes('--no-autostart')){
29
+ const probe=process.platform==='linux'?['systemctl',['--user','show-environment']]:process.platform==='darwin'?['launchctl',['print',`gui/${process.getuid()}`]]:null;
30
+ if(probe&&spawnSync(probe[0],probe[1],{env,stdio:'ignore',timeout:5000}).status!==0)throw Error(process.platform==='linux' ? `USER_SERVICE_UNAVAILABLE: no host settings changed. Enable your systemd user service, or rerun the same command with --no-autostart (example: afu ${args[0]} ${host} --no-autostart). Keep your existing --config-dir/--state and other options. Manual mode starts the gateway now but does not register login/reboot recovery; rerun it after reboot before using the host.` : 'USER_SERVICE_UNAVAILABLE: no host settings changed; enable your user service manager first');
31
+ }
32
+ console.error(`AFU: preflight passed for ${host}; preparing private runtime`);
33
+ return args;
34
+ }
@@ -0,0 +1,19 @@
1
+ /** Bounded child lifetime, restricted to the process group created by this call. */
2
+ import {spawn} from 'node:child_process';
3
+ export function runInstall(command,args,env=process.env,onChild=()=>{}){
4
+ const ms=Number(env.AFU_INSTALL_TIMEOUT_MS??180000);
5
+ if(!Number.isFinite(ms)||ms<1||ms>600000)throw Error('INVALID_INSTALL_TIMEOUT');
6
+ return new Promise((resolve,reject)=>{
7
+ const child=spawn(command,args,{env,stdio:['inherit',2,2],shell:false,detached:process.platform!=='win32'});
8
+ let reason=null,killTimer;
9
+ const signal=s=>{if(!child.pid||child.exitCode!==null)return;try{process.platform==='win32'?child.kill(s):process.kill(-child.pid,s);}catch(e){if(e.code!=='ESRCH')throw e;}};
10
+ const cancel=code=>{if(reason)return;reason=code;signal('SIGTERM');killTimer=setTimeout(()=>signal('SIGKILL'),2000);};
11
+ const interrupted=()=>cancel('INSTALL_INTERRUPTED');
12
+ const timer=setTimeout(()=>cancel('INSTALL_TIMEOUT'),ms);
13
+ process.on('SIGINT',interrupted);process.on('SIGTERM',interrupted);
14
+ const cleanup=()=>{clearTimeout(timer);clearTimeout(killTimer);process.off('SIGINT',interrupted);process.off('SIGTERM',interrupted);};
15
+ child.once('error',()=>{cleanup();reject(Error('INSTALL_EXECUTABLE_UNAVAILABLE'));});
16
+ child.once('exit',code=>{cleanup();code===0&&!reason?resolve():reject(Error(reason??'INSTALL_FAILED_CHECK_DOWNLOAD_LOG'));});
17
+ try{if(child.pid)onChild(child.pid);}catch{cancel('INSTALL_OWNERSHIP_LOST');}
18
+ });
19
+ }
@@ -0,0 +1,74 @@
1
+ import {obtainPack} from './prebuilt.mjs';
2
+ import {runInstall} from './install-process.mjs';
3
+ /** Host-only dependencies live outside the installed npm package. */
4
+ import {mkdirSync,readFileSync,writeFileSync,existsSync,rmSync,cpSync,renameSync,realpathSync} from 'node:fs';
5
+ import {join,dirname,delimiter} from 'node:path';
6
+ import {homedir} from 'node:os';
7
+
8
+ import {spawnSync} from 'node:child_process';
9
+ import {pathToFileURL} from 'node:url';
10
+ import {randomUUID} from 'node:crypto';
11
+ import {recoverLock} from './runtime.mjs';
12
+ const read=p=>{try{return JSON.parse(readFileSync(p,'utf8'));}catch{return null;}};
13
+ export function npmInvocation(env=process.env,platform=process.platform){
14
+ if(platform!=='win32')return ['npm',[]];
15
+ const candidates=[join(dirname(process.execPath),'node_modules/npm/bin/npm-cli.js'),
16
+ ...String(env.PATH??env.Path??'').split(delimiter).map(p=>join(p,'node_modules/npm/bin/npm-cli.js'))];
17
+ const cli=candidates.find(p=>existsSync(p));
18
+ if(!cli)throw Error('Cannot locate npm-cli.js; install Node.js with npm');
19
+ return [process.execPath,[cli]];
20
+ }
21
+ export async function preparePlatform(root,manifest,args,env=process.env){
22
+ const host=['install','upgrade'].includes(args[0])?args[1]:args[0];
23
+ if(!['pi','dsh'].includes(host))return root;
24
+ const dependencies=read(join(root,'platform-dependencies.json'))?.[host];
25
+ if(!dependencies)throw Error('Missing platform dependency manifest');
26
+ const base=env.AFU_RUNTIME_DIR??join(homedir(),'.local/share/afu-context/runtimes');
27
+ const canonical=join(base,manifest.build+'-'+host),pointer=canonical+'.location.json',lock=canonical+'.lock',token=randomUUID();
28
+ let target=canonical;
29
+ const refresh=()=>{const name=read(pointer)?.name;target=typeof name==='string'&&name.startsWith(manifest.build+'-'+host)&&!/[\\/]/.test(name)?join(base,name):canonical;};
30
+ refresh();
31
+ const ready=()=>read(join(target,'platform-ready.json'))?.build===manifest.build && Object.keys(dependencies).every(name=>existsSync(join(target,'node_modules',name,'package.json'))) && spawnSync(process.execPath,['--input-type=module','-e','await import(process.argv[1])',pathToFileURL(join(target,'src/afu_host_integrations',host,'dist/index.js')).href],{env,stdio:'ignore',timeout:10000}).status===0;
32
+ mkdirSync(base,{recursive:true,mode:0o700});
33
+ const deadline=Date.now()+180000;
34
+ while(true){
35
+ refresh();if(ready())return target;
36
+ try{mkdirSync(lock);writeFileSync(join(lock,'owner.json'),JSON.stringify({pid:process.pid,token}));break;}
37
+ catch(e){if(e.code!=='EEXIST')throw e;}
38
+ recoverLock(lock);if(Date.now()>deadline)throw Error('Platform installation still pending');
39
+ await new Promise(r=>setTimeout(r,300));
40
+ }
41
+ const owns=()=>read(join(lock,'owner.json'))?.token===token;
42
+ if(!owns())return target;
43
+ let createdTarget;
44
+ try{
45
+ refresh();if(ready())return target;
46
+ // Never repair in place: an older host may still import files from this tree.
47
+ target=canonical+'-generation-'+randomUUID();
48
+ createdTarget=target;
49
+ mkdirSync(target,{recursive:true,mode:0o700});
50
+ for(const name of ['src/afu_host_integrations','scripts'])cpSync(join(root,name),join(target,name),{recursive:true});
51
+ cpSync(join(root,'release.json'),join(target,'release.json'));
52
+ cpSync(join(root,'platform-locks',host,'package.json'),join(target,'package.json'));
53
+ cpSync(join(root,'platform-locks',host,'package-lock.json'),join(target,'package-lock.json'));
54
+ // COSEC: fixed package names/versions from the release, no shell interpolation.
55
+ const [executable,prefix]=npmInvocation(env);
56
+ if(manifest.prebuilt && process.platform!=='win32'){
57
+ const payload=await obtainPack(root,manifest,host,env,pid=>writeFileSync(join(lock,'owner.json'),JSON.stringify({pid:process.pid,childPid:pid,token}),{mode:0o600}));
58
+ cpSync(join(payload,'node_modules'),join(target,'node_modules'),{recursive:true,verbatimSymlinks:true});
59
+ }else {
60
+ // npm treats a Windows junction alias as a linked package. Use its real
61
+ // prefix so npm ci validates the shipped lock against the actual root.
62
+ const installTarget=process.platform==='win32'?realpathSync(target):target;
63
+ await runInstall(executable,[...prefix,'ci','--registry=https://registry.npmjs.org','--prefix',installTarget,'--ignore-scripts','--no-audit','--no-fund'],env,pid=>writeFileSync(join(lock,'owner.json'),JSON.stringify({pid:process.pid,childPid:pid,token})));
64
+ }
65
+ // Import the real adapters: a package.json marker alone misses broken files
66
+ // and corrupt transitive dependencies. No model request is made by imports.
67
+ await runInstall(process.execPath,['--input-type=module','-e','await import(process.argv[1])',pathToFileURL(join(target,'src/afu_host_integrations',host,'dist/index.js')).href],env);
68
+ writeFileSync(join(target,'platform-ready.json'),JSON.stringify({build:manifest.build,cache_protocol:1}),{mode:0o600});
69
+ const temp=pointer+'.'+token;writeFileSync(temp,JSON.stringify({name:target.slice(base.length+1)}),{mode:0o600});renameSync(temp,pointer);return target;
70
+ }catch(error){
71
+ if(owns()&&createdTarget)rmSync(createdTarget,{recursive:true,force:true});
72
+ throw error;
73
+ }finally{if(owns())rmSync(lock,{recursive:true,force:true});}
74
+ }
@@ -0,0 +1,64 @@
1
+ /** Release-owned platform packs, copied only after manifest verification. */
2
+ import {createRequire} from 'node:module';
3
+ import {readFileSync,existsSync,lstatSync,realpathSync,readdirSync,mkdirSync,mkdtempSync,renameSync,rmSync,writeFileSync} from 'node:fs';
4
+ import {join,resolve,sep} from 'node:path';
5
+ import {createHash} from 'node:crypto';
6
+ import {homedir} from 'node:os';
7
+ import {runInstall} from './install-process.mjs';
8
+ export function packName(kind){
9
+ if(!['runtime','pi','dsh'].includes(kind))throw Error('INVALID_PACK_KIND');
10
+ if(!['darwin','linux','win32'].includes(process.platform)||!['arm64','x64'].includes(process.arch))throw Error('UNSUPPORTED_PLATFORM');
11
+ return `afu-context-${kind}-${process.platform}-${process.arch}`;
12
+ }
13
+ export function verifyPack(folder,manifest,kind){
14
+ const info=JSON.parse(readFileSync(join(folder,'pack-manifest.json'),'utf8'));
15
+ if(info.build!==manifest.build||info.kind!==kind||info.platform!==process.platform||info.arch!==process.arch)throw Error('PLATFORM_PACK_MISMATCH');
16
+ const root=realpathSync(folder);
17
+ const listed=new Set(Object.keys(info.files));
18
+ function walk(dir){for(const entry of readdirSync(dir,{withFileTypes:true})){const file=join(dir,entry.name);if(entry.isDirectory())walk(file);else {const name=file.slice(root.length+1).split(sep).join('/');if(!listed.has(name))throw Error('PACK_UNLISTED_FILE');if(lstatSync(file).isSymbolicLink()&&lstatSync(realpathSync(file)).isDirectory())throw Error('PACK_DIRECTORY_LINK');}}}
19
+ walk(join(root,'payload'));
20
+ // COSEC: signed npm integrity plus per-file release hashes; forbid escaping
21
+ // paths/symlinks before copying any executable into the private runtime.
22
+ for(const [name,hash] of Object.entries(info.files)){
23
+ const file=resolve(root,name);
24
+ if(!file.startsWith(root+sep)||!realpathSync(file).startsWith(root+sep))throw Error('PACK_PATH_ESCAPE');
25
+ const stat=lstatSync(file);
26
+ const actual=createHash('sha256').update(readFileSync(file)).digest('hex');
27
+ if(!stat.isFile()&&!stat.isSymbolicLink()||actual!==hash)throw Error('PACK_INTEGRITY_FAILED');
28
+ }
29
+ if(!info.files['payload/'+(kind==='runtime'?'bin/python3':'package.json')])throw Error('PACK_ENTRY_MISSING');
30
+ return join(folder,'payload');
31
+ }
32
+ export async function obtainPack(root,manifest,kind,env=process.env,onChild=()=>{}){
33
+ const name=packName(kind);let folder;
34
+ if(env.AFU_BUNDLE_DIR)folder=join(resolve(env.AFU_BUNDLE_DIR),name);
35
+ else{
36
+ try{folder=createRequire(join(root,'package.json')).resolve(name+'/package.json').replace(/[/\\]package.json$/,'');}catch{}
37
+ if(!folder){
38
+ const base=resolve(env.AFU_RUNTIME_DIR??join(homedir(),'.local/share/afu-context/runtimes'),'downloads',manifest.build+'-'+kind);
39
+ // COSEC: only release-owned sibling generations can be selected by this pointer.
40
+ let cached=base;
41
+ try{const selected=JSON.parse(readFileSync(base+'.json','utf8')).generation;
42
+ if(typeof selected==='string'&&selected.startsWith(manifest.build+'-'+kind+'-')&&!/[\\/]/.test(selected))cached=join(resolve(base,'..'),selected);
43
+ }catch{}
44
+ try{return verifyPack(join(cached,'node_modules',name),manifest,kind);}catch{}
45
+ mkdirSync(resolve(base,'..'),{recursive:true,mode:0o700});
46
+ const stage=mkdtempSync(base+'-');let committed=false;
47
+ try{
48
+ console.error(`AFU: downloading ${kind} pack for ${process.platform}/${process.arch}`);
49
+ // npm retries transient fetch failures and validates its content-addressed cache.
50
+ await runInstall('npm',['install','--prefix',stage,'--registry=https://registry.npmjs.org','--ignore-scripts','--no-audit','--no-fund','--package-lock=false','--fetch-retries=2','--fetch-retry-mintimeout=1000','--fetch-retry-maxtimeout=5000','--fetch-timeout=30000',name+'@'+manifest.version],env,onChild);
51
+ folder=join(stage,'node_modules',name);
52
+ const payload=verifyPack(folder,manifest,kind);
53
+ const temporary=stage+'.json';
54
+ writeFileSync(temporary,JSON.stringify({generation:stage.slice(resolve(base,'..').length+1)}),{mode:0o600});
55
+ renameSync(temporary,base+'.json');committed=true;
56
+ return payload;
57
+ }finally{
58
+ // Never delete a previously usable pack; only this attempt's unpublished files.
59
+ if(!committed){rmSync(stage,{recursive:true,force:true});rmSync(stage+'.json',{force:true});}
60
+ }
61
+ }
62
+ }
63
+ return verifyPack(folder,manifest,kind);
64
+ }
@@ -0,0 +1,111 @@
1
+ import {obtainPack} from './prebuilt.mjs';
2
+ import {cpSync} from 'node:fs';
3
+ import {runInstall} from './install-process.mjs';
4
+ /** Isolated, recoverable bootstrap. Never touches host integrations. */
5
+ import {spawnSync} from 'node:child_process';
6
+ import {existsSync,mkdirSync,readFileSync,writeFileSync,rmSync,statSync,renameSync} from 'node:fs';
7
+ import {join,resolve,dirname,basename} from 'node:path';
8
+ import {homedir} from 'node:os';
9
+ import {randomUUID} from 'node:crypto';
10
+ const sleep=ms=>new Promise(r=>setTimeout(r,ms));
11
+ const read=p=>{try{return JSON.parse(readFileSync(p,'utf8'));}catch{return null;}};
12
+ const alive=pid=>{if(!Number.isSafeInteger(pid)||pid<=0)return false;try{process.kill(pid,0);return true;}catch(e){return e.code!=='ESRCH';}};
13
+ export function recoverLock(lock, now=Date.now()) {
14
+ // Serialize recovery contenders inside the old lock. Never steal a living owner.
15
+ const guard=join(lock,'recover');
16
+ let age;
17
+ try{age=now-(statSync(lock).birthtimeMs || statSync(lock).mtimeMs);}catch{return false;}
18
+ try{mkdirSync(guard);writeFileSync(join(guard,'owner.json'),JSON.stringify({pid:process.pid}));}
19
+ catch{
20
+ try {
21
+ const reaper=read(join(guard,'owner.json'));
22
+ if(reaper ? !alive(reaper.pid) : now-statSync(guard).mtimeMs>180000)rmSync(guard,{recursive:true,force:true});
23
+ } catch {}
24
+ return false;
25
+ }
26
+ let moved=false;
27
+ try{
28
+ const owner=read(join(lock,'owner.json'));
29
+ if(owner && (alive(owner.pid)||alive(owner.childPid)))return false;
30
+ // Missing/partial ownership may be a just-created lock, not a crashed install.
31
+ if(!owner && age<180000)return false;
32
+ const tomb=lock+'.abandoned-'+randomUUID();
33
+ renameSync(lock,tomb);moved=true;rmSync(tomb,{recursive:true,force:true});return true;
34
+ }catch{return false;}finally{if(!moved)rmSync(guard,{recursive:true,force:true});}
35
+ }
36
+ export function runtimeReady(location, build) {
37
+ const marker=read(join(location,'afu-ready.json'));
38
+ const python=join(location,process.platform==='win32'?'Scripts/python.exe':'bin/python');
39
+ if(marker?.build!==build || !existsSync(python))return false;
40
+ // A surviving marker is not proof that imports and the interpreter still work.
41
+ return spawnSync(python,['-c','import afu_context.local_cli, aiohttp, tiktoken'],{stdio:'ignore',timeout:10000}).status===0;
42
+ }
43
+ export async function prepareRuntime(root,manifest,env=process.env) {
44
+ const base=env.AFU_RUNTIME_DIR ?? join(homedir(),'.local/share/afu-context/runtimes');
45
+ const location=join(base,manifest.build), lock=location+'.lock';
46
+ const token=randomUUID();
47
+ const current=()=>{
48
+ const pointer=read(join(base,manifest.build+'.location.json'))?.location;
49
+ // COSEC: a cache pointer may select only this build's sibling repair directories.
50
+ if(typeof pointer==='string' && dirname(resolve(pointer))===resolve(base) && basename(pointer).startsWith(manifest.build+'-repair-'))return pointer;
51
+ return location;
52
+ };
53
+ mkdirSync(base,{recursive:true,mode:0o700});
54
+ if(runtimeReady(current(),manifest.build))return current();
55
+ const deadline=Date.now()+180000;
56
+ while(true){
57
+ try{mkdirSync(lock,{mode:0o700});writeFileSync(join(lock,'owner.json'),JSON.stringify({pid:process.pid,token}),{mode:0o600});break;}
58
+ catch(e){if(e.code!=='EEXIST')throw e;}
59
+ recoverLock(lock);
60
+ if(runtimeReady(current(),manifest.build))return current();
61
+ if(Date.now()>deadline)throw Error('Runtime installation is still owned by another process: '+lock);
62
+ await sleep(300);
63
+ }
64
+ const owns=()=>read(join(lock,'owner.json'))?.token===token;
65
+ const run=async(command,args)=>{
66
+ if(!owns())throw Error('Runtime installation ownership changed');
67
+ try{await runInstall(command,args,env,pid=>writeFileSync(join(lock,'owner.json'),JSON.stringify({pid:process.pid,childPid:pid,token}),{mode:0o600}));}
68
+ finally{if(owns())writeFileSync(join(lock,'owner.json'),JSON.stringify({pid:process.pid,token}),{mode:0o600});}
69
+ };
70
+ let createdTarget;
71
+ try{
72
+ if(runtimeReady(current(),manifest.build))return current();
73
+ if(manifest.prebuilt && process.platform!=='win32'){
74
+ const payload=await obtainPack(root,manifest,'runtime',env,pid=>writeFileSync(join(lock,'owner.json'),JSON.stringify({pid:process.pid,childPid:pid,token}),{mode:0o600}));
75
+ const target=existsSync(location)?location+'-repair-'+randomUUID():location;
76
+ createdTarget=target;
77
+ cpSync(payload,target,{recursive:true,verbatimSymlinks:true});
78
+ const python=join(target,'bin/python');
79
+ await run(python,['-c','import afu_context.local_cli, aiohttp, tiktoken']);
80
+ writeFileSync(join(target,'afu-release.json'),JSON.stringify(manifest),{mode:0o600});
81
+ writeFileSync(join(target,'afu-ready.json'),JSON.stringify({build:manifest.build,cache_protocol:1}),{mode:0o600});
82
+ if(target!==location){const pointer=join(base,manifest.build+'.location.json'),temp=pointer+'.'+token;writeFileSync(temp,JSON.stringify({location:target}),{mode:0o600});renameSync(temp,pointer);}
83
+ return target;
84
+ }
85
+ const choices=env.AFU_PYTHON ? [[env.AFU_PYTHON,[]]] : process.platform==='win32' ? [['py',['-3']],['python',[]]] : [['python3',[]],['python',[]]];
86
+ const selected=choices.find(([cmd,args])=>spawnSync(cmd,[...args,'-c','import sys;sys.exit(sys.version_info<(3,11))'],{stdio:'ignore',timeout:10000}).status===0);
87
+ if(!selected)throw Error('Python 3.11+ is required. Install it or set AFU_PYTHON.');
88
+ // Keep a damaged environment at its old path so existing processes/files are not removed.
89
+ // New preparation uses a fresh directory; the venv must be created at its final path.
90
+ let target=location;
91
+ if(existsSync(location))target=location+'-repair-'+randomUUID();
92
+ const python=join(target,process.platform==='win32'?'Scripts/python.exe':'bin/python');
93
+ createdTarget=target;
94
+ await run(selected[0],[...selected[1],'-m','venv',target]);
95
+ const inputs=['-c',join(root,'python/constraints.txt'),join(root,'python',manifest.wheel)];
96
+ if(spawnSync('uv',['--version'],{stdio:'ignore',timeout:10000}).status===0)await run('uv',['pip','install','--python',python,...inputs]);
97
+ else await run(python,['-m','pip','install','--disable-pip-version-check',...inputs]);
98
+ await run(python,['-c','import afu_context.local_cli, aiohttp, tiktoken']);
99
+ writeFileSync(join(target,'afu-release.json'),JSON.stringify(manifest),{mode:0o600});
100
+ writeFileSync(join(target,'afu-ready.json'),JSON.stringify({build:manifest.build,cache_protocol:1}),{mode:0o600});
101
+ if(target!==location){
102
+ const pointer=join(base,manifest.build+'.location.json');
103
+ const temporary=pointer+'.'+token;
104
+ writeFileSync(temporary,JSON.stringify({location:target}),{mode:0o600});renameSync(temporary,pointer);
105
+ }
106
+ return target;
107
+ }catch(error){
108
+ if(owns()&&createdTarget)rmSync(createdTarget,{recursive:true,force:true});
109
+ throw error;
110
+ }finally{if(owns())rmSync(lock,{recursive:true,force:true});}
111
+ }