@hanamesh/dsh-app-host 0.6.21 → 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.
Files changed (39) hide show
  1. package/README.md +19 -15
  2. package/dist/client-ui.js +8 -16
  3. package/dist/dsh-compat.js +1 -1
  4. package/dist/dsh.d.ts +1 -2
  5. package/dist/dsh.js +5 -9
  6. package/dist/git-command-worker.js +12 -0
  7. package/dist/git-process.js +8 -3
  8. package/dist/git-worker.js +37 -7
  9. package/dist/install-target/suite.js +3 -3
  10. package/dist/installation-status.d.ts +0 -8
  11. package/dist/library/diagnostics.js +0 -1
  12. package/dist/library/github-target.js +1 -1
  13. package/dist/library/handoff.js +3 -11
  14. package/dist/library/install-target-http.js +39 -0
  15. package/dist/library/install.js +13 -60
  16. package/dist/library/installation-cli.js +41 -16
  17. package/dist/library/installation-command-worker.js +3 -2
  18. package/dist/library/installation-discovery.js +95 -0
  19. package/dist/library/installation-status.js +14 -13
  20. package/dist/library/installation-worker.js +72 -14
  21. package/dist/library/locate.js +4 -13
  22. package/dist/library/routes.js +2 -10
  23. package/dist/library/service.js +5 -5
  24. package/dist/library/suite.js +9 -10
  25. package/dist/node-runtime/service.js +4 -4
  26. package/dist/router/sources.js +5 -2
  27. package/dist/runtime.js +15 -1
  28. package/dist/store.js +12 -5
  29. package/dist/windows-job.js +30 -0
  30. package/dist/windows-job.ps1 +75 -0
  31. package/docs/APP_PACKAGE.md +1 -1
  32. package/docs/CONTRACT.md +7 -7
  33. package/docs/DSH_COMPATIBILITY.md +1 -1
  34. package/docs/INSTALLATION_STATUS.md +29 -18
  35. package/docs/INSTALL_TARGET.md +9 -5
  36. package/docs/LIBRARY.md +3 -14
  37. package/docs/LICENSES.json +207 -69
  38. package/docs/SECURITY.md +16 -1
  39. package/package.json +41 -27
@@ -8,7 +8,7 @@ import { redact } from '../runtime.js';
8
8
  import { AppHostError, requireCondition, SerialQueue } from '../errors.js';
9
9
  import {noteFailurePhase} from './diagnostics.js';
10
10
  import { validateDefinition } from '../descriptor.js';
11
- import { itemKind } from './catalog.js';
11
+ import { isApplication } from './catalog.js';
12
12
  import { profileSnapshot, runtimeSnapshot, profileChecks, runtimeChecks, packageRestoreArgs, keepOriginals } from './recovery.js';
13
13
  import {SUITE_BUNDLE,prepareSuiteUpgrade,readSuiteStatus,createCoreSuiteAdapter} from './suite.js';
14
14
  import {packageInstallTarget,installTargetName,pinnedGithubSpec} from './github-target.js';
@@ -17,7 +17,6 @@ import {packageInstallTarget,installTargetName,pinnedGithubSpec} from './github-
17
17
  export const PROTECTED_PACKAGES=Object.freeze([SUITE_BUNDLE,'@hanamesh/core','@hanamesh/usage','@hanamesh/dsh-app-host','hanamesh-brand']);
18
18
  const blocked=new Set(PROTECTED_PACKAGES);
19
19
  const packageName=value=>typeof value==='string'&&/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value);
20
- const versionText=value=>typeof value==='string'&&/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value);
21
20
  async function json(path){return JSON.parse(await readFile(path,'utf8'));}
22
21
  const defaultSpawn=spawnGitSafe;
23
22
 
@@ -46,8 +45,8 @@ async function clearGeneratedRuntimeCache(result,runtimeRoot){
46
45
  }
47
46
 
48
47
  /** Package mutations use the public CLI in an owned monitored process; upstream's in-process manager has no scoped env/monitor port. */
