@hanamesh/dsh-app-host 0.6.22 → 0.6.24

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.
@@ -2,7 +2,8 @@
2
2
  import semver from 'semver';
3
3
  import {AppHostError} from '../errors.js';
4
4
  import {SUITE_BUNDLE,createCoreSuiteAdapter,SUITE_PACKAGES} from './suite.js';
5
- import {SUPPORTED_READER_VERSIONS,validReader,runOfficialInstallationCommand} from './installation-cli.js';
5
+ import {readerVersionFailure,validReader,runOfficialInstallationCommand} from './installation-cli.js';
6
+ import {discoverOfficialReader} from './installation-discovery.js';
6
7
  const object=x=>x!==null&&typeof x==='object'&&!Array.isArray(x);
7
8
  const exact=x=>typeof x==='string'&&semver.valid(x)===x?x:null;
8
9
  const reason=(code,message,action)=>({code,message,action});
@@ -48,11 +49,11 @@ function parseInventory(channel,plugins,marketplaces,target){
48
49
  return{status:failed?'incomplete':'installed',version,components:[{name:target.pluginName,version,active:row.enabled}],
49
50
  reason:failed?reason(channel==='claude'?'AGENT_PLUGIN_LOAD_FAILED':'AGENT_PLUGIN_PAYLOAD_MISSING','官方清单报告插件负载或加载问题。','在该官方客户端的插件详情中检查并修复。'):version===null?reason('AGENT_VERSION_UNAVAILABLE','已确认该作用域的安装记录,但实际版本未知。','在该官方客户端插件详情中核对实际版本。'):null};
50
51
  }
