@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.
- package/README.md +18 -14
- package/dist/client-ui.d.ts +11 -1
- package/dist/client-ui.js +121 -26
- package/dist/dsh-compat.js +1 -1
- package/dist/dsh.d.ts +1 -2
- package/dist/dsh.js +1 -3
- package/dist/git-command-worker.js +12 -0
- package/dist/git-process.js +8 -3
- package/dist/git-worker.js +37 -7
- package/dist/install-target/harness.js +2 -0
- package/dist/install-target/suite.js +5 -5
- package/dist/installation-status.d.ts +0 -8
- package/dist/library/catalog.js +4 -3
- package/dist/library/handoff.js +3 -11
- package/dist/library/install-target-http.js +39 -0
- package/dist/library/installation-cli.js +41 -16
- package/dist/library/installation-command-worker.js +3 -2
- package/dist/library/installation-discovery.js +95 -0
- package/dist/library/installation-status.js +20 -14
- package/dist/library/installation-worker.js +72 -14
- package/dist/library/routes.js +2 -10
- package/dist/node-runtime/service.js +4 -4
- package/dist/router/sources.js +5 -2
- package/dist/runtime.js +15 -1
- package/dist/store.js +12 -5
- package/dist/windows-job.js +30 -0
- package/dist/windows-job.ps1 +75 -0
- package/docs/CONTRACT.md +34 -3
- package/docs/INSTALLATION_STATUS.md +35 -18
- package/docs/INSTALL_TARGET.md +6 -2
- package/docs/LIBRARY.md +1 -1
- package/docs/LICENSES.json +207 -69
- package/docs/SECURITY.md +15 -0
- package/package.json +41 -27
- package/schemas/catalog-source.schema.json +5 -5
package/dist/git-worker.js
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
// Internal IPC supervisor. The parent never detaches a package manager directly.
|
|
2
|
-
import {spawn,spawnSync} from 'node:child_process';
|
|
2
|
+
import {spawn,spawnSync,fork} from 'node:child_process';
|
|
3
3
|
import {existsSync,mkdtempSync,writeFileSync,rmSync,realpathSync} from 'node:fs';
|
|
4
4
|
import {tmpdir} from 'node:os';
|
|
5
5
|
import {dirname,join,basename} from 'node:path';
|
|
6
|
+
import {createWindowsJob} from './windows-job.js';
|
|
6
7
|
import {observeGitTrace} from './git-process.js';
|
|
7
|
-
let monitor,directory,timer,done=false,started=false;
|
|
8
|
-
function finish(payload){
|
|
8
|
+
let monitor,directory,timer,done=false,started=false,anchor,anchorClosed,windowsJob,jobStarting,jobController;
|
|
9
|
+
async function finish(payload){
|
|
9
10
|
if(done)return;done=true;clearInterval(timer);
|
|
11
|
+
if(process.platform==='win32'&&anchor){
|
|
12
|
+
try{
|
|
13
|
+
if(!windowsJob){jobController?.abort();try{await jobStarting}catch{}}
|
|
14
|
+
if(windowsJob)await windowsJob.stop();else if(anchor.exitCode===null&&anchor.signalCode===null)anchor.kill('SIGKILL');
|
|
15
|
+
await anchorClosed;
|
|
16
|
+
}catch{payload={error:{code:'GIT_TREE_CLEANUP_FAILED'}};}
|
|
17
|
+
}
|
|
18
|
+
finalize(payload);
|
|
19
|
+
}
|
|
20
|
+
async function commandResult(payload){
|
|
21
|
+
if(done)return;
|
|
22
|
+
if(!payload.error){try{while(!done&&await windowsJob.active()>1)await new Promise(resolve=>setTimeout(resolve,5));}catch{void finish({error:{code:'GIT_TREE_CLEANUP_FAILED'}});return;}}
|
|
23
|
+
if(!done)void finish(payload);
|
|
24
|
+
}
|
|
25
|
+
function finalize(payload){
|
|
10
26
|
try{monitor?.check(true);}catch(error){payload={error:{code:error.code??'GIT_TRACE_INVALID'}};}
|
|
11
27
|
try{if(monitor)monitor.close();else if(directory)rmSync(directory,{recursive:true,force:true});}catch{payload={error:{code:'GIT_TRACE_CLEANUP_FAILED'}};}
|
|
12
28
|
const end=()=>{
|
|
13
|
-
if(process.platform==='win32')
|
|
29
|
+
if(process.platform==='win32')process.exit(0);
|
|
14
30
|
else process.kill(-process.pid,'SIGKILL');
|
|
15
31
|
};
|
|
16
32
|
if(process.connected)process.send(payload,end);else end();
|
|
@@ -19,15 +35,17 @@ function abort(code='ABORT_ERR'){finish({error:{code,...(code==='ABORT_ERR'?{nam
|
|
|
19
35
|
process.once('disconnect',()=>abort());
|
|
20
36
|
process.once('SIGTERM',()=>abort());process.once('SIGINT',()=>abort());
|
|
21
37
|
if(!process.connected)process.exit(1);
|
|
22
|
-
process.on('message',request=>{
|
|
38
|
+
process.on('message',async request=>{
|
|
23
39
|
if(request.abort)return abort();if(request.timeout)return abort('ETIMEDOUT');
|
|
24
40
|
if(started)return;started=true;
|
|
25
41
|
try{
|
|
26
42
|
let {command,args,options}=request;
|
|
27
43
|
// No caller-owned trace exists before this IPC worker can clean on disconnect.
|
|
28
|
-
|
|
44
|
+
const temporaryRoot=realpathSync(options.env.TMPDIR??tmpdir());
|
|
45
|
+
const parentRoot=options.env.HM_GIT_GUARD_TMP_ROOT===temporaryRoot&&options.env.HM_GIT_GUARD_PARENT_ROOT?realpathSync(options.env.HM_GIT_GUARD_PARENT_ROOT):temporaryRoot;
|
|
46
|
+
directory=mkdtempSync(join(parentRoot,'hm-git-guard-'));
|
|
29
47
|
writeFileSync(join(directory,'events.jsonl'),'',{mode:0o600});monitor=observeGitTrace(directory);
|
|
30
|
-
options.env={...options.env,GIT_TRACE2_EVENT:join(directory,'events.jsonl'),GIT_TRACE_REDACT:'1'};
|
|
48
|
+
options.env={...options.env,HM_GIT_GUARD_PARENT_ROOT:directory,HM_GIT_GUARD_TMP_ROOT:temporaryRoot,GIT_TRACE2_EVENT:join(directory,'events.jsonl'),GIT_TRACE_REDACT:'1'};
|
|
31
49
|
if(!process.connected)return abort();
|
|
32
50
|
process.send({directory},error=>{if(error)abort();});
|
|
33
51
|
// Native Windows .cmd shims cannot be executed by spawn without a shell.
|
|
@@ -40,6 +58,18 @@ process.on('message',request=>{
|
|
|
40
58
|
const cli=candidates.find(path=>existsSync(path));if(!cli)throw Object.assign(new Error('Package manager JS entry unavailable.'),{code:'PACKAGE_MANAGER_UNAVAILABLE'});
|
|
41
59
|
command=process.execPath;args=[cli,...args];
|
|
42
60
|
}
|
|
61
|
+
if(process.platform==='win32'){
|
|
62
|
+
anchor=fork(new URL('./git-command-worker.js',import.meta.url),[],{cwd:options.cwd,env:options.env,execArgv:[],stdio:['ignore','inherit','inherit','ipc']});
|
|
63
|
+
anchorClosed=new Promise(resolve=>anchor.once('close',resolve));
|
|
64
|
+
anchor.once('message',value=>{void commandResult(value)});anchor.once('error',error=>{void finish({error:{code:error.code??'SPAWN_FAILED'}})});
|
|
65
|
+
jobController=new AbortController();
|
|
66
|
+
jobStarting=createWindowsJob({pid:anchor.pid,env:{...options.env,TMPDIR:directory,TMP:directory,TEMP:directory},cwd:options.cwd,signal:jobController.signal});
|
|
67
|
+
windowsJob=await jobStarting;
|
|
68
|
+
if(done)return;
|
|
69
|
+
anchor.send({command,args,options});
|
|
70
|
+
timer=setInterval(()=>{try{monitor.check();}catch(error){finish({error:{code:error.code??'GIT_TRACE_INVALID'}});}},25);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
43
73
|
const {input,...spawnOptions}=options;
|
|
44
74
|
const child=spawn(command,args,{...spawnOptions,stdio:[input===undefined?'ignore':'pipe','inherit','inherit'],detached:false});
|
|
45
75
|
if(input!==undefined){child.stdin.on('error',()=>{});child.stdin.end(input);}
|
|
@@ -34,6 +34,8 @@ export async function loadClient({fetch:transport}){
|
|
|
34
34
|
render();await idle();
|
|
35
35
|
return{module,
|
|
36
36
|
text:()=>JSON.stringify(tree),
|
|
37
|
+
find:predicate=>find(tree,predicate),
|
|
38
|
+
async acknowledgeInstall(){const checkbox=find(tree,node=>node.type==='input'&&node.props['aria-label']==='我已核对来源与安装目标');if(!checkbox)throw new Error('Installation review acknowledgement is not visible');checkbox.props.onChange({currentTarget:{checked:true}});await idle();},
|
|
37
39
|
button:label=>find(tree,node=>node.type==='button'&&node.children.includes(label)),
|
|
38
40
|
async click(label){const button=find(tree,node=>node.type==='button'&&node.children.includes(label));if(!button)throw new Error('No visible button '+label);button.props.onClick();await idle();},
|
|
39
41
|
idle,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* real provider. Fixture data is a test catalog and is labelled as such in the market UI.
|
|
6
6
|
*/
|
|
7
7
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
8
|
-
import { join, dirname } from 'node:path';
|
|
8
|
+
import { join, dirname, resolve } from 'node:path';
|
|
9
9
|
import { createServer, request } from 'node:http';
|
|
10
10
|
import { randomUUID } from 'node:crypto';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
@@ -95,7 +95,7 @@ export async function sourceRecoveryProfile({recovery},item={}){
|
|
|
95
95
|
for(const [command,phase,skip] of item.inject??[])profile.fail(command,phase,skip);
|
|
96
96
|
if(item.concurrent)profile.concurrentAdd('@fixture/concurrent','1.0.0');
|
|
97
97
|
// The catalog fixture already supplies the exact reviewed version.
|
|
98
|
-
const installer=emit=>createLibraryInstaller({profileDir:profile.profileDir,profileName:profile.profileName,dataRoot:profile.dataRoot,nodeBinary:'/source-fixture/bin/node',dshBin:'/source-fixture/dsh/lib/bin.js',
|
|
98
|
+
const installer=emit=>createLibraryInstaller({profileDir:profile.profileDir,profileName:profile.profileName,dataRoot:profile.dataRoot,nodeBinary:resolve('/source-fixture/bin/node'),dshBin:resolve('/source-fixture/dsh/lib/bin.js'),
|
|
99
99
|
fetchImpl:profile.fetchImpl({[app.packageName]:app.latest,[plugin.packageName]:plugin.latest}),spawn:profile.spawn,provision:profile.provision,remove:profile.remove,ledger:profile.ledger,emit});
|
|
100
100
|
return{profile,target,installer};
|
|
101
101
|
}
|
|
@@ -173,7 +173,7 @@ async function runRecoveryProvider(contract,record){
|
|
|
173
173
|
await record(id,'recovery','market shows '+caseId+' as '+(id==='RU01'?'a failure that was restored':'a failure whose restoration failed, never as installed or rolled back'),async()=>{
|
|
174
174
|
const item=recovery.cases.find(row=>row.id===caseId),source=await sourceRecoveryProfile(contract,item);
|
|
175
175
|
const host=await startProvider(contract,{profile:source.profile,installer:source.installer});const client=await loadClient({fetch:host.browserFetch});
|
|
176
|
-
try{await client.module.openInstallTarget({packageName:source.target.packageName});await client.idle();await client.click('确认安装');await host.settled();await client.tick(1000);
|
|
176
|
+
try{await client.module.openInstallTarget({packageName:source.target.packageName});await client.idle();await client.acknowledgeInstall();await client.click('确认安装');await host.settled();await client.tick(1000);
|
|
177
177
|
const text=client.text();
|
|
178
178
|
if(!text.includes('安装失败:'+item.expect.code)||!text.includes(expected))throw new Error('market text: '+text.slice(text.indexOf('安装失败')>=0?text.indexOf('安装失败'):0).slice(0,300));
|
|
179
179
|
if(text.includes(refused)||text.includes('安装完成'))throw new Error('market misreports the recovery result');
|
|
@@ -247,7 +247,7 @@ export async function runProviderSuite(){
|
|
|
247
247
|
await record('PC02','confirm','only an explicit confirmation installs, once, through the existing guarded route',async()=>{
|
|
248
248
|
expectEqual(host.installs.length,0,'installs before confirmation');
|
|
249
249
|
await client.module.openInstallTarget({packageName:'@hanamesh-fixture/install-target-app',contractVersion:'1'});await client.idle();
|
|
250
|
-
const confirm=client.button('确认安装');confirm.props.onClick();confirm.props.onClick();await client.idle();
|
|
250
|
+
await client.acknowledgeInstall();const confirm=client.button('确认安装');confirm.props.onClick();confirm.props.onClick();await client.idle();
|
|
251
251
|
expectEqual(host.posts.map(p=>[p.path,p.headers['x-hanamesh-client']]),[['/hanamesh/library/install','workspace-v1']],'install requests');
|
|
252
252
|
expectEqual(host.installs,['@hanamesh-fixture/install-target-app'],'installer calls');
|
|
253
253
|
});
|
|
@@ -258,7 +258,7 @@ export async function runProviderSuite(){
|
|
|
258
258
|
if(!client.button('确认安装'))throw new Error('localhost fragment did not reach confirmation');
|
|
259
259
|
await client.click('取消');expectEqual([host.posts.length,host.installs.length],[postsBefore,installsBefore],'localhost cancellation');
|
|
260
260
|
await client.module.openInstallTarget({packageName:'@hanamesh-fixture/install-target-plugin',contractVersion:'1'});await client.idle();
|
|
261
|
-
const confirm=client.button('确认安装');confirm.props.onClick();confirm.props.onClick();await client.idle();
|
|
261
|
+
await client.acknowledgeInstall();const confirm=client.button('确认安装');confirm.props.onClick();confirm.props.onClick();await client.idle();
|
|
262
262
|
expectEqual([host.posts.length,host.installs.length],[postsBefore+1,installsBefore+1],'localhost confirmed install once');
|
|
263
263
|
expectEqual(host.installs.at(-1),'@hanamesh-fixture/install-target-plugin','localhost installer target');
|
|
264
264
|
});
|
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
/** Advertised metadata only. Discovery performs no installation read. */
|
|
2
2
|
export const INSTALLATION_STATUS_CAPABILITY:Readonly<{contract:'hanamesh.installation-status';contractVersion:'1';path:'/hanamesh/library/installation-status'}>;
|
|
3
|
-
export interface InstallationReaderConfig {
|
|
4
|
-
/** Absolute official executable and explicitly selected user/config directories. Never supplied by a website. */
|
|
5
|
-
executable:string;home:string;configDir:string;
|
|
6
|
-
/** Identity and exact HTTPS marketplace URL from the registered AgentPluginDTO. */
|
|
7
|
-
pluginName:string;marketplace:string;marketplaceUrl:string;
|
|
8
|
-
/** Required positive bounded operation budgets. No product default is inferred. */
|
|
9
|
-
timeoutMs:number;maxOutputBytes:number;
|
|
10
|
-
}
|
|
11
3
|
export interface InstallationStatusRequest {contractVersion:'1';consent:'read-installations'}
|
|
12
4
|
export interface InstallationObservation {
|
|
13
5
|
channel:'hanamesh'|'dsh'|'claude'|'codex';
|
package/dist/library/catalog.js
CHANGED
|
@@ -12,14 +12,15 @@ const MAX_SOURCE_LINKS=500;
|
|
|
12
12
|
const plain=value=>typeof value==='string'&&value.length>0&&!/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(value);
|
|
13
13
|
const own=(value,keys)=>value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).every(key=>keys.includes(key));
|
|
14
14
|
// WHATWG URL canonicalizes hostnames (including IPv6); no DNS lookup or origin aliasing.
|
|
15
|
-
const
|
|
16
|
-
|
|
15
|
+
const loopback=url=>['127.0.0.1','[::1]','localhost'].includes(url.hostname);
|
|
16
|
+
const catalogOrigin=url=>(url.protocol==='https:'&&(!url.port||loopback(url)||url.hostname.endsWith('.localhost')))||
|
|
17
|
+
(url.protocol==='http:'&&loopback(url));
|
|
17
18
|
|
|
18
19
|
export function validateManifestUrl(value){
|
|
19
20
|
let url;try{url=new URL(value);}catch{throw new AppHostError('INVALID_CATALOG_URL','Catalog manifest URL is invalid.');}
|
|
20
21
|
const viewQuery=!url.search||(url.searchParams.size===1&&url.searchParams.get('view')==='hanamesh');
|
|
21
22
|
requireCondition(catalogOrigin(url)&&!url.username&&!url.password&&viewQuery&&!url.hash,
|
|
22
|
-
'INVALID_CATALOG_URL','Catalog manifests require standard-port HTTPS
|
|
23
|
+
'INVALID_CATALOG_URL','Catalog manifests require standard-port HTTPS, local HTTPS (loopback or *.localhost), or loopback HTTP, with only an optional view=hanamesh query.');
|
|
23
24
|
return url;
|
|
24
25
|
}
|
|
25
26
|
|
package/dist/library/handoff.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { installationStatusCors, serveInstallationStatus } from './installation-status-http.js';
|
|
2
|
+
import {serveInstallTargetProbe} from './install-target-http.js';
|
|
2
3
|
/**
|
|
3
4
|
* Website → client install handoff (C-MARKET, 2026-10-15 E/F). Upstream Desktop listens on an OS-assigned port that
|
|
4
5
|
* nothing records, and a browser tab without the launch token gets 401, so a website can neither find nor enter the
|
|
@@ -33,7 +34,7 @@ const TIMER_MAX_MS = 2_147_483_647;
|
|
|
33
34
|
/** Fixed rules (correctness, not policy): listed on the settings page with the reason they cannot be switched off. */
|
|
34
35
|
export const HANDOFF_RULES = Object.freeze([
|
|
35
36
|
{ rule: '只在本机 127.0.0.1 上监听,请求的 Host 必须是 127.0.0.1 或 localhost 加监听端口', why: '防止别的机器或 DNS 重绑定的网页冒充本机' },
|
|
36
|
-
{ rule: '
|
|
37
|
+
{ rule: '只有「允许交接的网站」能读取客户端能力、交接安装和交接状态;未许可网站仅能读取固定的来源拒绝提示', why: '拒绝提示只说明本机监听拒绝了该网站,不透露客户端类型、能力、安装情况或允许名单' },
|
|
37
38
|
{ rule: '网站交接只会在应用库里弹出确认;只有你点「确认安装」才会安装', why: '网站不能替你决定装什么' },
|
|
38
39
|
{ rule: '交接状态只给发起它的那个网站看', why: '一个网站不能读另一个网站的交接' },
|
|
39
40
|
{ rule: `交接请求体不超过 ${HANDOFF_BODY_MAX_BYTES} 字节`, why: '交接只包含目录身份、合约版本及已审核的结构化安装目标;正文上限为 4096 字节,不接受来源命令' },
|
|
@@ -168,16 +169,7 @@ export function createHandoffHandler({ library, queue, settings, port }) {
|
|
|
168
169
|
requireCondition([`127.0.0.1:${bound}`, `localhost:${bound}`].includes(req.headers.host), 'HOST_DENIED', 'Unrecognized request host.', {}, 403);
|
|
169
170
|
const url = new URL(req.url ?? '/', `http://${req.headers.host}`), path = url.pathname, origin = req.headers.origin;
|
|
170
171
|
if (path === '/hanamesh/library/install-target') {
|
|
171
|
-
|
|
172
|
-
// Requests without an Origin are not from a website (a person's address bar, a local tool) and are answered.
|
|
173
|
-
if (origin !== undefined) {
|
|
174
|
-
requireCondition(settings().allowedOrigins.includes(origin), 'ORIGIN_DENIED', 'Not available.', undefined, 403);
|
|
175
|
-
cors = { 'access-control-allow-origin': origin, vary: 'Origin', 'access-control-allow-private-network': 'true' };
|
|
176
|
-
}
|
|
177
|
-
if (req.method === 'OPTIONS') { send(res, 204, undefined, { ...cors, 'access-control-allow-methods': 'GET' }); return; }
|
|
178
|
-
requireCondition(req.method === 'GET', 'METHOD_NOT_ALLOWED', 'Discovery requires GET.', {}, 405);
|
|
179
|
-
requireCondition(!url.search, 'UNKNOWN_FIELDS', 'Discovery takes no parameters.');
|
|
180
|
-
send(res, 200, { ...library.installTarget(), traceId }, cors); return;
|
|
172
|
+
serveInstallTargetProbe(req,res,{allowedOrigins:settings().allowedOrigins,read:()=>library.installTarget(),traceId,send});return;
|
|
181
173
|
}
|
|
182
174
|
if(path === '/hanamesh/library/installation-status'){
|
|
183
175
|
cors=installationStatusCors(req,settings().allowedOrigins);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Public discovery only: an unapproved website may read one constant refusal, never client facts. */
|
|
2
|
+
import {AppHostError,requireCondition} from '../errors.js';
|
|
3
|
+
const PATH='/hanamesh/library/install-target';
|
|
4
|
+
const VARY='Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network';
|
|
5
|
+
function browserOrigin(origin){
|
|
6
|
+
if(typeof origin!=='string')return false;
|
|
7
|
+
try{const url=new URL(origin);return ['https:','http:'].includes(url.protocol)&&url.origin===origin}catch{return false}
|
|
8
|
+
}
|
|
9
|
+
export function serveInstallTargetProbe(req,res,{allowedOrigins,read,traceId,send}){
|
|
10
|
+
let cors={};
|
|
11
|
+
try{
|
|
12
|
+
requireCondition(req.url?.startsWith('/')&&!req.url.startsWith('//')&&!req.url.includes('\\')&&req.url.split('?')[0]===PATH,'BAD_TARGET','Invalid discovery target.');
|
|
13
|
+
requireCondition(req.headers['sec-fetch-dest']!=='iframe','FRAME_CONTROL_DENIED','An application frame cannot inspect the host.',{},403);
|
|
14
|
+
const url=new URL(req.url,'http://127.0.0.1'),origin=req.headers.origin,approved=origin===undefined||allowedOrigins.includes(origin);
|
|
15
|
+
if(!approved){
|
|
16
|
+
requireCondition(browserOrigin(origin),'ORIGIN_DENIED','Not available.',{},403);
|
|
17
|
+
requireCondition(req.method==='GET'||req.method==='OPTIONS','METHOD_NOT_ALLOWED','Discovery requires GET.',{},405);
|
|
18
|
+
requireCondition(!url.search,'UNKNOWN_FIELDS','Discovery takes no parameters.');
|
|
19
|
+
requireCondition(req.headers.cookie===undefined&&req.headers.authorization===undefined,'CREDENTIALS_NOT_ACCEPTED','Discovery requires credentials: omit.',{},403);
|
|
20
|
+
}
|
|
21
|
+
if(req.method==='OPTIONS'){
|
|
22
|
+
const method=req.headers['access-control-request-method'],headers=req.headers['access-control-request-headers'],privateNetwork=req.headers['access-control-request-private-network'];
|
|
23
|
+
requireCondition(method==='GET'||(approved&&method===undefined),'METHOD_NOT_ALLOWED','Discovery preflight permits GET only.',{},405);
|
|
24
|
+
requireCondition(headers===undefined||headers.trim()==='','PREFLIGHT_HEADERS_DENIED','Discovery preflight accepts no additional request headers.',{},403);
|
|
25
|
+
requireCondition(privateNetwork===undefined||privateNetwork==='true','INVALID_PREFLIGHT','Invalid private-network preflight.');
|
|
26
|
+
cors=origin===undefined?{}:{'access-control-allow-origin':origin};
|
|
27
|
+
// A non-2xx preflight hides the subsequent refusal from fetch. This grants only the fixed GET response.
|
|
28
|
+
send(res,204,undefined,{...cors,vary:VARY,'access-control-allow-methods':'GET',...(origin!==undefined&&privateNetwork==='true'?{'access-control-allow-private-network':'true'}:{})});return;
|
|
29
|
+
}
|
|
30
|
+
cors=origin===undefined?{}:{'access-control-allow-origin':origin,vary:'Origin'};
|
|
31
|
+
if(!approved){send(res,403,{error:{code:'ORIGIN_DENIED',message:'This website is not allowed to read discovery.'}},cors);return;}
|
|
32
|
+
requireCondition(req.method==='GET','METHOD_NOT_ALLOWED','Discovery requires GET.',{},405);
|
|
33
|
+
requireCondition(!url.search,'UNKNOWN_FIELDS','Discovery takes no parameters.');
|
|
34
|
+
send(res,200,{...read(),traceId},cors);
|
|
35
|
+
}catch(error){
|
|
36
|
+
const trusted=error instanceof AppHostError;
|
|
37
|
+
send(res,trusted?error.status:500,{error:{code:trusted?error.code:'INTERNAL_ERROR',message:trusted?error.message:'The operation could not be completed.'},traceId},cors);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -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
|
-
|
|
9
|
-
export const
|
|
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(
|
|
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
|
-
//
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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});
|
|
@@ -16,6 +17,11 @@ const unknown=(row,code,message,action)=>({...row,status:'unknown',version:null,
|
|
|
16
17
|
const agentBase=channel=>base(channel,'agent-cli','official-user-profile',channel==='claude'?'Claude Code 用户级插件(不覆盖项目或其他隔离资料)':'Codex CLI 本地市场用户资料(不代表桌面 App、项目或其他隔离资料)');
|
|
17
18
|
const inventoryError=()=>{throw new AppHostError('AGENT_INVENTORY_INVALID','Official inventory shape is unsupported.',{},503)};
|
|
18
19
|
const qualified=x=>typeof x==='string'&&/^[a-z0-9][a-z0-9._-]*@[a-z0-9][a-z0-9._-]*$/.test(x)&&x.length<=257;
|
|
20
|
+
function claudeMarketplaceUrl(value){
|
|
21
|
+
if(value.source==='github'&&typeof value.repo==='string'&&/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repo))return `https://github.com/${value.repo}`;
|
|
22
|
+
const url=['url','git'].includes(value.source)?value.url:null;
|
|
23
|
+
return value.source==='git'&&typeof url==='string'&&/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/.test(url)?url.slice(0,-4):url;
|
|
24
|
+
}
|
|
19
25
|
function parseInventory(channel,plugins,marketplaces,target){
|
|
20
26
|
let rows,markets;
|
|
21
27
|
if(channel==='claude'){
|
|
@@ -28,7 +34,7 @@ function parseInventory(channel,plugins,marketplaces,target){
|
|
|
28
34
|
if(!markets.every(r=>object(r)&&typeof r.name==='string'&&r.name.length>0&&r.name.length<=128))inventoryError();
|
|
29
35
|
const sourceRows=markets.filter(r=>r.name===target.marketplace);
|
|
30
36
|
if(sourceRows.length>1)throw new AppHostError('AGENT_INVENTORY_CONFLICT','Conflicting marketplace identities.',{},503);
|
|
31
|
-
if(sourceRows.length){const m=sourceRows[0],url=channel==='claude'?(
|
|
37
|
+
if(sourceRows.length){const m=sourceRows[0],url=channel==='claude'?claudeMarketplaceUrl(m):(['git','url'].includes(m.marketplaceSource?.sourceType)?m.marketplaceSource.source:null);
|
|
32
38
|
if(url!==target.marketplaceUrl)throw new AppHostError('AGENT_MARKETPLACE_SOURCE_MISMATCH','The configured marketplace source is not the registered target.',{},503);
|
|
33
39
|
}
|
|
34
40
|
const id=`${target.pluginName}@${target.marketplace}`;
|
|
@@ -48,11 +54,11 @@ function parseInventory(channel,plugins,marketplaces,target){
|
|
|
48
54
|
return{status:failed?'incomplete':'installed',version,components:[{name:target.pluginName,version,active:row.enabled}],
|
|
49
55
|
reason:failed?reason(channel==='claude'?'AGENT_PLUGIN_LOAD_FAILED':'AGENT_PLUGIN_PAYLOAD_MISSING','官方清单报告插件负载或加载问题。','在该官方客户端的插件详情中检查并修复。'):version===null?reason('AGENT_VERSION_UNAVAILABLE','已确认该作用域的安装记录,但实际版本未知。','在该官方客户端插件详情中核对实际版本。'):null};
|
|
50
56
|
}
|
|
51
|
-
const failureCodes=new Set(['
|
|
52
|
-
/**
|
|
53
|
-
export function createInstallationStatusReader({client='dsh',core=()=>undefined,
|
|
57
|
+
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']);
|
|
58
|
+
/** Automatic current-user discovery only; `core` is the optional public protocol-1 Cordis service accessor. */
|
|
59
|
+
export function createInstallationStatusReader({client='dsh',core=()=>undefined,workerNode,runCommand=runOfficialInstallationCommand,discoverReader=discoverOfficialReader}={}){
|
|
54
60
|
if(!['dsh','hanamesh'].includes(client))throw new TypeError('client must be dsh or hanamesh');
|
|
55
|
-
const
|
|
61
|
+
const active=new Set();let closed=false;
|
|
56
62
|
const suite=createCoreSuiteAdapter({core});
|
|
57
63
|
async function runtime(signal){
|
|
58
64
|
const row=base(client,'current-runtime','responding-runtime',client==='dsh'?'当前响应的 DSH runtime':'当前响应的 HanaMesh 客户端 runtime');
|
|
@@ -64,20 +70,20 @@ export function createInstallationStatusReader({client='dsh',core=()=>undefined,
|
|
|
64
70
|
}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
71
|
}
|
|
66
72
|
async function agent(channel,signal){
|
|
67
|
-
const initial=agentBase(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);
|
|
73
|
+
const initial=agentBase(channel);let row=initial;
|
|
72
74
|
try{
|
|
73
|
-
const
|
|
74
|
-
if(
|
|
75
|
+
const config=await abortable(discoverReader({channel,signal,workerNode}),signal);
|
|
76
|
+
if(!validReader(config))throw new AppHostError('AGENT_READER_DISCOVERY_INVALID','Invalid automatic reader.',{},503);
|
|
77
|
+
row={...initial,identity:{pluginName:config.pluginName,marketplace:config.marketplace}};
|
|
78
|
+
const invoke=args=>abortable(runCommand({channel,reader:config,args,signal,workerNode}),signal);
|
|
79
|
+
const versionFailure=readerVersionFailure(channel,await invoke(['--version']));
|
|
80
|
+
if(versionFailure)return unknown(row,versionFailure,versionFailure==='AGENT_READER_VERSION_INVALID'?'官方 CLI 版本输出格式无效。':'官方 CLI 低于支持的最低版本。','通过官方渠道升级 Claude Code 至 2.1.301 或以上、Codex CLI 至 0.162.1 或以上,再重新检测。');
|
|
75
81
|
const suffix=channel==='codex'?['--disable','remote_plugin']:[];
|
|
76
82
|
const text=await invoke(['plugin','list','--json',...suffix]);
|
|
77
83
|
const sourceText=await invoke(['plugin','marketplace','list','--json',...suffix]);
|
|
78
84
|
let plugins,marketplaces;try{plugins=JSON.parse(text);marketplaces=JSON.parse(sourceText)}catch{inventoryError()}
|
|
79
85
|
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,'本次官方清单读取未能提供完整且一致的安装事实。','
|
|
86
|
+
}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
87
|
}
|
|
82
88
|
const read=async({signal}={})=>{
|
|
83
89
|
if(closed||signal?.aborted)throw interrupted();const controller=new AbortController();active.add(controller);const combined=signal?AbortSignal.any([signal,controller.signal]):controller.signal;
|