49
- export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBinary,dshBin,desktopCli,desktopLauncher,pluginManagerVersion,registryRules,registry,allowPrerelease=false,
50
- fetchImpl=globalThis.fetch,spawn=defaultSpawn,provision,remove,ledger,coreSuitePlan,coreService=()=>undefined,suiteSourceMode=()=>'configured',suiteSourceService=()=>undefined,pluginManager=()=>undefined,emit=()=>{},profileWriter=new SerialQueue()}){
48
+ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBinary,dshBin,desktopCli,desktopLauncher,pluginManagerVersion,registry,
49
+ spawn=defaultSpawn,provision,remove,ledger,coreSuitePlan,coreService=()=>undefined,suiteSourceMode=()=>'configured',suiteSourceService=()=>undefined,emit=()=>{},profileWriter=new SerialQueue()}){
51
50
  requireCondition(typeof profileDir==='string'&&resolve(profileDir)===profileDir,'PROFILE_DIR_REQUIRED','library.profileDir must be an absolute path.');
52
51
  requireCondition(typeof dataRoot==='string'&&resolve(dataRoot)===dataRoot,'INVALID_ROOT','dataRoot must be absolute.');
53
52
  const launcherAvailable=typeof desktopLauncher?.command==='string'&&resolve(desktopLauncher.command)===desktopLauncher.command&&typeof desktopLauncher.home==='string'&&join(desktopLauncher.home,'profiles',profileName)===profileDir;
@@ -66,58 +65,15 @@ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBina
66
65
  const runtimeApi=async name=>injected[name]??(await defaults())[name];
67
66
  async function installed(packageNameValue){const root=join(profileDir,'node_modules',...packageNameValue.split('/')),pkg=await json(join(root,'package.json'));const appFile=typeof pkg.hanamesh?.app==='string'?pkg.hanamesh.app:'app.json';return{pkg,definition:validateDefinition(await json(join(root,appFile)))};}
68
67
  const commandPath=command=>[dirname(command),...(process.platform==='win32'?(process.env.PATH??process.env.Path??'').split(delimiter):['/opt/homebrew/bin','/usr/local/bin','/usr/bin','/bin'])].join(delimiter);
69
- async function run(args){try{requireCondition(cliAvailable,'DSH_CLI_UNAVAILABLE','The official dsh plugin CLI is unavailable in this host.',{need:['nodeBinary','dshBin']},503);
70
- const options={cwd:profileDir,workerNode:supervisorNode,env:safeGitEnv({HOME:process.env.HOME,DSH_HOME:process.env.DSH_HOME,LANG:process.env.LANG??'C.UTF-8',PATH:commandPath(nodeBinary??desktopLauncher.command)})},tail=['plugin','--profile',profileName,...args];
68
+ const run=args=>runCli(['plugin','--profile',profileName,...args]);
69
+ async function runCli(tail){try{requireCondition(cliAvailable,'DSH_CLI_UNAVAILABLE','The official dsh plugin CLI is unavailable in this host.',{need:['nodeBinary','dshBin']},503);
70
+ const options={cwd:profileDir,workerNode:supervisorNode,env:safeGitEnv({HOME:process.env.HOME,DSH_HOME:process.env.DSH_HOME,LANG:process.env.LANG??'C.UTF-8',PATH:commandPath(nodeBinary??desktopLauncher.command)})};
71
71
  if(launcherAvailable)options.env=safeGitEnv({...options.env,DSH_HOME:desktopLauncher.home,PATH:commandPath(desktopLauncher.command)});
72
72
  const result=await spawn(launcherAvailable?desktopLauncher.command:nodeBinary,launcherAvailable?tail:[...(desktopCli?['--expose-internals',fileURLToPath(new URL('./desktop-cli.js',import.meta.url)),desktopCli.entry,desktopCli.runtimeDir,desktopCli.supportDir]:[dshBin]),...tail],options);
73
73
  const clean=text=>redact(stripVTControlCharacters(String(text??''))).replace(/(https?:\/\/)[^/\s@]+@/g,'$1[REDACTED]@').slice(-2000);
74
74
  const stdout=clean(result.stdout),stderr=clean(result.stderr);
75
75
  requireCondition(result.code===0,'DSH_PLUGIN_FAILED','DSH plugin operation failed.',{code:result.code,signal:result.signal,stderr,diagnostic:[stdout,stderr].filter(Boolean).join('\n')||`DSH plugin exited with code ${result.code}${result.signal?' ('+result.signal+')':''}.`},502);return result;
76
76
  }catch(error){noteFailurePhase(error,'CLI run');throw error;}}
77
- /** Bound the entire pre-install lookup, including public source discovery and metadata readback. */
78
- async function latest(name){
79
- const controller=new AbortController();let timer,phase='registry configuration';
80
- const timeout=new Promise((_,reject)=>{timer=setTimeout(()=>{
81
- const error=new AppHostError('REGISTRY_LOOKUP_TIMEOUT','Registry lookup timed out after 30000ms. Check DSH registry settings and network, then retry explicitly.',{},504);
82
- noteFailurePhase(error,phase);controller.abort(error);reject(error);
83
- },30000);});
84
- async function lookup(){
85
- const manager=pluginManager();
86
- requireCondition(typeof manager?.registries==='function','REGISTRY_CONFIG_UNAVAILABLE','DSH does not expose registry configuration. Restore the public plugin manager before installing.',{},503);
87
- // Public DSH ports own ordering, normalization, de-duplication and private-registry isolation.
88
- const ruleMessage='DSH does not expose its registry rules. Restore the public plugin manager before installing.';
89
- requireCondition(typeof registryRules==='function','REGISTRY_CONFIG_UNAVAILABLE',ruleMessage,{},503);
90
- let rules;try{rules=await registryRules();}catch(cause){const error=new AppHostError('REGISTRY_CONFIG_UNAVAILABLE',ruleMessage,{},503);error.cause=cause;throw error;}
91
- const {registryPlan,attributeFailure}=rules??{};
92
- requireCondition(typeof registryPlan==='function'&&typeof attributeFailure==='function','REGISTRY_CONFIG_UNAVAILABLE',ruleMessage,{},503);
93
- controller.signal.throwIfAborted();const configured=await manager.registries();controller.signal.throwIfAborted();
94
- const plan=registryPlan(registry,configured);
95
- for(let index=0;index<plan.length;index++){
96
- const selected=plan[index],source=selected??configured.resolved;let failureKind='unknown';
97
- controller.signal.throwIfAborted();phase='registry configuration';
98
- requireCondition(typeof source==='string','REGISTRY_CONFIG_UNAVAILABLE','DSH could not resolve its configured registry. Check DSH registry settings.',{},503);
99
- const base=new URL(source);requireCondition(['https:','http:'].includes(base.protocol),'REGISTRY_DENIED','Registry URL is invalid.');
100
- try{
101
- phase='registry fetch';let response;
102
- try{response=await fetchImpl(new URL(`${base.href.replace(/\/$/,'')}/${encodeURIComponent(name)}/latest`),{headers:{accept:'application/json','accept-encoding':'identity'},signal:controller.signal});}
103
- catch(error){failureKind='network';throw error;}
104
- controller.signal.throwIfAborted();if(!response.ok){failureKind=response.status===404?'not-found':response.status>=500?'network':'unknown';throw new AppHostError('REGISTRY_LOOKUP_FAILED','Registry latest lookup failed. Check DSH registry settings and network, then retry explicitly.',{status:response.status},502);}
105
- phase='registry readback';const metadata=await response.json();controller.signal.throwIfAborted();const version=metadata.version;
106
- requireCondition(versionText(version)&&(allowPrerelease||!version.includes('-')),'UNSTABLE_VERSION','Registry latest must be an exact stable version.');
107
- return{version,registry:selected};
108
- }catch(error){
109
- // Ask only fallbacks configured by DSH, and only when its public rule permits another source.
110
- if(!controller.signal.aborted&&index+1<plan.length&&attributeFailure(failureKind,'',{kind:'registry',name,spec:name})==='registry')continue;
111
- throw error;
112
- }
113
- }
114
- }
115
- try{return await Promise.race([lookup(),timeout]);}
116
- catch(cause){
117
- const error=cause instanceof AppHostError?cause:new AppHostError('REGISTRY_LOOKUP_FAILED','Registry lookup failed. Check DSH registry settings and network, then retry explicitly.',{},502);
118
- if(error!==cause)error.cause=cause;noteFailurePhase(error,phase);throw error;
119
- }finally{clearTimeout(timer);}
120
- }
121
77
  /**
122
78
  * rc.10 compensation after a failed install: the package goes back to its baseline spec through the official CLI, runtime
123
79
  * items this operation newly claimed are removed by ownership, a replaced baseline item is provisioned again from the
@@ -162,8 +118,7 @@ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBina
162
118
  try{
163
119
  base=await profileSnapshot(profileDir,name);
164
120
  stage='before-add';let version;const target=item.installTarget,github=target?.source==='github';
165
- if(target?.source==='npm'){version=target.version;installRegistry=registry??null;}
166
- else if(!github)({version,registry:installRegistry}=await latest(name));
121
+ if(target.source==='npm'){version=target.version;installRegistry=registry??null;}
167
122
  const spec=github?pinnedGithubSpec(target.repo,target.ref,target.path):`${name}@${version}`;
168
123
  const status='restart-required';
169
124
  stage='add';await run(['add','--save-exact',...(installRegistry===null?[]:[`--registry=${installRegistry}`]),...(target?.allowBuild??[]).map(entry=>`--allow-build=${entry}`),spec]);
@@ -188,7 +143,7 @@ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBina
188
143
  catch(error){emit({type:'library.install-failed',...(item.id?{itemId:item.id}:{}),packageName:name,kind,...(item.installTarget?{installTarget:item.installTarget}:{}),code:error.code??'INSTALL_FAILED',...(error.recovery?{recovery:error.recovery}:{})});throw error;}
189
144
  }
190
145
  let suitePlan;
191
- const suiteProvider=coreSuitePlan??createCoreSuiteAdapter({core:coreService,registry:registry??'https://registry.npmjs.org',suiteSourceMode,suiteSourceService});
146
+ const suiteProvider=coreSuitePlan??createCoreSuiteAdapter({core:coreService,registry,profileName,suiteSourceMode,suiteSourceService});
192
147
  async function previewSuite(){
193
148
  const next=await prepareSuiteUpgrade({coreSuitePlan:suiteProvider});
194
149
  suitePlan={...next,planId:randomUUID()};return structuredClone(suitePlan);
@@ -204,7 +159,7 @@ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBina
204
159
  requireCondition(suitePlan?.planId===planId,'SUITE_PLAN_CHANGED','升级确认已变化,请重新查看并确认。',{},409);
205
160
  const confirmed=suitePlan;suitePlan=undefined;
206
161
  const fresh=await prepareSuiteUpgrade({coreSuitePlan:suiteProvider});
207
- const identity=plan=>JSON.stringify({sourceBinding:plan.sourceBinding,bundle:plan.bundle,components:plan.components,declarationId:plan.declarationId,registry:plan.registry,selectedUpgrade:plan.selectedUpgrade,physicalBaseline:plan.physicalBaseline});
162
+ const identity=plan=>JSON.stringify({installation:plan.installation,sourceBinding:plan.sourceBinding,bundle:plan.bundle,components:plan.components,declarationId:plan.declarationId,registry:plan.registry,selectedUpgrade:plan.selectedUpgrade,physicalBaseline:plan.physicalBaseline});
208
163
  requireCondition(identity(fresh)===identity(confirmed),'SUITE_PLAN_CHANGED','套件声明或版本已变化,请重新查看并确认。',{},409);
209
164
  requireCondition(fresh.available,'SUITE_NO_UPDATE','当前没有兼容新版。',{},409);
210
165
  const before=await readSuiteStatus(profileDir);
@@ -214,7 +169,7 @@ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBina
214
169
  requireCondition(identity(executing)===identity(confirmed),'SUITE_PLAN_CHANGED','执行前套件安装包来源或声明已变化,请重新查看并确认。',{},409);
215
170
  emit({type:'library.install-started',itemId:'hanamesh:suite-upgrade',packageName:SUITE_BUNDLE,kind:'suite-upgrade'});
216
171
  try{
217
- await run(['add','--save-exact','--strict-peer-dependencies',`--registry=${fresh.registry??registry??'https://registry.npmjs.org'}`,`${SUITE_BUNDLE}@${fresh.bundle.to}`]);
172
+ await runCli(fresh.installation.args);
218
173
  const actual=await readSuiteStatus(profileDir);
219
174
  const saved=await json(join(profileDir,'package.json'));
220
175
  requireCondition(actual.status==='installed'&&actual.version===fresh.bundle.to&&saved.dependencies?.[SUITE_BUNDLE]===fresh.bundle.to&&fresh.components.every((c,i)=>actual.components[i]?.version===c.to),'INSTALL_READBACK_MISMATCH','套件升级后的版本或启用状态不符,请完全退出后按补齐命令修复。',{},502);
@@ -225,13 +180,11 @@ export function createLibraryInstaller({profileDir,profileName,dataRoot,nodeBina
225
180
  }
226
181
  });},
227
182
  async install(item,context){
228
- if(item.installTarget){const target=packageInstallTarget(item.installTarget);item={...item,installTarget:target,package:{registry:target.source,name:installTargetName(target)}};}
229
- const kind=itemKind(item);
230
- requireCondition(kind!=='listing','NOT_INSTALLABLE','Catalog item has no npm package to install.');
183
+ requireCondition(item?.installTarget,'NOT_INSTALLABLE','A fixed catalog installation target is required.',{reason:'fixed-install-target-required'});
184
+ const target=packageInstallTarget(item.installTarget);item={...item,installTarget:target,package:{registry:target.source,name:installTargetName(target)}};
185
+ const kind=isApplication(item)?'application':'plugin';
231
186
  return await installAs(item,kind,context);
232
187
  },
233
- /** rc.28: install a plugin by package name (already verified against the catalog by the service). */
234
- async installPlugin({packageName:name},context){return await installAs({package:{registry:'npm',name}},'plugin',context);},
235
188
  async provisionRuntime({appId,packageName:installedName,runtimeItem}){return await profileWriter.run(async()=>{const record=await installed(installedName);requireCondition(record.definition.id===appId,'APP_ID_MISMATCH','Installed package app id does not match.');const runtime=record.definition.deployments.map(deployment=>deployment.runtime).find(value=>value?.item===runtimeItem);requireCondition(runtime,'RUNTIME_NOT_DECLARED','Runtime item is not declared.');const result=await (await runtimeApi('provision'))(runtime.manifest,{root:runtimeRoot(appId),only:[runtime.item],onProgress:event=>emit({type:'library.provision-progress',appId,...event})});return{status:'restart-required',appId,packageName:installedName,result};});},
236
189
  async uninstall({packageName:installedName,appId,runtimeItem,running}){permitted(installedName);requireCondition(!running,'INSTANCE_IN_USE','Stop all application instances before uninstalling.',{},409);return await profileWriter.run(async()=>{await run(['remove',installedName]);if(runtimeItem){const result=await (await runtimeApi('remove'))(runtimeItem,{root:runtimeRoot(appId)});await clearGeneratedRuntimeCache(result,runtimeRoot(appId));}return{status:'restart-required',kind:'application',appId,packageName:installedName,dataPreserved:true};});},
237
190
  /** rc.28: `dsh plugin remove <name>`; the suite itself (PROTECTED_PACKAGES) is refused with PACKAGE_DENIED. */
@@ -5,11 +5,33 @@ import {stat,realpath,rm} from 'node:fs/promises';
5
5
  import {tmpdir} from 'node:os';
6
6
  import {dirname,isAbsolute,delimiter} from 'node:path';
7
7
  import {AppHostError} from '../errors.js';
8
- const fail=code=>{throw new AppHostError(code,'The configured official installation reader could not complete.',{},503)};
9
- export const SUPPORTED_READER_VERSIONS=Object.freeze({claude:'2.1.301 (Claude Code)',codex:'codex-cli 0.162.1'});
8
+ import semver from 'semver';
9
+ export const INSTALLATION_MANAGER_ENV_KEYS=Object.freeze(['VOLTA_HOME','ASDF_DATA_DIR','ASDF_CONFIG_FILE','MISE_DATA_DIR','MISE_CONFIG_DIR','MISE_GLOBAL_CONFIG_FILE','MISE_CACHE_DIR','MISE_STATE_DIR','FNM_DIR','NVM_DIR','NVM_HOME','NVM_SYMLINK','PNPM_HOME']);
10
+ export const INSTALLATION_ENV_KEYS=Object.freeze([...INSTALLATION_MANAGER_ENV_KEYS,'PATH','XDG_CONFIG_HOME','XDG_CACHE_HOME','XDG_DATA_HOME','XDG_STATE_HOME','XDG_RUNTIME_DIR','XDG_CONFIG_DIRS','XDG_DATA_DIRS']);
11
+ const pathListKeys=new Set(['PATH','XDG_CONFIG_DIRS','XDG_DATA_DIRS']);
12
+ const absolutePath=value=>typeof value==='string'&&isAbsolute(value)&&!/[\x00-\x1f]/.test(value);
13
+ export function installationEnvironment(source,platform=process.platform){
14
+ const separator=platform==='win32'?';':':',result={};
15
+ for(const key of INSTALLATION_ENV_KEYS){const value=key==='PATH'?source.PATH??source.Path:source[key];
16
+ if(typeof value!=='string'||/[\x00-\x1f]/.test(value))continue;
17
+ if(pathListKeys.has(key))result[key]=value.split(separator).filter(absolutePath).join(separator);
18
+ else if(absolutePath(value))result[key]=value;
19
+ }
20
+ return result;
21
+ }
22
+ const fail=code=>{throw new AppHostError(code,'The automatically discovered official installation reader could not complete.',{},503)};
23
+ export const MINIMUM_READER_VERSIONS=Object.freeze({claude:'2.1.301',codex:'0.162.1'});
24
+ export function readerVersionFailure(channel,output){
25
+ const pattern=channel==='claude'?/^(\d+\.\d+\.\d+) \(Claude Code\)$/:/^codex-cli (\d+\.\d+\.\d+)$/;
26
+ const version=pattern.exec(output.trim())?.[1];
27
+ if(!version||semver.valid(version)!==version)return 'AGENT_READER_VERSION_INVALID';
28
+ return semver.gte(version,MINIMUM_READER_VERSIONS[channel])?null:'AGENT_READER_VERSION_UNSUPPORTED';
29
+ }
10
30
  export function validReader(value){
11
31
  if(!value||typeof value!=='object'||Array.isArray(value))return false;
12
- const keys=['executable','home','configDir','pluginName','marketplace','marketplaceUrl','timeoutMs','maxOutputBytes'];
32
+ const keys=['executable','home','configDir','pluginName','marketplace','marketplaceUrl','timeoutMs','maxOutputBytes','nodeScript','runtimeEnv'];
33
+ if(value.nodeScript!==undefined&&typeof value.nodeScript!=='boolean')return false;
34
+ if(value.runtimeEnv!==undefined&&(!value.runtimeEnv||typeof value.runtimeEnv!=='object'||Array.isArray(value.runtimeEnv)||!Object.entries(value.runtimeEnv).every(([key,path])=>INSTALLATION_ENV_KEYS.includes(key)&&typeof path==='string'&&!/[\x00-\x1f]/.test(path)&&(pathListKeys.has(key)?path===''||path.split(delimiter).every(absolutePath):absolutePath(path)))))return false;
13
35
  if(Object.keys(value).some(key=>!keys.includes(key))||!['executable','home','configDir'].every(key=>typeof value[key]==='string'&&isAbsolute(value[key])&&!/[\x00-\x1f]/.test(value[key])))return false;
14
36
  if(!['pluginName','marketplace'].every(key=>typeof value[key]==='string'&&/^[a-z0-9][a-z0-9._-]{0,127}$/.test(value[key])))return false;
15
37
  try{const url=new URL(value.marketplaceUrl);if(url.protocol!=='https:'||url.username||url.password||url.hash||url.search||url.href!==value.marketplaceUrl)return false}catch{return false}
@@ -17,32 +39,35 @@ export function validReader(value){
17
39
  }
18
40
  export async function runOfficialInstallationCommand({channel,reader,args,signal,workerNode}){
19
41
  if(signal?.aborted)fail('INSTALLATION_STATUS_CANCELLED');
20
- if(process.platform==='win32')fail('AGENT_READER_PLATFORM_UNSUPPORTED');
21
- if(!validReader(reader))fail('AGENT_READER_CONFIG_INVALID');
42
+ if(!validReader(reader))fail('AGENT_READER_DISCOVERY_INVALID');
22
43
  const nodeRuntime=guardNodeBinary({workerNode});if(!nodeRuntime)fail('AGENT_READER_NODE_REQUIRED');
23
44
  for(const path of [reader.home,reader.configDir]){let value;try{value=await stat(path)}catch{fail('AGENT_PROFILE_UNAVAILABLE')}if(!value.isDirectory())fail('AGENT_PROFILE_UNAVAILABLE')}
24
- const temporaryRoot=await realpath(tmpdir());let cwd;
25
- // An exact configuration directory is explicit. Inherited model/API tokens, proxy, shell startup and askpass are absent.
26
- const env={PATH:[dirname(nodeRuntime),'/usr/bin','/bin','/usr/local/bin'].join(delimiter),HOME:reader.home,USERPROFILE:reader.home,
27
- ...(channel==='claude'?{CLAUDE_CONFIG_DIR:reader.configDir,CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:'1',DISABLE_AUTOUPDATER:'1'}:{CODEX_HOME:reader.configDir}),
28
- GIT_TERMINAL_PROMPT:'0',GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null',GIT_CONFIG_COUNT:'1',GIT_CONFIG_KEY_0:'credential.helper',GIT_CONFIG_VALUE_0:''};
45
+ const temporaryRoot=await realpath(tmpdir());let cwd,cleanupConfirmed=false;
46
+ // Keep the selected user's manager resolution conditions, without model/API tokens, proxy, preloads or askpass.
47
+ const systemRoot=process.platform==='win32'?(process.env.SystemRoot??'C:\\Windows'):undefined;
48
+ const inheritedPath=(reader.runtimeEnv?.PATH??'').split(delimiter).filter(absolutePath);
49
+ const env={...reader.runtimeEnv,PATH:[...new Set([dirname(nodeRuntime),dirname(reader.executable),...inheritedPath,...(systemRoot?[systemRoot,`${systemRoot}\\System32`]:['/usr/bin','/bin','/usr/local/bin'])])].join(delimiter),HOME:reader.home,USERPROFILE:reader.home,
50
+ ...(systemRoot?{SystemRoot:systemRoot,LOCALAPPDATA:process.env.LOCALAPPDATA??`${reader.home}\\AppData\\Local`,APPDATA:process.env.APPDATA??`${reader.home}\\AppData\\Roaming`}:{}),
51
+ ...(channel==='claude'?{CLAUDE_CONFIG_DIR:reader.configDir,CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:'1',DISABLE_AUTOUPDATER:'1'}:channel==='codex'?{CODEX_HOME:reader.configDir}:{}),
52
+ GIT_TERMINAL_PROMPT:'0',GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:systemRoot?'NUL':'/dev/null',GIT_CONFIG_COUNT:'1',GIT_CONFIG_KEY_0:'credential.helper',GIT_CONFIG_VALUE_0:''};
29
53
  try{return await new Promise((resolve,reject)=>{
30
54
  if(signal?.aborted){reject(new AppHostError('INSTALLATION_STATUS_CANCELLED','Installation read was cancelled.',{},499));return}
31
- const child=fork(new URL('./installation-worker.js',import.meta.url),[],{cwd:temporaryRoot,env,execPath:nodeRuntime,execArgv:[],detached:true,stdio:['ignore','pipe','pipe','ipc']});
55
+ // The reaper must survive caller death on Windows too; libuv kills non-detached children with their creator.
56
+ const child=fork(new URL('./installation-worker.js',import.meta.url),[],{cwd:temporaryRoot,env,execPath:nodeRuntime,execArgv:[],detached:true,windowsHide:true,stdio:['ignore','pipe','pipe','ipc']});
32
57
  const chunks=[];let total=0,stderr=false,fault,exited=false,message;
33
- const kill=code=>{fault??=code;if(!exited&&child.pid){child.kill('SIGTERM')}};
58
+ const kill=code=>{fault??=code;if(!exited&&child.pid){if(process.platform==='win32'){if(child.connected)child.send({abort:true},()=>{})}else child.kill('SIGTERM')}};
34
59
  const abort=()=>kill('INSTALLATION_STATUS_CANCELLED');signal?.addEventListener('abort',abort,{once:true});
35
60
  const timer=setTimeout(()=>kill('AGENT_READER_TIMEOUT'),reader.timeoutMs);
36
61
  child.stdout.on('data',chunk=>{total+=chunk.length;if(total>reader.maxOutputBytes)kill('AGENT_READER_OUTPUT_LIMIT');else chunks.push(chunk)});
37
62
  child.stderr.on('data',chunk=>{stderr=true;total+=chunk.length;if(total>reader.maxOutputBytes)kill('AGENT_READER_OUTPUT_LIMIT')});
38
- child.on('message',value=>{if(typeof value.directory==='string')cwd=value.directory;else message=value});
63
+ child.on('message',value=>{if(typeof value.directory==='string')cwd=value.directory;else{message=value;cleanupConfirmed=value.cleanupComplete===true}});
39
64
  child.once('exit',()=>{exited=true});
40
65
  child.once('error',error=>{fault??=error.code==='EACCES'?'AGENT_READER_PERMISSION_DENIED':'AGENT_READER_UNAVAILABLE'});
41
66
  child.once('close',code=>{exited=true;clearTimeout(timer);signal?.removeEventListener('abort',abort);
42
- if(fault||message?.error||message?.result!==0||stderr)reject(new AppHostError(fault??message?.error??(stderr?'AGENT_READER_DIAGNOSTIC':'AGENT_READER_FAILED'),'The configured official reader did not return a complete clean result.',{},503));
67
+ if(fault||message?.error||message?.result!==0||(stderr&&channel!=='shell'))reject(new AppHostError((cwd&&!cleanupConfirmed?'AGENT_READER_CLEANUP_FAILED':fault??message?.error)??(stderr&&channel!=='shell'?'AGENT_READER_DIAGNOSTIC':'AGENT_READER_FAILED'),'The official reader did not return a complete successful result.',{},503));
43
68
  else resolve(Buffer.concat(chunks).toString('utf8'));
44
69
  });
45
- if(child.connected)child.send({executable:reader.executable,args,temporaryRoot,env},()=>{});
70
+ if(child.connected)child.send({executable:reader.nodeScript?nodeRuntime:reader.executable,args:reader.nodeScript?[reader.executable,...args]:args,temporaryRoot,env},()=>{});
46
71
  if(signal?.aborted)abort();
47
- })}finally{if(cwd)await rm(cwd,{recursive:true,force:true})}
72
+ })}finally{if(cwd&&cleanupConfirmed)await rm(cwd,{recursive:true,force:true,maxRetries:4,retryDelay:25})}
48
73
  }
@@ -1,7 +1,8 @@
1
1
  /** Live command-group anchor. Its separate reaper removes the cwd only after this group has ended. */
2
- import {spawn} from 'node:child_process';
2
+ import {spawn,spawnSync} from 'node:child_process';
3
+ import {join} from 'node:path';
3
4
  let started=false,reported=false;
4
- const stopOwnedGroup=()=>process.kill(-process.pid,'SIGKILL');
5
+ const stopOwnedGroup=()=>{if(process.platform==='win32'){spawnSync(join(process.env.SystemRoot,'System32','taskkill.exe'),['/pid',String(process.pid),'/T','/F'],{stdio:'ignore',windowsHide:true});process.exit(1)}else process.kill(-process.pid,'SIGKILL')};
5
6
  function report(payload){
6
7
  if(reported)return;reported=true;
7
8
  if(process.connected){try{process.send(payload,error=>{if(error)stopOwnedGroup()})}catch{stopOwnedGroup()}}else stopOwnedGroup();
@@ -0,0 +1,95 @@
1
+ /** Host-only discovery. Lists executable directories, never authentication/config contents. */
2
+ import {access,stat,readdir} from 'node:fs/promises';
3
+ import {constants} from 'node:fs';
4
+ import {homedir,userInfo} from 'node:os';
5
+ import {join,isAbsolute} from 'node:path';
6
+ import semver from 'semver';
7
+ import {AppHostError} from '../errors.js';
8
+ import {guardNodeBinary} from '../git-process.js';
9
+ import {runOfficialInstallationCommand,INSTALLATION_ENV_KEYS,installationEnvironment} from './installation-cli.js';
10
+
11
+ // Existing installation-command fixture budgets; automatic selection adds no retry or longer deadline.
12
+ export const INSTALLATION_READ_BUDGET=Object.freeze({timeoutMs:2000,maxOutputBytes:20000});
13
+ export const AGENT_INSTALLATION_TARGET=Object.freeze({pluginName:'hanamesh',marketplace:'hanamesh',marketplaceUrl:'https://github.com/yzsnstotz/hanamesh-plugins'});
14
+ const absolute=value=>typeof value==='string'&&isAbsolute(value)&&!/[\x00-\x1f]/.test(value);
15
+ const failure=code=>new AppHostError(code,'Automatic official CLI discovery could not complete.',{},503);
16
+ const split=(value,platform)=>(value??'').split(platform==='win32'?';':':').filter(absolute);
17
+ const unique=values=>[...new Set(values.filter(absolute))];
18
+ async function exists(file,platform){try{if(!(await stat(file)).isFile())return false;await access(file,platform==='win32'?constants.F_OK:constants.X_OK);return true}catch(error){if(['ENOENT','ENOTDIR'].includes(error.code))return false;if(['EACCES','EPERM'].includes(error.code))throw failure('AGENT_READER_PERMISSION_DENIED');throw failure('AGENT_READER_UNAVAILABLE')}}
19
+ async function versions(root,suffix){
20
+ let entries;try{entries=await readdir(root,{withFileTypes:true})}catch(error){if(['ENOENT','ENOTDIR'].includes(error.code))return[];throw failure(error.code==='EACCES'?'AGENT_READER_PERMISSION_DENIED':'AGENT_READER_UNAVAILABLE')}
21
+ return entries.filter(entry=>(entry.isDirectory()||entry.isSymbolicLink())&&semver.valid(entry.name.replace(/^v/,''))).sort((a,b)=>semver.rcompare(a.name.replace(/^v/,''),b.name.replace(/^v/,''))).map(entry=>join(root,entry.name,...suffix));
22
+ }
23
+
24
+ /** VS Code's framed login-shell environment pattern, narrowed to named infrastructure paths only. */
25
+ export async function readLoginEnvironment({home=homedir(),env=process.env,signal,workerNode,timeoutMs=INSTALLATION_READ_BUDGET.timeoutMs,runCommand=runOfficialInstallationCommand}={}){
26
+ const shell=env.SHELL??userInfo().shell;
27
+ if(!absolute(shell))throw failure('AGENT_SHELL_ENV_UNAVAILABLE');
28
+ const node=guardNodeBinary({workerNode});if(!node)throw failure('AGENT_READER_NODE_REQUIRED');
29
+ const quote=value=>"'"+value.replaceAll("'","'\\''")+"'";
30
+ const script=`const env={};for(const key of ${JSON.stringify(INSTALLATION_ENV_KEYS)})if(process.env[key]!==undefined)env[key]=process.env[key];process.stdout.write('\\0HANAMESH_ENV\\0'+JSON.stringify(env)+'\\0');`;
31
+ const output=await runCommand({channel:'shell',reader:{executable:shell,home,configDir:home,...AGENT_INSTALLATION_TARGET,...INSTALLATION_READ_BUDGET,timeoutMs,runtimeEnv:installationEnvironment(env)},
32
+ args:['-ilc',`${quote(node)} -e ${quote(script)}`],signal,workerNode});
33
+ const parts=output.split('\0HANAMESH_ENV\0');
34
+ if(parts.length!==2||!parts[1].includes('\0'))throw failure('AGENT_SHELL_ENV_UNAVAILABLE');
35
+ let value;try{value=JSON.parse(parts[1].split('\0')[0])}catch{throw failure('AGENT_SHELL_ENV_UNAVAILABLE')}
36
+ if(!value||typeof value!=='object'||Array.isArray(value)||typeof value.PATH!=='string'||!Object.entries(value).every(([key,path])=>INSTALLATION_ENV_KEYS.includes(key)&&typeof path==='string'&&!/[\x00-\x1f]/.test(path)))throw failure('AGENT_SHELL_ENV_UNAVAILABLE');
37
+ return installationEnvironment(value);
38
+ }
39
+ export async function readLoginPath(options){return(await readLoginEnvironment(options)).PATH}
40
+
41
+ /** Select once: inherited PATH, login PATH, then documented layouts; never try another CLI after execution. */
42
+ export async function discoverOfficialReader({channel,home=process.env.HOME??process.env.USERPROFILE??homedir(),env=process.env,platform=process.platform,
43
+ systemBinDirs=platform==='win32'?[]:['/opt/homebrew/bin','/usr/local/bin','/home/linuxbrew/.linuxbrew/bin','/usr/bin','/bin'],loginPath,signal,workerNode}={}){
44
+ if(!['claude','codex'].includes(channel)||!absolute(home))throw failure('AGENT_PROFILE_UNAVAILABLE');
45
+ signal?.throwIfAborted();
46
+ const configDir=env[channel==='claude'?'CLAUDE_CONFIG_DIR':'CODEX_HOME']??join(home,channel==='claude'?'.claude':'.codex');
47
+ if(!absolute(configDir))throw failure('AGENT_PROFILE_UNAVAILABLE');
48
+ const appData=env.APPDATA??join(home,'AppData','Roaming'),local=env.LOCALAPPDATA??join(home,'AppData','Local');
49
+ const runtimeEnv=installationEnvironment(env,platform);
50
+ const reader={home,configDir,...AGENT_INSTALLATION_TARGET,...INSTALLATION_READ_BUDGET,runtimeEnv};
51
+ const find=async dirs=>{for(const dir of unique(dirs)){
52
+ signal?.throwIfAborted();
53
+ for(const name of platform==='win32'?[`${channel}.exe`,`${channel}.cmd`]:[channel]){
54
+ const executable=join(dir,name);if(!await exists(executable,platform))continue;
55
+ if(name.endsWith('.cmd')){
56
+ const entry=channel==='claude'?['@anthropic-ai','claude-code','cli.js']:['@openai','codex','bin','codex.js'];
57
+ const entries=[join(dir,'node_modules',...entry)];
58
+ if(dir===join(local,'Yarn','bin'))entries.push(join(local,'Yarn','Data','global','node_modules',...entry));
59
+ if(dir===join(home,'.yarn','bin'))entries.push(join(home,'.config','yarn','global','node_modules',...entry));
60
+ // pnpm's global layout has a numbered compatibility directory; inspect directory names only.
61
+ for(const base of unique([dir,...(dir===join(local,'pnpm','bin')?[join(local,'pnpm')]:[]),...(absolute(env.PNPM_HOME)&&dir===join(env.PNPM_HOME,'bin')?[env.PNPM_HOME]:[])])){
62
+ let global=[];try{global=await readdir(join(base,'global'))}catch(error){if(!['ENOENT','ENOTDIR'].includes(error.code))throw failure('AGENT_READER_ENTRYPOINT_UNAVAILABLE')}
63
+ for(const name of global.filter(name=>/^\d+$/.test(name)).sort((a,b)=>Number(b)-Number(a)))entries.push(join(base,'global',name,'node_modules',...entry));
64
+ }
65
+ let script;for(const candidate of entries)if(await exists(candidate,'win32')){script=candidate;break}
66
+ if(!script)throw failure('AGENT_READER_ENTRYPOINT_UNAVAILABLE');
67
+ return{...reader,executable:script,nodeScript:true};
68
+ }
69
+ return{...reader,executable};
70
+ }
71
+ }return null};
72
+ let selected=await find(split(env.PATH??env.Path,platform));if(selected)return selected;
73
+ let shellError;
74
+ if(platform!=='win32'){
75
+ try{const resolved=loginPath?{PATH:await loginPath({home,env,signal,workerNode})}:await readLoginEnvironment({home,env,signal,workerNode});Object.assign(runtimeEnv,resolved);selected=await find(split(resolved.PATH,platform));if(selected)return selected}
76
+ catch(error){if(signal?.aborted)throw error;shellError=error}
77
+ }
78
+ const nvm=runtimeEnv.NVM_DIR??join(home,'.nvm'),fnm=runtimeEnv.FNM_DIR??join(runtimeEnv.XDG_DATA_HOME??join(home,'.local','share'),'fnm'),asdf=runtimeEnv.ASDF_DATA_DIR??join(home,'.asdf'),mise=runtimeEnv.MISE_DATA_DIR??join(runtimeEnv.XDG_DATA_HOME??join(home,'.local','share'),'mise');
79
+ const fixed=[join(home,'.local','bin'),...(channel==='claude'?[join(home,'.claude','local'),join(home,'.claude','local','node_modules','.bin')]:[]),
80
+ env.CODEX_INSTALL_DIR,env.NVM_BIN,env.NVM_SYMLINK,env.FNM_MULTISHELL_PATH,
81
+ ...[env.npm_config_prefix,env.NPM_CONFIG_PREFIX,join(home,'.npm-global'),join(home,'.npm')].filter(absolute).map(root=>platform==='win32'?root:join(root,'bin')),
82
+ env.PNPM_HOME,...(absolute(env.PNPM_HOME)?[join(env.PNPM_HOME,'bin')]:[]),join(home,'.local','share','pnpm'),join(home,'.local','share','pnpm','bin'),join(home,'Library','pnpm'),join(home,'Library','pnpm','bin'),join(home,'.yarn','bin'),join(home,'.config','yarn','global','node_modules','.bin'),
83
+ join(env.VOLTA_HOME??join(home,'.volta'),'bin'),join(asdf,'shims'),join(mise,'shims'),
84
+ ...(platform==='win32'?[join(appData,'npm'),join(local,'pnpm'),join(local,'Yarn','bin'),join(local,'Programs','OpenAI','Codex','bin'),join(local,'Microsoft','WinGet','Links'),join(local,'Volta','bin'),...(absolute(env.ProgramFiles)?[join(env.ProgramFiles,'nodejs')]:[])]:[]),...systemBinDirs];
85
+ selected=await find(fixed);if(selected)return selected;
86
+ const managed=[];
87
+ for(const [root,suffix]of [[join(nvm,'versions','node'),['bin']],[env.NVM_HOME,[]],[join(fnm,'node-versions'),['installation',...(platform==='win32'?[]:['bin'])]],
88
+ [join(home,'Library','Application Support','fnm','node-versions'),['installation','bin']],[join(appData,'fnm','node-versions'),['installation']],
89
+ [join(asdf,'installs','nodejs'),['bin']],[join(mise,'installs','node'),['bin']]]){
90
+ if(absolute(root))managed.push(...await versions(root,suffix));
91
+ }
92
+ selected=await find(managed);if(selected)return selected;
93
+ if(shellError)throw failure(shellError.code==='AGENT_READER_TIMEOUT'?'AGENT_READER_TIMEOUT':'AGENT_SHELL_ENV_UNAVAILABLE');
94
+ throw failure('AGENT_READER_UNAVAILABLE');
95
+ }
@@ -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
  });
@@ -28,18 +28,10 @@ async function pluginManagerFacts(anchor){
28
28
  try{
29
29
  const require=createRequire(anchor),manifestPath=require.resolve('@deepseek-ai/dsh-plugin-manager/package.json'),pkg=await json(manifestPath);
30
30
  if(pkg?.name!=='@deepseek-ai/dsh-plugin-manager'||typeof pkg.version!=='string')return {};
31
- let pluginManagerRegistry;
32
- try{const path=require.resolve('@deepseek-ai/dsh-plugin-manager/registry');if(path.startsWith(dirname(manifestPath)+sep))pluginManagerRegistry=path;}catch{}
33
- return{pluginManagerVersion:pkg.version,...(pluginManagerRegistry?{pluginManagerRegistry}:{})};
31
+ return{pluginManagerVersion:pkg.version};
34
32
  }catch{return {};}
35
33
  }
36
34
  export async function inferPluginManagerVersion(anchor){return(await pluginManagerFacts(anchor)).pluginManagerVersion;}
37
- async function desktopRegistryPath(root,pkg){
38
- const entry=pkg.exports?.['./registry'],target=typeof entry==='string'?entry:entry?.import??entry?.default;
39
- if(typeof target!=='string'||!target.startsWith('./'))return undefined;
40
- const path=resolve(root,target);if(!path.startsWith(root+sep))return undefined;
41
- try{await access(path);return path;}catch{return undefined;}
42
- }
43
35
  /** A DSH profile manifest that carries HanaMesh: `dsh.profile` (written by `dsh plugin`) or a hanamesh dependency. */
44
36
  export function isHanameshProfileManifest(manifest){
45
37
  if(!manifest||typeof manifest!=='object'||Array.isArray(manifest))return false;
@@ -109,7 +101,6 @@ export async function inferDesktopLayout({moduleUrl=import.meta.url,argv=process
109
101
  }
110
102
  const managerManifest=await json(join(runtimeDir,'node_modules/@deepseek-ai/dsh-plugin-manager/package.json'));
111
103
  const pluginManagerVersion=managerManifest?.name==='@deepseek-ai/dsh-plugin-manager'&&managerManifest.version===hostManifest.version?managerManifest.version:undefined;
112
- const pluginManagerRegistry=pluginManagerVersion?await desktopRegistryPath(join(runtimeDir,'node_modules/@deepseek-ai/dsh-plugin-manager'),managerManifest):undefined;
113
104
  // Current canonical runtime manifest only. No legacy descriptor inference or PATH search.
114
105
  if(metadata?.platform===process.platform&&metadata?.arch===process.arch&&
115
106
  metadata.desktopVersion===(await json(join(hostRoot,'package.json')))?.version&&/^24\.\d+\.\d+$/.test(metadata.node??'')) {
@@ -121,7 +112,7 @@ export async function inferDesktopLayout({moduleUrl=import.meta.url,argv=process
121
112
  if(actual.node===metadata.node&&actual.electron===undefined)nodeBinary=await realpath(node);
122
113
  } catch { /* Unavailable/unproven payload Node stays unavailable; never use Electron. */ }
123
114
  }
124
- return {profileDir:await realpath(profileDir),profileName:'desktop',dshBin,parentOrigin:'dsh-app://app',frameAncestors:[],desktopCli,desktopLauncher,pluginManagerVersion,pluginManagerRegistry,nodeBinary,runtimeDir};
115
+ return {profileDir:await realpath(profileDir),profileName:'desktop',dshBin,parentOrigin:'dsh-app://app',frameAncestors:[],desktopCli,desktopLauncher,pluginManagerVersion,nodeBinary,runtimeDir};
125
116
  }
126
117
 
127
118
  /** The DSH CLI that started this process — `argv[1]` only when it IS `@deepseek-ai/dsh/lib/bin.js`. */
@@ -149,6 +140,6 @@ export async function resolveLibraryLocations(config={},{moduleUrl=import.meta.u
149
140
  let dshBin=config.dshBin;if(dshBin===undefined){dshBin=desktop?.dshBin??inferDshBin(argv);if(dshBin)inferred.push('dshBin');}
150
141
  let node=nodeBinary;if(node===undefined){node=desktop?.nodeBinary??inferNodeBinary({execPath,versions});if(node)inferred.push('nodeBinary');}
151
142
  let sources=config.sources;if(sources===undefined){sources=structuredClone(DEFAULT_CATALOG_SOURCES);inferred.push('sources');}
152
- const {pluginManagerVersion,pluginManagerRegistry}=desktop??await pluginManagerFacts(inferDshBin(argv)??(profileDir?join(profileDir,'package.json'):undefined));
153
- return{profileDir,profileName,dshBin,nodeBinary:node,sources,inferred,...(pluginManagerVersion?{pluginManagerVersion}:{}),...(pluginManagerRegistry?{pluginManagerRegistry}:{}),...(desktop&&desktop.profileDir===profileDir&&config.dshBin===undefined?{desktopCli:desktop.desktopCli,desktopLauncher:desktop.desktopLauncher}:{})};
143
+ const {pluginManagerVersion}=desktop??await pluginManagerFacts(inferDshBin(argv)??(profileDir?join(profileDir,'package.json'):undefined));
144
+ return{profileDir,profileName,dshBin,nodeBinary:node,sources,inferred,...(pluginManagerVersion?{pluginManagerVersion}:{}),...(desktop&&desktop.profileDir===profileDir&&config.dshBin===undefined?{desktopCli:desktop.desktopCli,desktopLauncher:desktop.desktopLauncher}:{})};
154
145
  }