51
- const failureCodes=new Set(['AGENT_READER_UNAVAILABLE','AGENT_READER_PERMISSION_DENIED','AGENT_READER_PLATFORM_UNSUPPORTED','AGENT_READER_NODE_REQUIRED','AGENT_PROFILE_UNAVAILABLE','AGENT_READER_TIMEOUT','AGENT_READER_OUTPUT_LIMIT','AGENT_READER_CLEANUP_FAILED','AGENT_READER_DIAGNOSTIC','AGENT_READER_FAILED','AGENT_INVENTORY_INVALID','AGENT_INVENTORY_CONFLICT','AGENT_MARKETPLACE_SOURCE_MISMATCH','AGENT_MARKETPLACE_SOURCE_UNAVAILABLE','AGENT_SCOPE_UNOBSERVABLE']);
52
- /** Trusted static configuration only; `core` is the optional public protocol-1 Cordis service accessor. */
53
- export function createInstallationStatusReader({client='dsh',core=()=>undefined,readers={},workerNode,runCommand=runOfficialInstallationCommand}={}){
52
+ const failureCodes=new Set(['AGENT_SHELL_ENV_UNAVAILABLE','AGENT_READER_DISCOVERY_INVALID','AGENT_READER_ENTRYPOINT_UNAVAILABLE','AGENT_READER_UNAVAILABLE','AGENT_READER_PERMISSION_DENIED','AGENT_READER_NODE_REQUIRED','AGENT_PROFILE_UNAVAILABLE','AGENT_READER_TIMEOUT','AGENT_READER_OUTPUT_LIMIT','AGENT_READER_CLEANUP_FAILED','AGENT_READER_DIAGNOSTIC','AGENT_READER_FAILED','AGENT_INVENTORY_INVALID','AGENT_INVENTORY_CONFLICT','AGENT_MARKETPLACE_SOURCE_MISMATCH','AGENT_MARKETPLACE_SOURCE_UNAVAILABLE','AGENT_SCOPE_UNOBSERVABLE']);
53
+ /** Automatic current-user discovery only; `core` is the optional public protocol-1 Cordis service accessor. */
54
+ export function createInstallationStatusReader({client='dsh',core=()=>undefined,workerNode,runCommand=runOfficialInstallationCommand,discoverReader=discoverOfficialReader}={}){
54
55
  if(!['dsh','hanamesh'].includes(client))throw new TypeError('client must be dsh or hanamesh');
55
- const settings=structuredClone(readers??{}),active=new Set();let closed=false;
56
+ const active=new Set();let closed=false;
56
57
  const suite=createCoreSuiteAdapter({core});
57
58
  async function runtime(signal){
58
59
  const row=base(client,'current-runtime','responding-runtime',client==='dsh'?'当前响应的 DSH runtime':'当前响应的 HanaMesh 客户端 runtime');
@@ -64,20 +65,20 @@ export function createInstallationStatusReader({client='dsh',core=()=>undefined,
64
65
  }catch(error){if(signal.aborted)throw interrupted();const code=['CORE_SUITE_DECLARATION_MISSING','CORE_SUITE_DECLARATION_INVALID'].includes(error.code)?error.code:'CORE_SUITE_READ_FAILED';return unknown(row,code,code==='CORE_SUITE_DECLARATION_MISSING'?'本 runtime 未提供 Core 套件声明方法。':'本次 Core 套件声明读取失败。','启用支持 protocol 1 getSuiteDeclaration 的 Core,查看其设置并重新检测。')}
65
66
  }
66
67
  async function agent(channel,signal){
67
- const initial=agentBase(channel),config=settings[channel];
68
- if(config===undefined)return unknown(initial,'AGENT_READER_NOT_CONFIGURED','尚未配置该渠道的官方只读清单读取器。','在 AppHost library.installationReaders 中配置官方命令、用户资料、登记目标与读取预算后重新检测。');
69
- if(!validReader(config))return unknown(initial,'AGENT_READER_CONFIG_INVALID','官方读取器缺少有效的显式配置。','核对 library.installationReaders 的绝对路径、登记插件名/市场来源和必填读取预算。');
70
- const row={...initial,identity:{pluginName:config.pluginName,marketplace:config.marketplace}};
71
- const invoke=args=>abortable(runCommand({channel,reader:config,args,signal,workerNode}),signal);
68
+ const initial=agentBase(channel);let row=initial;
72
69
  try{
73
- const version=(await invoke(['--version'])).trim();
74
- if(version!==SUPPORTED_READER_VERSIONS[channel])return unknown(row,'AGENT_READER_VERSION_UNSUPPORTED','该官方 CLI 版本尚未验证此读取协议。','使用提供方正文列明的支持版本,或先核实新版本官方清单协议。');
70
+ const config=await abortable(discoverReader({channel,signal,workerNode}),signal);
71
+ if(!validReader(config))throw new AppHostError('AGENT_READER_DISCOVERY_INVALID','Invalid automatic reader.',{},503);
72
+ row={...initial,identity:{pluginName:config.pluginName,marketplace:config.marketplace}};
73
+ const invoke=args=>abortable(runCommand({channel,reader:config,args,signal,workerNode}),signal);
74
+ const versionFailure=readerVersionFailure(channel,await invoke(['--version']));
75
+ if(versionFailure)return unknown(row,versionFailure,versionFailure==='AGENT_READER_VERSION_INVALID'?'官方 CLI 版本输出格式无效。':'官方 CLI 低于支持的最低版本。','通过官方渠道升级 Claude Code 至 2.1.301 或以上、Codex CLI 至 0.162.1 或以上,再重新检测。');
75
76
  const suffix=channel==='codex'?['--disable','remote_plugin']:[];
76
77
  const text=await invoke(['plugin','list','--json',...suffix]);
77
78
  const sourceText=await invoke(['plugin','marketplace','list','--json',...suffix]);
78
79
  let plugins,marketplaces;try{plugins=JSON.parse(text);marketplaces=JSON.parse(sourceText)}catch{inventoryError()}
79
80
  return{...row,...parseInventory(channel,plugins,marketplaces,config)};
80
- }catch(error){if(signal.aborted||error.code==='INSTALLATION_STATUS_CANCELLED')throw interrupted();const code=failureCodes.has(error.code)?error.code:'AGENT_READER_FAILED';return unknown(row,code,'本次官方清单读取未能提供完整且一致的安装事实。','在对应官方 CLI 的已配置用户资料中检查插件与市场清单、权限或诊断,修复后显式重检。')}
81
+ }catch(error){if(signal.aborted||error.code==='INSTALLATION_STATUS_CANCELLED')throw interrupted();const code=failureCodes.has(error.code)?error.code:'AGENT_READER_FAILED';const messages={AGENT_READER_UNAVAILABLE:'未在当前用户的 PATH 或常见安装位置找到官方 CLI。',AGENT_SHELL_ENV_UNAVAILABLE:'登录 shell 的 PATH 未能读取。',AGENT_PROFILE_UNAVAILABLE:'当前用户的官方资料目录不存在或不可访问。',AGENT_READER_TIMEOUT:'本次官方只读检测超时。',AGENT_INVENTORY_INVALID:'官方清单 JSON 不符合受支持的格式。',AGENT_READER_DISCOVERY_INVALID:'自动发现的官方 CLI 信息无效。',AGENT_READER_ENTRYPOINT_UNAVAILABLE:'已发现 Windows 命令入口,但官方 npm 程序文件缺失。'};return unknown(row,code,messages[code]??'本次官方清单读取未能提供完整且一致的安装事实。','在终端确认官方 CLI 可运行,并在其用户级插件页面检查清单、权限和市场来源后重新检测。')}
81
82
  }
82
83
  const read=async({signal}={})=>{
83
84
  if(closed||signal?.aborted)throw interrupted();const controller=new AbortController();active.add(controller);const combined=signal?AbortSignal.any([signal,controller.signal]):controller.signal;
@@ -1,13 +1,52 @@
1
1
  /** Detached reaper: it outlives the caller and owns the neutral directory, outside the command group. */
2
- import {fork} from 'node:child_process';
3
- import {mkdtempSync,realpathSync,rmSync} from 'node:fs';
2
+ import {fork,spawnSync} from 'node:child_process';
3
+ import {mkdtempSync,realpathSync,rmSync,lstatSync} from 'node:fs';
4
4
  import {join} from 'node:path';
5
- let started=false,finishing=false,finalized=false,directory,anchor,anchorExited=false,payload;
6
- function finalize(){
5
+ import {createWindowsJob} from '../windows-job.js';
6
+ let started=false,finishing=false,finalized=false,directory,anchor,anchorExited=false,payload,windowsJob,jobStarting,jobController,stopping;
7
+ function groupMetadata(){
8
+ const result=spawnSync('/bin/ps',['-axo','pid=,pgid=,uid=,stat='],{encoding:'utf8',stdio:['ignore','pipe','ignore']});
9
+ if(result.status!==0)throw new Error('Owned group metadata is unavailable.');
10
+ const rows=result.stdout.trim().split('\n').filter(Boolean).map(line=>{
11
+ const match=/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line);
12
+ if(!match)throw new Error('Owned group metadata is invalid.');
13
+ return{pid:Number(match[1]),pgid:Number(match[2]),uid:Number(match[3]),state:match[4]};
14
+ });
15
+ return rows.filter(row=>row.pgid===anchor.pid);
16
+ }
17
+ async function confirmOwnedGroupGone(){
18
+ if(process.platform==='win32'){if(stopping)await stopping;return}if(!anchor)return;
19
+ // Observe only the PGID created by this anchor, never send another termination
20
+ // signal after its PID could be reused. close(anchor) alone does not reap descendants.
21
+ const deadline=Date.now()+1000;const observed=new Set();
22
+ while(true){
23
+ try{process.kill(-anchor.pid,0)}catch(error){
24
+ if(error.code==='ESRCH')return;
25
+ if(error.code!=='EPERM')throw error;
26
+ // Darwin may return EPERM for an empty or zombie-only process group. Read
27
+ // metadata only (never argv/commands); a foreign UID or unknown state fails closed.
28
+ const members=groupMetadata();
29
+ if(members.some(row=>row.uid!==process.getuid()||!/^[-A-Za-z+<>]+$/.test(row.state)))throw error;
30
+ for(const row of members)observed.add(row.pid);
31
+ if(members.length===0){
32
+ let remaining=false;for(const pid of observed){try{process.kill(pid,0);remaining=true}catch(probe){if(probe.code!=='ESRCH')throw probe}}
33
+ if(!remaining)return;
34
+ }
35
+ }
36
+ if(Date.now()>=deadline)throw new Error('Owned command group did not finish reaping.');
37
+ await new Promise(resolve=>setTimeout(resolve,5));
38
+ }
39
+ }
40
+ async function finalize(){
7
41
  if(finalized)return;finalized=true;
42
+ let cleanupComplete=false;
8
43
  // Normal owned shutdown reaches here after the live group was killed and its anchor closed.
9
44
  // An external SIGKILL/native crash of an internal supervisor is outside automatic recovery.
10
- if(directory){try{rmSync(directory,{recursive:true,force:true})}catch{payload={error:'AGENT_READER_CLEANUP_FAILED'}}}
45
+ try{await confirmOwnedGroupGone();
46
+ if(directory){rmSync(directory,{recursive:true,force:true,maxRetries:4,retryDelay:25});try{lstatSync(directory);throw new Error('Owned neutral directory survived cleanup.')}catch(error){if(error.code!=='ENOENT')throw error}}
47
+ cleanupComplete=true;
48
+ }catch{payload={error:'AGENT_READER_CLEANUP_FAILED'}}
49
+ payload={...payload,cleanupComplete};
11
50
  let flushed=0;const complete=()=>{if(++flushed!==2)return;const exit=()=>process.exit(0);
12
51
  if(process.connected){try{process.send(payload,exit)}catch{exit()}}else exit();
13
52
  };
@@ -15,10 +54,23 @@ function finalize(){
15
54
  }
16
55
  function finish(value){
17
56
  if(finishing)return;finishing=true;payload=value;
18
- if(anchor&&!anchorExited){
19
- // The IPC anchor deliberately stays alive after command completion. Kill only its still-owned group.
20
- try{process.kill(-anchor.pid,'SIGKILL')}catch{payload={error:'AGENT_READER_FAILED'}}
21
- }else if(!anchor)finalize();
57
+ if(anchor&&(process.platform==='win32'||!anchorExited)){
58
+ // The anchor is not released until the entire exact command ownership has ended.
59
+ if(process.platform==='win32'){
60
+ stopping=(async()=>{
61
+ if(!windowsJob){jobController?.abort();try{await jobStarting}catch{};try{anchor.kill('SIGKILL')}catch(error){if(error.code!=='ESRCH')throw error}}
62
+ else await windowsJob.stop();
63
+ })();
64
+ stopping.catch(()=>{payload={error:'AGENT_READER_CLEANUP_FAILED'}});
65
+ }else try{process.kill(-anchor.pid,'SIGKILL')}catch{payload={error:'AGENT_READER_FAILED'}}
66
+ }else if(!anchor)void finalize();
67
+ }
68
+ async function commandResult(value){
69
+ if(finishing)return;
70
+ if(process.platform==='win32'&&!value.error){
71
+ try{while(!finishing){if(await windowsJob.active()<=1)break;await new Promise(resolve=>setTimeout(resolve,5));}if(finishing)return;}catch{finish({error:'AGENT_READER_FAILED'});return}
72
+ }
73
+ finish(value);
22
74
  }
23
75
  process.once('disconnect',()=>finish({error:'INSTALLATION_STATUS_CANCELLED'}));
24
76
  process.once('SIGTERM',()=>finish({error:'INSTALLATION_STATUS_CANCELLED'}));
@@ -27,18 +79,24 @@ process.once('uncaughtException',()=>finish({error:'AGENT_READER_FAILED'}));
27
79
  process.once('unhandledRejection',()=>finish({error:'AGENT_READER_FAILED'}));
28
80
  if(!process.connected)process.exit(1);
29
81
  process.on('message',request=>{
82
+ if(request.abort){finish({error:'INSTALLATION_STATUS_CANCELLED'});return}
30
83
  if(started)return;started=true;
31
84
  try{
32
85
  if(!process.connected){finish({error:'INSTALLATION_STATUS_CANCELLED'});return}
33
86
  directory=mkdtempSync(join(realpathSync(request.temporaryRoot),'hanamesh-installation-read-'));
34
87
  process.send({directory},error=>{if(error)finish({error:'INSTALLATION_STATUS_CANCELLED'})});
35
- const env={...request.env,XDG_CONFIG_HOME:join(directory,'config'),XDG_CACHE_HOME:join(directory,'cache'),XDG_STATE_HOME:join(directory,'state'),TMPDIR:directory,TMP:directory,TEMP:directory};
36
- anchor=fork(new URL('./installation-command-worker.js',import.meta.url),[],{cwd:directory,env,execArgv:[],detached:true,stdio:['ignore','pipe','pipe','ipc']});
88
+ const env={...request.env,TMPDIR:directory,TMP:directory,TEMP:directory};
89
+ anchor=fork(new URL('./installation-command-worker.js',import.meta.url),[],{cwd:directory,env,execArgv:[],detached:process.platform!=='win32',stdio:['ignore','pipe','pipe','ipc']});
37
90
  anchor.stdout.pipe(process.stdout,{end:false});anchor.stderr.pipe(process.stderr,{end:false});
38
- anchor.on('message',value=>finish(value));
91
+ anchor.on('message',value=>{void commandResult(value)});
39
92
  anchor.once('exit',()=>{anchorExited=true});
40
93
  anchor.once('error',()=>{if(!finishing){finishing=true;payload={error:'AGENT_READER_UNAVAILABLE'}}});
41
- anchor.once('close',()=>{if(!finishing){finishing=true;payload={error:'AGENT_READER_FAILED'}}finalize()});
42
- anchor.send({executable:request.executable,args:request.args},error=>{if(error)finish({error:'AGENT_READER_UNAVAILABLE'})});
94
+ anchor.once('close',()=>{if(!finishing)finish({error:'AGENT_READER_FAILED'});void finalize()});
95
+ const launch=()=>{if(!finishing)anchor.send({executable:request.executable,args:request.args},error=>{if(error)finish({error:'AGENT_READER_UNAVAILABLE'})});};
96
+ if(process.platform==='win32'){
97
+ jobController=new AbortController();
98
+ jobStarting=createWindowsJob({pid:anchor.pid,env,cwd:request.temporaryRoot,signal:jobController.signal});
99
+ void jobStarting.then(job=>{windowsJob=job;if(!finishing)launch();},()=>finish({error:'AGENT_READER_UNAVAILABLE'}));
100
+ }else launch();
43
101
  }catch{finish({error:'AGENT_READER_UNAVAILABLE'})}
44
102
  });
@@ -1,4 +1,5 @@
1
1
  import { installationStatusCors, serveInstallationStatus } from './installation-status-http.js';
2
+ import {serveInstallTargetProbe} from './install-target-http.js';
2
3
  import { randomUUID } from 'node:crypto';
3
4
  import { AppHostError, requireCondition } from '../errors.js';
4
5
  import { controlOrigins } from '../control-origin.js';
@@ -33,16 +34,7 @@ export function createLibraryHttpHandler(library,{parentOrigin,transportOrigin,a
33
34
  await serveInstallationStatus(req,res,{read:library.installationStatus,maxBodyBytes:HANDOFF_BODY_MAX_BYTES,traceId,cors,send});return;
34
35
  }
35
36
  if(path==='/hanamesh/library/install-target'){
36
- // Match the website handoff policy; same-origin workspace and non-browser local reads remain available.
37
- const origin=req.headers.origin;
38
- if(origin!==undefined){
39
- requireCondition(origin===requestOrigin||origins.acceptsOrigin(origin)||library.handoffSettings().allowedOrigins.includes(origin),'ORIGIN_DENIED','Not available.',undefined,403);
40
- cors={'access-control-allow-origin':origin,vary:'Origin','access-control-allow-private-network':'true'};
41
- }
42
- if(req.method==='OPTIONS'){send(res,204,undefined,{...cors,'access-control-allow-methods':'GET'});return;}
43
- requireCondition(req.method==='GET','METHOD_NOT_ALLOWED','Discovery requires GET.',{},405);
44
- requireCondition(!url.search,'UNKNOWN_FIELDS','Discovery takes no parameters.');
45
- send(res,200,{...library.installTarget(),traceId},cors);return;
37
+ serveInstallTargetProbe(req,res,{allowedOrigins:[requestOrigin,origins.parent,...library.handoffSettings().allowedOrigins],read:()=>library.installTarget(),traceId,send});return;
46
38
  }
47
39
  const readOnly=req.method==='GET'&&READ_ROUTES.has(path);
48
40
  requireCondition(readOnly||(req.method==='POST'&&!['/hanamesh/library/installedPlugins','/hanamesh/library/target','/hanamesh/library/handoffs'].includes(path)),'METHOD_NOT_ALLOWED','Unsupported library method.',{},405);
@@ -19,7 +19,7 @@ const checkScript="console.log(JSON.stringify({version:process.versions.node,pla
19
19
  export async function probeStandaloneNode(binary,{platform,version,signal}={}){
20
20
  requireCondition(typeof binary==='string'&&isAbsolute(binary),'NODE_BINARY_INVALID','独立 Node 路径必须是绝对路径。');
21
21
  await access(binary,constants.X_OK);
22
- const {stdout}=await run(binary,['-e',checkScript],{env:{PATH:''},maxBuffer:4096,signal});
22
+ const {stdout}=await run(binary,['-e',checkScript],{env:{PATH:''},timeout:2_000,maxBuffer:4096,signal});
23
23
  let actual;try{actual=JSON.parse(stdout);}catch{throw new AppHostError('NODE_NOT_STANDALONE','所选文件没有返回独立 Node 的检查结果;Electron 包装脚本不能代替独立 Node。');}
24
24
  requireCondition(actual.electron===null&&/^24\./.test(actual.version)&&(Number(actual.version.split('.')[1])>13||Number(actual.version.split('.')[1])===13&&Number(actual.version.split('.')[2])>=1)&&(!version||actual.version===version)&&`${actual.platform}-${actual.arch}`===platform,'NODE_NOT_STANDALONE','需要当前平台的独立 Node.js 24(24.13.1 或更新);所选文件的版本、平台或 Electron 检查未通过。');
25
25
  return actual;
@@ -47,8 +47,8 @@ export function createNodeRuntime({domain,dataRoot,config={},initialBinary,platf
47
47
  requireCondition(next.mode!=='external'||isAbsolute(next.externalBinary),'NODE_BINARY_INVALID','已有独立 Node 路径必须是绝对路径。');
48
48
  return {...next,sourceBase:source.href.replace(/\/$/,'')};
49
49
  }
50
- const externalCheck=()=>requireCondition(['darwin-arm64','darwin-x64','linux-arm64','linux-x64'].includes(platform),'NODE_PLATFORM_UNSUPPORTED','当前平台尚未完成独立 Node 的进程守护支持;Windows 不支持本安装流程。');
51
- const supportedCheck=()=>requireCondition(supported,'NODE_PLATFORM_UNSUPPORTED','一键准备当前仅支持 macOS 13.5 及以上的 arm64 / x64;Windows 安装与进程守护尚未接通。');
50
+ const externalCheck=()=>requireCondition(['darwin-arm64','darwin-x64','linux-arm64','linux-x64','win32-x64'].includes(platform),'NODE_PLATFORM_UNSUPPORTED','当前平台尚未完成已有独立 Node 的受控 CLI 支持。');
51
+ const supportedCheck=()=>requireCondition(supported,'NODE_PLATFORM_UNSUPPORTED','一键准备当前仅支持 macOS 13.5 及以上的 arm64 / x64;Windows 受管准备和 owned 应用守护尚未接通。');
52
52
  async function publish(next){const whole={...next,revision:state.revision+1};await domain.global.set(whole);state=whole;}
53
53
  function manifest(p){return {schema:1,sources:[{id:'confirmed-source',kind:'https',base:new URL(`v${p.version}/`,p.sourceBase+'/').href}],items:[{id:'node',version:p.version,kind:'tar.gz',installTo:'runtime',platforms:{[platform]:{asset:p.asset,sha256:p.sha256,size:p.bytes,strip:1}},verify:{exec:'bin/node',args:['-e',checkScript],expect:JSON.stringify({version:p.version,platform:'darwin',arch:platform.slice(7),electron:null})}}]};}
54
54
  async function verifyActive(active,signal,heldLock){
@@ -66,7 +66,7 @@ export function createNodeRuntime({domain,dataRoot,config={},initialBinary,platf
66
66
  else if(state.active){supportedCheck();executable=await verifyActive(state.active);}
67
67
  }catch(e){operation={id:null,state:'failed',phase:'restore',error:{message:'安装环境尚未就绪:'+causeText(e),code:e.code??'NODE_CHECK_FAILED'}};}
68
68
  },
69
- status(){return copy({settings,names:NODE_SETTING_NAMES,versions:[...new Set(catalog.archives.map(a=>a.version))],platform,supported,ready:!!executable,executable:executable??null,active:state?.active??null,operation,limitations:'一键准备支持 macOS 13.5+ arm64 / x64;Windows 尚不支持。下载仅在确认后开始;不重试、不切换来源。'});},
69
+ status(){return copy({settings,names:NODE_SETTING_NAMES,versions:[...new Set(catalog.archives.map(a=>a.version))],platform,supported,ready:!!executable,executable:executable??null,active:state?.active??null,operation,limitations:'一键准备支持 macOS 13.5+ arm64 / x64;Windows x64 可使用已验证的已有独立 Node 运行受控 CLI,受管准备和 owned 应用守护仍不支持。下载仅在确认后开始;不重试、不切换来源。'});},
70
70
  async save(input){exact(input,['settings']);idle();const next=validate(input.settings);saving=true;plan=null;
71
71
  try{await safeAncestors(next.storageRoot);let external;
72
72
  if(next.mode==='external'){externalCheck();await probe(next.externalBinary,{platform});external=next.externalBinary;}
@@ -36,9 +36,12 @@ export function createProviderSources({ credentials, apps, llm = () => undefined
36
36
  if (!dshHome) return null;
37
37
  const path = join(dshHome,'.coding-oauth-gateway.json');
38
38
  let fileStat, value;
39
- try { fileStat = await stat(path); value = JSON.parse(await readFile(path,'utf8')); } catch { return null; }
40
- if ((fileStat.mode & 0o077) !== 0) return { id:'coding-oauth-gateway', source:'coding-oauth-gateway', kind:'api-key',
39
+ try { fileStat = await stat(path); } catch { return null; }
40
+ // Node mode bits cannot prove a private Windows ACL. Refuse file-mode
41
+ // secrets before reading them; the authenticated HTTP provider remains available.
42
+ if (process.platform === 'win32' || (fileStat.mode & 0o077) !== 0) return { id:'coding-oauth-gateway', source:'coding-oauth-gateway', kind:'api-key',
41
43
  displayName:'Coding OAuth Gateway', state:'unreachable' };
44
+ try { value = JSON.parse(await readFile(path,'utf8')); } catch { return null; }
42
45
  const base = { id:'coding-oauth-gateway', source:'coding-oauth-gateway', kind:'api-key', displayName:'Coding OAuth Gateway',
43
46
  ...(Array.isArray(value.models) ? { models:value.models } : {}), ...(value.keyHint ? { keyHint:value.keyHint } : {}) };
44
47
  if (!value.enabled) return { ...base, state:'gateway-off' };
package/dist/runtime.js CHANGED
@@ -22,6 +22,16 @@ export async function resolveNodeBinary(nodeBinary) {
22
22
  }
23
23
  try { await access(selected, constants.X_OK); }
24
24
  catch { throw new AppHostError('NODE_RUNTIME_REQUIRED', 'nodeBinary must be an absolute executable path.'); }
25
+ if (process.platform === 'win32') {
26
+ // Windows X_OK only checks existence. Verify the selected native runtime
27
+ // with a fixed expression before it can become a guardian executable.
28
+ try {
29
+ const {stdout} = await exec(selected, ['-p', 'JSON.stringify({node:process.release.name,electron:process.versions.electron??null})'],
30
+ {timeout:2_000,maxBuffer:1024,windowsHide:true,env:guardianEnvironment(selected)});
31
+ const result=JSON.parse(stdout);
32
+ if(result.node!=='node'||result.electron!==null)throw new Error('Not a standalone Node executable.');
33
+ } catch { throw new AppHostError('NODE_RUNTIME_REQUIRED','nodeBinary must be a standalone executable Node.js runtime.'); }
34
+ }
25
35
  return selected;
26
36
  }
27
37
  export function guardianEnvironment(nodeBinary, source = process.env) {
@@ -30,7 +40,11 @@ export function guardianEnvironment(nodeBinary, source = process.env) {
30
40
  if (typeof source[key] === 'string' && source[key].length) env[key] = source[key];
31
41
  }
32
42
  if (!env.LANG) env.LANG = 'C.UTF-8';
33
- env.PATH = `${dirname(nodeBinary)}:/usr/bin:/bin`;
43
+ if(process.platform==='win32') {
44
+ const systemRoot=source.SystemRoot??source.SYSTEMROOT??process.env.SystemRoot??process.env.SYSTEMROOT;
45
+ if(systemRoot)env.SystemRoot=systemRoot;
46
+ env.PATH=dirname(nodeBinary);
47
+ } else env.PATH = `${dirname(nodeBinary)}:/usr/bin:/bin`;
34
48
  return env;
35
49
  }
36
50
  export async function freePort() {
package/dist/store.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { constants } from 'node:fs';
2
2
  import { mkdir, readFile, open, rename, unlink, lstat, realpath } from 'node:fs/promises';
3
- import { dirname, isAbsolute, join, resolve } from 'node:path';
3
+ import { dirname, isAbsolute, join, resolve, parse, sep } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { AppHostError, SerialQueue, copy, requireCondition } from './errors.js';
6
6
  import { processIdentity } from './runtime.js';
@@ -42,8 +42,9 @@ export function validateSnapshot(s) {
42
42
  // Only use under an explicitly selected local profile root. Reject symlink components.
43
43
  export async function secureDirectory(path) {
44
44
  requireCondition(isAbsolute(path), 'INVALID_ROOT', 'An absolute data directory is required.');
45
- const parts = resolve(path).split('/').filter(Boolean);
46
- let current = '/';
45
+ const absolute = resolve(path), root = parse(absolute).root;
46
+ const parts = absolute.slice(root.length).split(sep).filter(Boolean);
47
+ let current = root;
47
48
  for (const part of parts) {
48
49
  current = join(current, part);
49
50
  await mkdir(current, { mode: 0o700 }).catch(e => { if (e.code !== 'EEXIST') throw e; });
@@ -218,8 +219,14 @@ export class AtomicFileStore {
218
219
  await this.checkpoint('snapshot-temp-fsynced', copy(image));
219
220
  await fd.close(); fd = undefined;
220
221
  await rename(temp, this.path); renamed = true;
221
- const directory = await open(dirname(this.path), constants.O_RDONLY);
222
- try { await directory.sync(); } finally { await directory.close(); }
222
+ // Node cannot open a directory for fsync on Windows. The completed
223
+ // file sync and same-directory rename still publish one whole image;
224
+ // only POSIX additionally synchronizes the directory entry. Do not
225
+ // reinterpret an arbitrary POSIX fsync failure as success.
226
+ if (process.platform !== 'win32') {
227
+ const directory = await open(dirname(this.path), constants.O_RDONLY);
228
+ try { await directory.sync(); } finally { await directory.close(); }
229
+ }
223
230
  await this.checkpoint('snapshot-published', copy(image));
224
231
  } catch (e) {
225
232
  // Post-rename failure is an ambiguous commit, not a guaranteed rollback.
@@ -0,0 +1,30 @@
1
+ /** A Windows Job Object assigned before an IPC anchor is allowed to spawn commands. */
2
+ import {spawn} from 'node:child_process';
3
+ import {join} from 'node:path';
4
+ import {fileURLToPath} from 'node:url';
5
+ export function createWindowsJob({pid,env,cwd,signal}){
6
+ if(process.platform!=='win32'||!Number.isSafeInteger(pid)||pid<=1)throw new Error('A live Windows anchor PID is required.');
7
+ const command=join(env.SystemRoot??'C:\\Windows','System32','WindowsPowerShell','v1.0','powershell.exe');
8
+ const child=spawn(command,['-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',fileURLToPath(new URL('./windows-job.ps1',import.meta.url)),'-AnchorPid',String(pid)],{env,cwd,signal,windowsHide:true,stdio:['pipe','pipe','pipe']});
9
+ let pending='',fault,closed=false,readyResolve,readyReject,stopPromise;const requests=[];
10
+ const ready=new Promise((resolve,reject)=>{readyResolve=resolve;readyReject=reject});
11
+ const exit=new Promise(resolve=>child.once('close',()=>{closed=true;fail(new Error('Windows ownership helper closed.'));resolve()}));
12
+ function fail(error){fault??=error;readyReject(error);for(const request of requests.splice(0))request.reject(error);if(!child.stdin.destroyed)child.stdin.end()}
13
+ child.once('error',fail);child.stdin.on('error',fail);
14
+ child.stderr.on('data',()=>fail(new Error('Windows ownership helper failed.')));
15
+ child.stdout.on('data',chunk=>{
16
+ pending+=chunk.toString('utf8');if(pending.length>4096){fail(new Error('Invalid Windows job response.'));child.stdin.end();return}
17
+ const lines=pending.split(/\r?\n/);pending=lines.pop();
18
+ for(const line of lines){if(line==='READY'){readyResolve();continue}const request=requests.shift();
19
+ if(!request||!request.pattern.test(line)){fail(new Error('Invalid Windows job control response.'));child.stdin.end();return}request.resolve(line);
20
+ }
21
+ });
22
+ const send=(text,pattern)=>new Promise((resolve,reject)=>{
23
+ if(fault||closed){reject(fault??new Error('Windows ownership helper is closed.'));return}
24
+ requests.push({resolve,reject,pattern});child.stdin.write(text+'\n',error=>{if(error)fail(error)});
25
+ });
26
+ return ready.then(()=>({
27
+ active:async()=>Number((await send('ACTIVE',/^ACTIVE \d+$/)).slice(7)),
28
+ stop:()=>stopPromise??=(async()=>{try{await send('STOP',/^STOPPED$/)}finally{child.stdin.end();await exit}})(),
29
+ }),async error=>{child.stdin.end();await exit;throw error;});
30
+ }
@@ -0,0 +1,75 @@
1
+ param([Parameter(Mandatory=$true)][int]$AnchorPid)
2
+ $ErrorActionPreference = 'Stop'
3
+ $ProgressPreference = 'SilentlyContinue'
4
+ $PSModuleAutoLoadingPreference = 'None'
5
+ Import-Module ($PSHOME+'\Modules\Microsoft.PowerShell.Utility\Microsoft.PowerShell.Utility.psd1') -ErrorAction Stop
6
+ Add-Type -TypeDefinition @'
7
+ using System;
8
+ using System.Runtime.InteropServices;
9
+ using System.Threading;
10
+ public static class HanaMeshOwnedJob {
11
+ [StructLayout(LayoutKind.Sequential)] struct BasicLimits {
12
+ public long ProcessTime, JobTime;
13
+ public uint Flags;
14
+ public UIntPtr MinimumWorkingSet, MaximumWorkingSet;
15
+ public uint ActiveLimit;
16
+ public UIntPtr Affinity;
17
+ public uint PriorityClass, SchedulingClass;
18
+ }
19
+ [StructLayout(LayoutKind.Sequential)] struct IoCounters {
20
+ public ulong ReadOperations, WriteOperations, OtherOperations, ReadBytes, WriteBytes, OtherBytes;
21
+ }
22
+ [StructLayout(LayoutKind.Sequential)] struct ExtendedLimits {
23
+ public BasicLimits Basic;
24
+ public IoCounters Io;
25
+ public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory;
26
+ }
27
+ [StructLayout(LayoutKind.Sequential)] struct Accounting {
28
+ public long UserTime, KernelTime, PeriodUserTime, PeriodKernelTime;
29
+ public uint PageFaults, TotalProcesses, ActiveProcesses, TerminatedProcesses;
30
+ }
31
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateJobObject(IntPtr attributes, string name);
32
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int infoClass, ref ExtendedLimits info, uint length);
33
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool QueryInformationJobObject(IntPtr job, int infoClass, out Accounting info, uint length, IntPtr returnedLength);
34
+ [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
35
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
36
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint exitCode);
37
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
38
+ static Exception Failure(string operation) { return new InvalidOperationException(operation+":"+Marshal.GetLastWin32Error()); }
39
+ static uint Active(IntPtr job) {
40
+ Accounting info;
41
+ if(!QueryInformationJobObject(job, 1, out info, (uint)Marshal.SizeOf(typeof(Accounting)), IntPtr.Zero)) throw Failure("query-job");
42
+ return info.ActiveProcesses;
43
+ }
44
+ public static void Run(int pid) {
45
+ IntPtr job=CreateJobObject(IntPtr.Zero,null), process=IntPtr.Zero;
46
+ if(job==IntPtr.Zero) throw Failure("create-job");
47
+ try {
48
+ ExtendedLimits limits=new ExtendedLimits(); limits.Basic.Flags=0x2000; // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
49
+ if(!SetInformationJobObject(job,9,ref limits,(uint)Marshal.SizeOf(typeof(ExtendedLimits)))) throw Failure("limit-job");
50
+ process=OpenProcess(0x0101,false,pid); // PROCESS_SET_QUOTA | PROCESS_TERMINATE
51
+ if(process==IntPtr.Zero) throw Failure("open-anchor");
52
+ if(!AssignProcessToJobObject(job,process)) throw Failure("assign-anchor");
53
+ CloseHandle(process); process=IntPtr.Zero;
54
+ Console.WriteLine("READY"); Console.Out.Flush();
55
+ string line;
56
+ while((line=Console.ReadLine())!=null) {
57
+ if(line=="ACTIVE") { Console.WriteLine("ACTIVE "+Active(job)); Console.Out.Flush(); }
58
+ else if(line=="STOP") {
59
+ if(!TerminateJobObject(job,1)) throw Failure("terminate-job");
60
+ DateTime deadline=DateTime.UtcNow.AddSeconds(1);
61
+ while(Active(job)!=0) {
62
+ if(DateTime.UtcNow>=deadline) throw new InvalidOperationException("job-not-empty");
63
+ Thread.Sleep(5);
64
+ }
65
+ Console.WriteLine("STOPPED"); Console.Out.Flush(); return;
66
+ } else throw new InvalidOperationException("invalid-job-control");
67
+ }
68
+ } finally {
69
+ if(process!=IntPtr.Zero) CloseHandle(process);
70
+ CloseHandle(job); // EOF or any helper failure kills the entire assigned tree.
71
+ }
72
+ }
73
+ }
74
+ '@
75
+ [HanaMeshOwnedJob]::Run($AnchorPid)
package/docs/CONTRACT.md CHANGED
@@ -1,4 +1,4 @@
1
- 0.6.7 adds the explicitly consented public installation-status capability on both existing library listeners. Full request/response, strict refusals, configured official-reader support and scope/lifecycle are defined in [INSTALLATION_STATUS.md](INSTALLATION_STATUS.md); it does not authorize installation or alter recording consent.
1
+ 0.6.7 adds the explicitly consented public installation-status capability on both existing library listeners. Full request/response, strict refusals, automatic official-reader discovery and minimum-version support and scope/lifecycle are defined in [INSTALLATION_STATUS.md](INSTALLATION_STATUS.md); it does not authorize installation or alter recording consent.
2
2
 
3
3
  # 公开契约 v1(候选,0.1.0-rc.41;rc.41 卸载 runtime 时清理生成的 Python 字节码缓存、未知残留仍 fail-closed;rc.40 市场只读查询待启动实例,ready 后一次 resume;前台即时 heartbeat 与失效帧显式清理;rc.39 Market seat 内打开应用时将原有应用帧固定铺满原生窗口并覆盖 Settings,同一回执/租约/关闭动作;rc.38 投射时仅提升 shell.overlay 层级以盖过原生 Settings;rc.35 市场开关按钮即时反馈、owned 应用已退出时提前结束 TERM 等待并确认精确进程组终止;rc.34 内置市场消费 heartbeat/视图清理;rc.33 owned 回收与启动握手分阶段限时;rc.32 客户端 `market` 席位与 dshmarket 互斥、壳内重启改走 postMessage 桥(见「市场席位」);rc.28 HanaMesh 市场:插件与应用同路径装卸、已装含插件、可升级/需重启、`installedPlugins` 与 `plugins/*` 路由、`LIB-PROVISION-INPUT` 400(见「市场」);rc.27 应用使用证据经 usage 插件 record 座位上报(见「应用使用证据」);rc.26 应用库默认目录源 + 纯 DSH 位置推断(见「应用库配置」);rc.15 目录条目校验补 updatedAt;rc.16 应用库默认 category=hanamesh-app、q/cursor 透传;rc.13/rc.14 与 rc.12 契约相同:rc.13 补许可证/repository/精确 peer,rc.14 去掉客户端对 hanameshCore 的读取)
4
4
 
@@ -222,7 +222,7 @@ Authenticated same-origin routes (normal workspace CSRF and connection cookie):
222
222
 
223
223
  Public install-target remains read-only and unchanged. Preparation never weakens authentication/CORS or guardian admission. Subsequent AppHost spawns, library CLI supervisors and installation readers capture the new standalone binding without restarting DSH. In-flight operations retain their delegate; settings/preparation reject while library/host operations are in use.
224
224
 
225
- Support implementation: macOS13.5+ arm64/x64, known Node24.13.1 or24.22.0 (default24.22.0). Windows is explicitly unsupported: POSIX-owned process supervision, cancellation and reader chain need separate implementation and real-host proof. No Windows ready claim. mac x64 native proof is tracked separately from its signed archive integrity. No client or upstream files change. No npm lifecycle download.
225
+ Support implementation: macOS13.5+ arm64/x64, known Node24.13.1 or24.22.0 (default24.22.0). Windows managed Node preparation remains unsupported (`supported:false` describes managed preparation). Windows x64 accepts an explicitly selected existing standalone Node for controlled CLI and installation-reader operations only, after the fixed probe verifies actual Node 24 version, OS/architecture and absence of Electron; a verified external selection may report `ready:true`. Owned application launch remains unsupported and fails before spawn or credential projection. Automatic installation readers have a separate Windows discovery/owned-tree implementation; their native proof remains required. mac x64 native proof is tracked separately from its signed archive integrity. No client or upstream files change. No npm lifecycle download.
226
226
 
227
227
  Storage space budget is archive bytes + twice measured regular-file payload (new tree + staging); never delete another version or unknown files to make space. lib-provision alone owns staging/ledger/manifests; interrupted or cancelled downloads may leave its resumable owned cache. Another attempt requires a new visible confirmation. Existing versions remain until an explicit owned cleanup is designed; no automatic deletion or implied rollback. Offline startup verifies an existing active tree/ledger and standalone executable; failures are visible and never trigger a download.
228
228
 
@@ -48,35 +48,44 @@ interface InstallationObservation {
48
48
  }
49
49
  ```
50
50
 
51
- The five distinct observations are: responding runtime, the other DSH/HanaMesh runtime (always `unknown`, not observed by this responder), Claude Code configured user-level CLI scope, Codex CLI configured local-marketplace user scope, and Codex/ChatGPT desktop App scope (always named `unknown`). CLI labels explicitly exclude project/other isolated profiles and desktop App. A valid absence is absence **only in the named configured scope**; never show it as a machine-wide uninstall. Preserve every responder/scope; ports are not durable instance identities. Conflicts between responders are unknown, never resolved by choosing one installed result.
51
+ The five distinct observations are: responding runtime, the other DSH/HanaMesh runtime (always `unknown`, not observed by this responder), Claude Code current-user CLI scope, Codex CLI current-user local-marketplace scope, and Codex/ChatGPT desktop App scope (always named `unknown`). CLI labels explicitly exclude project/other isolated profiles and desktop App. A valid absence is absence **only in the named current-user scope**; never show it as a machine-wide uninstall. Preserve every responder/scope; ports are not durable instance identities. Conflicts between responders are unknown, never resolved by choosing one installed result.
52
52
 
53
53
  Current runtime status/version/components come only from the optional public Core protocol `'1'` `getSuiteDeclaration()` **without an argument**, through the existing Core adapter. No registry request, health substitution, physical file-count completeness, version-range/peer/source algorithm or local upgrade selection is added. Complete maps to installed; incomplete stays incomplete; unknown stays unknown with top-level version null but known component versions and sanitized named blocked codes retained. Missing method, malformed declaration and read failure have distinct reasons. Each detection re-reads; Core removal/invalidation cannot reuse an old installed answer. Agent distribution version is independent of the DSH suite bundle version. Missing active fields remain absent.
54
54
 
55
- ## Explicit agent reader settings
55
+ ## Automatic official CLI discovery
56
56
 
57
- `DshPluginConfig.library.installationReaders` may configure `claude` and/or `codex`. Each reader requires all these fields; **there are no inferred/default values**:
57
+ AppHost 0.6.23 discovers Claude Code and Codex for the current OS user after the consented request. There is no manual reader setting; the former `library.installationReaders` option and public reader configuration type are removed. Browser requests cannot supply paths, commands, environment or profile selection. The registered suite identity is `hanamesh@hanamesh`, with the exact HTTPS source `https://github.com/yzsnstotz/hanamesh-plugins`, as in the existing suite catalog contract.
58
58
 
59
- | Field | Required value |
59
+ Selection is deterministic: the first executable in the inherited absolute PATH directories wins; if absent on macOS/Linux, query the user's interactive login shell once (`-ilc`), extracting only the named infrastructure-path environment in a framed JSON value. This follows [VS Code's shell environment practice](https://code.visualstudio.com/docs/configure/command-line#_how-do-i-detect-when-a-shell-was-launched-by-vs-code). No full environment dump is collected. Like [VS Code's implementation](https://github.com/microsoft/vscode/blob/main/src/vs/platform/shell/node/shellEnv.ts), shell stderr is separate from success: exit/result and a valid framed payload decide success. Normal Bash non-TTY diagnostics are discarded, not treated as failure; both streams still count toward the existing byte budget. Plugin inventory commands retain their clean-stderr requirement. Shell startup noise and stderr never enter public output. Next search the fixed layouts below in listed order. Finally, scan the listed manager version directories in order, newest Node SemVer first within each directory. Relative/empty PATH segments are excluded. Do not run every candidate or switch to a second CLI when the selected command fails, is old or returns an invalid inventory. Executable names alone are not a signature guarantee; the fixed official version syntax and inventory schema are validated before returning facts.
60
+
61
+ | Layout | Searched locations and source |
60
62
  |---|---|
61
- | executable | Absolute path to the person's official CLI executable |
62
- | home | Absolute explicitly selected user's home directory |
63
- | configDir | Absolute existing official config directory for that selected profile (`CLAUDE_CONFIG_DIR` or `CODEX_HOME`) |
64
- | pluginName / marketplace | Exact registered canonical AgentPluginDTO identity; not a guessed hanamesh@hanamesh |
65
- | marketplaceUrl | Exact registered `installation.marketplaceUrl`, canonical credential-free HTTPS, no query/fragment |
66
- | timeoutMs / maxOutputBytes | Required positive safe-integer read budgets; timeout at most Node's 2147483647 limit |
63
+ | Official native / standalone | `~/.local/bin/claude[.exe]` ([Claude setup](https://code.claude.com/docs/en/setup#auto-updates)); native launcher symlinks resolve through normal OS execution. Codex `~/.local/bin/codex`, `CODEX_INSTALL_DIR`, Windows `%LOCALAPPDATA%/Programs/OpenAI/Codex/bin/codex.exe` ([official installer variables](https://learn.chatgpt.com/docs/config-file/environment-variables#installer-variables), [installation](https://learn.chatgpt.com/docs/cli)). Standalone release binaries in custom directories are found through inherited/login PATH or `CODEX_INSTALL_DIR`; unlinked archives are not guessed. |
64
+ | Legacy Claude local | `~/.claude/local/claude`, `~/.claude/local/node_modules/.bin/claude`. These are historical npm-local layouts, not the current native default; [upstream issue with the original launcher path](https://github.com/anthropics/claude-code/issues/2824). No `.claude.json` or installMethod file is read. |
65
+ | npm global | `npm_config_prefix` / `NPM_CONFIG_PREFIX` (`bin` on POSIX, prefix itself on Windows), `~/.npm-global/bin`, `~/.npm/bin`, `%APPDATA%/npm`, `%ProgramFiles%/nodejs`; system prefixes and active Node manager bins below. [npm folder rules](https://docs.npmjs.com/cli/v11/configuring-npm/folders/). No npmrc read or npm config command. |
66
+ | pnpm global | `PNPM_HOME` and its `bin`, `~/.local/share/pnpm[/bin]`, `~/Library/pnpm[/bin]`, `%LOCALAPPDATA%/pnpm`. [pnpm setup](https://pnpm.io/cli/setup). Both earlier direct-bin and current bin-subdirectory layouts are recognized. |
67
+ | Yarn Classic | `~/.yarn/bin`, `~/.config/yarn/global/node_modules/.bin`, custom prefix via PATH/login shell. [Yarn global](https://classic.yarnpkg.com/lang/en/docs/cli/global/). |
68
+ | Volta / asdf / mise | `VOLTA_HOME/bin` or `~/.volta/bin`, `%LOCALAPPDATA%/Volta/bin`; `ASDF_DATA_DIR/shims` or `~/.asdf/shims`; `MISE_DATA_DIR/shims` or `~/.local/share/mise/shims`. [Volta](https://docs.volta.sh/guide/understanding), [asdf](https://asdf-vm.com/manage/configuration.html), [mise](https://mise.jdx.dev/directories.html). |
69
+ | Homebrew / system | `/opt/homebrew/bin`, `/usr/local/bin`, `/home/linuxbrew/.linuxbrew/bin`, `/usr/bin`, `/bin`. [Homebrew prefixes](https://docs.brew.sh/Installation); [Claude Homebrew setup](https://code.claude.com/docs/en/setup), [Codex distribution](https://learn.chatgpt.com/docs/cli). Brew-linked native binaries use the same discovery path. |
70
+ | nvm / fnm / installed manager versions | `NVM_BIN`, `NVM_SYMLINK`; `NVM_DIR/versions/node/*/bin` or `~/.nvm/versions/node/*/bin`; Windows `NVM_HOME/*`; `FNM_MULTISHELL_PATH`; `FNM_DIR/node-versions/*/installation[/bin]`, `~/.local/share/fnm/node-versions/*/installation/bin`, `~/Library/Application Support/fnm/node-versions/*/installation/bin`, `%APPDATA%/fnm/node-versions/*/installation`; asdf `installs/nodejs/*/bin`, mise `installs/node/*/bin`. Only SemVer directory names are scanned. [nvm](https://github.com/nvm-sh/nvm), [fnm](https://github.com/Schniz/fnm/blob/master/docs/commands.md), [asdf](https://asdf-vm.com/manage/configuration.html), [mise](https://mise.jdx.dev/directories.html). |
71
+ | Windows native links / npm shims | `%LOCALAPPDATA%/Microsoft/WinGet/Links` ([Claude WinGet](https://code.claude.com/docs/en/setup)). `.exe` precedes `.cmd` per directory. A `.cmd` is never sent to a shell: resolve only the adjacent official npm `node_modules/@anthropic-ai/claude-code/cli.js` or `node_modules/@openai/codex/bin/codex.js`, run it with the verified standalone Node. Missing JS is a named failure, not a guessed shell command. |
72
+
73
+ HOME is the host user's HOME/USERPROFILE. Honor existing `CLAUDE_CONFIG_DIR` / `CODEX_HOME` environment paths, otherwise use `~/.claude` / `~/.codex`. These directories must already exist; discovery never creates them, reads their contents, or reads credentials, sessions or private configuration files. Only official read commands observe plugin inventory. The named profile scope is not a machine-wide inventory. Electron hosts retain the existing verified standalone Node requirement.
67
74
 
68
- No website body can set these fields, executable arguments, environment or filesystem paths. No PATH/profile discovery or direct reading of private authentication/chat/config inventories is implemented. An absent/invalid reader is an actionable unknown, not “not installed”. Missing directories are not created. Windows readers are explicitly unsupported in this implementation; runtime Core reads still work. Electron hosts need the existing verified standalone `nodeBinary` for the process supervisor; absent Node is named unknown, not an Electron workaround.
75
+ Named automatic read budgets are `INSTALLATION_READ_BUDGET.timeoutMs=2000` and `maxOutputBytes=20000`, retaining the existing installation-command fixtures' values without extending deadlines or adding retries. **待追认:** the former product configuration had required budgets but no defaults; adopting those existing fixture values for automatic product reads requires owner acknowledgement. They are code constants documented here, with no manual reader form.
69
76
 
70
- Exact verified protocols (other CLI versions return `AGENT_READER_VERSION_UNSUPPORTED`):
77
+ Minimum CLI version and fixed syntax (no new upper bound):
71
78
 
72
- | Channel | Verified `--version` result | Fixed subsequent read argv |
79
+ | Channel | Minimum version / accepted `--version` syntax | Fixed subsequent read argv |
73
80
  |---|---|---|
74
- | Claude Code | `2.1.301 (Claude Code)` | `plugin list --json`; `plugin marketplace list --json` |
75
- | Codex CLI | `codex-cli 0.162.1` | `plugin list --json --disable remote_plugin`; `plugin marketplace list --json --disable remote_plugin` |
81
+ | Claude Code | `>=2.1.301`, `MAJOR.MINOR.PATCH (Claude Code)` | `plugin list --json`; `plugin marketplace list --json` |
82
+ | Codex CLI | `>=0.162.1`, `codex-cli MAJOR.MINOR.PATCH` | `plugin list --json --disable remote_plugin`; `plugin marketplace list --json --disable remote_plugin` |
76
83
 
77
- The Codex flag was checked through this exact CLI's public `features list`; it excludes remote directory reads. A version match is necessary but not sufficient: both JSON shapes must validate. Claude uses the official installed array's `id`, user `scope`, enabled/version and load diagnostics. Other project-only target rows are unknown for this reader. Codex uses the official `installed` array and its qualified identity, installed/enabled/version. The exact target marketplace must match the registered HTTPS source in marketplace list; Codex's per-install `marketplaceSource` must also be present, supported and consistent. A local/different/unreadable source, duplicate/conflicting target or malformed/truncated/empty raw output is unknown. Only a complete valid clean inventory with no target in the configured scope is not-installed. A known install may have version null; consumers must show version unknown. Official load/payload issues produce incomplete, not successful readiness. No plugin MCP/hook definitions, unrelated inventory or CLI raw diagnostics are returned.
84
+ Malformed version output is `AGENT_READER_VERSION_INVALID`; below-minimum versions are `AGENT_READER_VERSION_UNSUPPORTED`; JSON parse/shape failure is `AGENT_INVENTORY_INVALID`. New versions such as 2.1.319 / 0.167.0 must pass exactly the existing inventory validation.
78
85
 
79
- Commands run with an empty stdin in a disposable neutral cwd, only named HOME/config/platform paths, no inherited model/API keys, proxy/auth/askpass/Node preload, and disabled Git credential helpers. No model/install/upgrade/marketplace refresh command runs. A detached IPC reaper owns the neutral cwd outside a separate live command-group anchor. The anchor remains alive until output closes; abort, budget exhaustion, overflow, provider disposal or parent IPC disconnect makes the surviving reaper terminate only that live anchor's group. The reaper waits for group close before removing the cwd, so active writers cannot race deletion, including after caller death. No released PID/PGID or arbitrary process name is used. The caller also performs exact-path cleanup when alive. There is no retry or stale-result cache. Automatic recovery after an internal reaper/anchor itself is externally SIGKILLed or suffers a native crash is outside this lifecycle guarantee; an external intervention can leave command processes or temporary files and is never reported as an installed/absent inventory.
86
+ The Codex flag was checked through this exact CLI's public `features list`; it excludes remote directory reads. Passing the version floor is necessary but not sufficient: both JSON shapes must validate. Claude uses the official installed array's `id`, user `scope`, enabled/version and load diagnostics. Other project-only target rows are unknown for this reader. Codex uses the official `installed` array and its qualified identity, installed/enabled/version. The exact target marketplace must match the registered HTTPS source in marketplace list; Codex's per-install `marketplaceSource` must also be present, supported and consistent. A local/different/unreadable source, duplicate/conflicting target or malformed/truncated/empty raw output is unknown. Only a complete valid clean inventory with no target in the observed user scope is not-installed. A known install may have version null; consumers must show version unknown. Official load/payload issues produce incomplete, not successful readiness. No plugin MCP/hook definitions, unrelated inventory or CLI raw diagnostics are returned.
87
+
88
+ Commands run with an empty stdin in a disposable neutral cwd, only named HOME/config/platform paths, only the selected inherited/recovered absolute PATH plus allowlisted manager and standard XDG directory variables, no inherited model/API keys, proxy/auth/askpass/Node preload, and disabled Git credential helpers. The verified Node and selected executable directories prefix the selected PATH; retain its manager binary directories, so an official asdf shim can resolve `asdf`. Preserve existing XDG_CONFIG_HOME/CACHE_HOME/DATA_HOME/STATE_HOME/RUNTIME_DIR and CONFIG_DIRS/DATA_DIRS; absent variables stay absent and retain manager OS defaults. The supervisor never overwrites XDG paths with empty temporary directories. Only TMPDIR/TMP/TEMP and cwd use the owned disposable directory. Manager paths additionally include ASDF_CONFIG_FILE, MISE_CONFIG_DIR/GLOBAL_CONFIG_FILE/CACHE_DIR/STATE_DIR alongside the earlier manager-home keys; credentials, proxy, Node preloads and askpass remain excluded. Recovered shell manager/XDG paths follow the same allowlist; there is no fallback to a different installation. No model/install/upgrade/marketplace refresh command runs. On POSIX, a detached IPC reaper owns the neutral cwd outside a separate live command-group anchor. The anchor remains alive until output closes; abort, budget exhaustion, overflow, provider disposal or parent IPC disconnect makes the surviving reaper terminate only that live anchor's group. The reaper waits for group close before removing the cwd, so active writers cannot race deletion, including after caller death. No released PID/PGID or arbitrary process name is used. The caller also performs exact-path cleanup when alive. On Windows the same live anchor/reaper ownership uses the OS taskkill tree operation for its precise owned anchor; no process-name match or command-shell execution. Windows/Linux runtime evidence is separate from macOS fixture execution. There is no retry or stale-result cache. Automatic recovery after an internal reaper/anchor itself is externally SIGKILLed or suffers a native crash is outside this lifecycle guarantee; an external intervention can leave command processes or temporary files and is never reported as an installed/absent inventory.
80
89
 
81
90
  ## Refusals, unknown reasons, and lifecycle
82
91
 
@@ -86,7 +95,7 @@ Per-scope unknown reasons always include a safe code/message/action:
86
95
 
87
96
  - `CORE_SUITE_DECLARATION_MISSING`, `CORE_SUITE_DECLARATION_INVALID`, `CORE_SUITE_READ_FAILED`, `CORE_SUITE_DECLARATION_UNKNOWN`; incomplete: `CORE_SUITE_CURRENT_INCOMPLETE`, plus Core blocked codes.
88
97
  - `OTHER_RUNTIME_NOT_OBSERVED`, `CODEX_DESKTOP_SCOPE_UNOBSERVABLE`.
89
- - `AGENT_READER_NOT_CONFIGURED`, `AGENT_READER_CONFIG_INVALID`, `AGENT_READER_VERSION_UNSUPPORTED`, `AGENT_READER_PLATFORM_UNSUPPORTED`, `AGENT_READER_NODE_REQUIRED`.
98
+ - `AGENT_READER_VERSION_INVALID`, `AGENT_READER_VERSION_UNSUPPORTED`, `AGENT_READER_NODE_REQUIRED`, `AGENT_SHELL_ENV_UNAVAILABLE`, `AGENT_READER_ENTRYPOINT_UNAVAILABLE`, `AGENT_READER_DISCOVERY_INVALID`.
90
99
  - `AGENT_PROFILE_UNAVAILABLE`, `AGENT_READER_UNAVAILABLE`, `AGENT_READER_PERMISSION_DENIED`, `AGENT_READER_TIMEOUT`, `AGENT_READER_OUTPUT_LIMIT`, `AGENT_READER_DIAGNOSTIC`, `AGENT_READER_CLEANUP_FAILED`, `AGENT_READER_FAILED`.
91
100
  - `AGENT_INVENTORY_INVALID`, `AGENT_INVENTORY_CONFLICT`, `AGENT_SCOPE_UNOBSERVABLE`, `AGENT_MARKETPLACE_SOURCE_UNAVAILABLE`, `AGENT_MARKETPLACE_SOURCE_MISMATCH`.
92
101
  - An installed record with unobserved version uses `AGENT_VERSION_UNAVAILABLE`; incomplete uses `AGENT_PLUGIN_LOAD_FAILED` / `AGENT_PLUGIN_PAYLOAD_MISSING`.
@@ -96,3 +105,5 @@ Start each explicit detection by clearing previous observations. Cancel fetch wh
96
105
  Official command references: [Claude Code plugin commands](https://code.claude.com/docs/en/plugins/cli-reference), [Codex CLI plugin reference](https://learn.chatgpt.com/docs/cli/reference). [OpenAI local plugin scope documentation](https://developers.openai.com/plugins/build/plugins) describes multiple client scopes; CLI installation is not used here as proof of the desktop App.
97
106
 
98
107
  For DSH/HanaMesh runtime observations, versionKind is suite-bundle and version is the installed @hanamesh/hanamesh version. Component rows retain their physical versions. Agent distribution observations retain their existing semantics.
108
+
109
+ R115 regression fixtures execute real non-TTY Bash, asdf 0.20.2 official shim and mise 2026.11.4 with default/custom/shell-defined global XDG selection. Tool provenance, hashes and the explicit test-only binary inputs are in source `tests/fixtures/installation-managers/README.md`; binaries do not enter the npm package.