@hanamesh/dsh-app-host 0.6.22 → 0.6.25

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.
@@ -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,12 +222,12 @@ 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
 
229
229
 
230
- 目录来源 URL 接受标准端口 HTTPS,另接受本机回环 `127.0.0.1`、`::1`、`localhost` 的任意端口 HTTP。使用 Node `URL` 解析后的主机名精确比较;其它来源保持原规则。凭据、fragment、查询和完整同源校验不变;不增加设置、映射或别名。目录来源自身 attribution 可使用该回环来源,普通条目展示链接仍按原 HTTPS 校验。
230
+ 目录来源 URL 接受标准端口 HTTPS;HTTPS 本机回环 127.0.0.1、::1、localhost 及 *.localhost 可使用任意端口,另接受本机回环 `127.0.0.1`、`::1`、`localhost` 的任意端口 HTTP。使用 Node `URL` 解析后的主机名精确比较;其它来源保持原规则。凭据、fragment、查询和完整同源校验不变;不增加设置、映射或别名。目录来源自身 attribution 可使用该回环来源,普通条目展示链接仍按原 HTTPS 校验。
231
231
 
232
232
  ## B17 套件安装包来源(2026-10-21 候选)
233
233
 
@@ -259,3 +259,34 @@ B17 review repair(同0.6.8候选):`AppHost` 未注入 `runtimeLedgerReader
259
259
  A2 0.6.22:普通插件必须有 Indexer 已审核的 installMethods 和 oneClickInstall.allowed:true;仅执行其 npm 精确版本或完整 GitHub SHA。缺目标具名拒绝,不根据包名查可变版本标签。CLI 使用显式 library.registry(若有),否则保留宿主原生 registry 配置。删除普通插件按包名内部直装方法、元数据查询及其专用设置/规则加载。套件仅透传 Core installCommand。安装失败保留脱敏 CLI/读回诊断及补偿结果;精确版本发布后即可原生验收。
260
260
 
261
261
  A2 0.6.22:升级tuple必须带Core生成的installation(name/version/command/args),name/version与该tuple一致。AppHost传实际profile和可选配置来源;未配置来源由Core显式声明。CLI直接执行args;confirmation同时绑定该定义。installCommand是Core的可空修复展示文本,不解析命令前缀。旧Core缺定义不回退自拼。
262
+
263
+ ## CL1 HanaMesh client pages (2026-11-10 candidate)
264
+
265
+ AppHost adds keyed main entries `hanamesh-library` and `hanamesh-providers`. Sidebar list id `hanamesh-library` (order 11, label 应用库) addresses the matching main key. The existing market seat, overlay fallback, website install-target entry, exact install/readback, app receipts, leases and close ownership remain intact; new layout-aware entry actions select the main page without creating a Session. The legacy footer action is hidden when the native main navigation is available.
266
+
267
+ Core declares root-scoped list slot `hanamesh.environment.content` as a child of main key `hanamesh-environment`. AppHost registers its own `EnvironmentContent` there: installation environment and lazily opened catalog/suite-source/handoff forms. Those forms use their original authenticated routes and stores; opening the environment does not read every closed form or save/install anything. Tabs select Core keys `hanamesh-account` / `hanamesh-environment` and AppHost `hanamesh-providers`. No sibling source import, Core allowlist change or host root/sidebar/conversation replacement is involved.
268
+
269
+ The library includes local installed/all tabs and a source-preserving details view. Installation confirmation shows the exact reviewed npm version or full GitHub ref/path/build permission, and requires acknowledgement of the **current resolved target object** before its original confirm action can post. A newly resolved target clears that acknowledgement; server-side fixed-target reread remains authoritative. The shipped conformance harness includes an explicit `acknowledgeInstall()` step before confirmation. Old directory `latestVersion` is still rejected/not displayed.
270
+
271
+ The current catalog-view DTO has no public GitHub stars / npm downloads / platform install people / usage people fields. Details render each as unavailable with a reason, never as zero or a local-inventory substitute. The website SearchSignals DTO is a different contract, and store installation次数 is not 人数. Supplying these figures needs a formally added catalog-view DTO; this candidate does not relax validation or perform request-time GitHub/npm calls. A “在网站查看” link is derived only from the reviewed HanaMesh-view catalog provider; fixture/other-provider routes are not guessed. No trading or asset UI is added to the client.
272
+
273
+ Scoped page styles consume the same host theme tokens. The library glyph reuses the supplied DS1 asset. Source/fixture checks do not establish real DSH loading or design screenshot acceptance; those remain separate CL1 gates.
274
+
275
+ ### CL1 existing public credibility reads (2026-11-10)
276
+
277
+ On detail open only, the client uses the reviewed HanaMesh catalog origin's existing
278
+ `GET /api/v2/hanas/:id` and `GET /hm/v1/custody/hanas/:id/popularity` public reads.
279
+ The first is WX11/WX12's projection of Host 0.6.54+ `/v1/indexer/signals/changes`:
280
+ `githubStars` becomes `signals.stars`, with `npmDownloads30d`, nullable
281
+ `storeInstalls30d` and `checkedAt`. The second supplies `users30d` and its own
282
+ `asOf`. DSH installs are 30-day counts, never unique installation people; neither
283
+ npm downloads nor `installCount` is substituted. All missing/invalid/error values
284
+ show “暂无”; real zero stays zero, and timestamps remain separate.
285
+
286
+ No new backend, signal-feed consumption, source scraping, polling or catalog DTO
287
+ extension. Both requests omit credentials, refuse redirects and time out after
288
+ 8 seconds; identity must match the selected item. Source routes are derived only
289
+ for the reviewed `/catalog-source.json?view=hanamesh` provider. Other catalog
290
+ providers and unavailable/CORS-denied public reads retain “暂无”. The source's
291
+ browser access rules are respected; there is no proxy/CORS bypass. Closing or
292
+ switching details ignores stale replies.
@@ -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,11 @@ 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.
110
+
111
+ 0.6.25:Claude 官方市场清单的 `source: "github"` 使用 `repo` 的 owner/repository 身份与登记的 GitHub HTTPS 市场核对;`url` / `git` 继续核对其 URL;官方 GitHub HTTPS Git 来源允许同一仓库的 `.git` 后缀。来源不同仍为 `AGENT_MARKETPLACE_SOURCE_MISMATCH`,不会把无响应或缺少 CLI 当作未安装。
112
+
113
+ 安装物回归:`node tests/native/installed-detection.mjs /absolute/HanaMesh.app /absolute/new-evidence-directory https://website-origin` 从实际 app 资源读取种子版本,以全新 HOME / DSH_HOME / shared home / user-data 启动同一可执行文件;仅在出现该按钮时选择 Set up later,不登录或开启记录;随后断言获准 Origin 的发现响应、真实 POST、五作用域及本 runtime 的完整套件/版本。检测失败退出非零,保留逐端口响应、UI 与首错;不以页面可打开或目录中存在包文件代替运行结果。该手工安装物测试需要 macOS GUI 会话,默认源码单测另覆盖全新彩排配置初始化。
114
+
115
+ 未发布候选可在最后追加本人自建、无凭据且由正常 DSH CLI 安装的 profile 路径。回归把该资料复制到新 DSH_HOME,再运行同一安装物;不手改 app 或 dist。默认不传此参数时仍检查 app 原始种子。
@@ -16,7 +16,11 @@ GitHub ref 必须是收录时固定的完整 40 位 SHA,不在安装时解析
16
16
 
17
17
  GitHub CLI exit 0 后还须核对 profile 精确保存的 Git spec、模块包名/版本、dsh.bundle 和 profile 启用列表,随后报告 restart-required;重开后由原生 DSH 加载。不自行 clone/build,不改上游,不用进程内安装器。失败补偿沿原 CLI;pnpm-workspace.yaml 若发生变化,恢复报告保留 build-permissions 残留及原文件,不擅自撤销或静默宣称恢复。handoff 事件按完整目标匹配,不能只靠同名包把另一个 SHA 报为成功。
18
18
 
19
- 网站发现:DSH 本地路由与交接监听的 `/hanamesh/library/install-target` 都按当前 `library.handoff.allowedOrigins` 回答跨来源 GET / OPTIONS;只回显获准 Origin(Vary: Origin),不使用通配 CORS。未获准来源为 403 ORIGIN_DENIED,正文无客户端能力。正常工作区同源读取、无 Origin 的本机工具读取保留。其余安装/状态路由继续需要各自认证与确认。
19
+ 网站发现(0.6.23候选,编排11-09授权D3):DSH本地与交接监听共用`install-target-http.js`。仅获准来源可读取客户端能力;名单外的有效HTTP/HTTPS来源,对无查询、无Cookie/Authorization、非iframe的精确`GET /hanamesh/library/install-target`,返回精确`Access-Control-Allow-Origin: <请求Origin>`、`Vary: Origin`及固定`403 {"error":{"code":"ORIGIN_DENIED","message":"This website is not allowed to read discovery."}}`。无客户端类型/能力/ready/版本/清单/允许名单/traceId;不调用能力或安装清单读取器。该最小拒绝有意允许网站识别本机存在拒绝它的监听,不代表任何安装或读取授权。无`*`/Allow-Credentials/Set-Cookie。无Origin的本机工具及获准工作区读取保留。
20
+
21
+ 预检:精确同一路径的有效GET预检返回204空正文、精确Origin和`Access-Control-Allow-Methods: GET`,使浏览器能继续GET并读到403;不把403放在预检上。仅请求带`Access-Control-Request-Private-Network: true`时返回`Access-Control-Allow-Private-Network: true`;普通预检和实际GET均不带该响应头。Vary包含Origin、Access-Control-Request-Method、Access-Control-Request-Headers、Access-Control-Request-Private-Network;无Max-Age缓存。请求额外headers、非GET预检、非法PNA值被拒绝且不授CORS;名单外预检必须显式声明GET。获准来源/本机工具的旧OPTIONS缺省method保持可用。依据:[Fetch预检成功状态](https://fetch.spec.whatwg.org/#cors-preflight-fetch)、[Chrome PNA预检](https://developer.chrome.com/blog/private-network-access-preflight)。现代Chrome的[LNA本机访问权限](https://developer.chrome.com/docs/modern-web-guidance/guides/security/local-network-access)独立生效,此响应不绕过浏览器授权。
22
+
23
+ 错误Host、绝对/双斜线/反斜线target、opaque或畸形Origin、名单外凭据/查询/非探测方法、iframe均不获此拒绝CORS例外;installation-status、交接、库存、设置、安装等接口继续原名单/同意/认证与确认规则。
20
24
 
21
25
  轮询设置:`pollMs` 写入应用库真实 storage domain;保存、卸载再加载后保持。旧记录没有该字段时使用既有配置/默认 1500 ms,不重写其它存储数据。
22
26
 
@@ -127,7 +131,7 @@ node node_modules/@hanamesh/dsh-app-host/dist/install-target/run.js consumer --u
127
131
 
128
132
  ## 本机公开探测与目录消费(D-180)
129
133
 
130
- `GET /hanamesh/library/install-target` 无参数,精确回环 Host、非 iframe,允许跨源匿名只读探测。返回 `{client:'dsh',ready,installTarget:{contract:'hanamesh.install-target',contractVersion:'1',supported:['1'],fragmentPrefix:'#hanamesh-install?'},traceId,error?}`;缺安装器 `ready:false` 且 `error.code='INSTALL_TARGET_UNAVAILABLE'`。只这一项带 `Access-Control-Allow-Origin:*`、no-store,不发 Cookie/Allow-Credentials,不返回 profile、已装行、工作区认证地址、命令或租约。独立 DSH 默认报 dsh;没有真实 Desktop 公开证据时不推断 HanaMesh 品牌。其余控制路径仍按原 DSH 认证与准确同源授权执行。
134
+ `GET /hanamesh/library/install-target` 无参数,精确回环 Host、非 iframe,允许跨源匿名只读探测。返回 `{client:'dsh',ready,installTarget:{contract:'hanamesh.install-target',contractVersion:'1',supported:['1'],fragmentPrefix:'#hanamesh-install?'},traceId,error?}`;缺安装器 `ready:false` 且 `error.code='INSTALL_TARGET_UNAVAILABLE'`。只这一项依本页“网站发现”规则带精确Origin CORS、no-store,不发 Cookie/Allow-Credentials,不返回 profile、已装行、工作区认证地址、命令或租约。独立 DSH 默认报 dsh;没有真实 Desktop 公开证据时不推断 HanaMesh 品牌。其余控制路径仍按原 DSH 认证与准确同源授权执行。
131
135
 
132
136
  C-WEB-CATALOG 第1版的显式 `catalog-source.json?view=hanamesh` 与 manifest 的 `/v1/plugins?view=hanamesh` 可配置为目录来源。旧无 view 源仍严格按 catalog-major-1,不混 schema 或静默降级。新 DTO 保留 id=itemId、可空 npm 身份及公开 sourceLinks;q 是子串查询,目标按精确身份匹配,空页仍有游标则续翻。只给包名时遍历确认唯一目录身份;多个身份返回409 CATALOG_TARGET_AMBIGUOUS。无 npm 包仅浏览;来源链接/分类/目录版本都不能成为启动或安装权限。CATALOG_CHANGED 中止本轮、不拼代或自动重试,其余提供方公开错误保持原 code;故障不退 fixture。
133
137