@hanamesh/dsh-app-host 0.6.22 → 0.6.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -14
- package/dist/client-ui.js +4 -4
- 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/suite.js +2 -2
- package/dist/installation-status.d.ts +0 -8
- 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 +14 -13
- 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 +2 -2
- package/docs/INSTALLATION_STATUS.md +29 -18
- package/docs/INSTALL_TARGET.md +6 -2
- package/docs/LICENSES.json +207 -69
- package/docs/SECURITY.md +15 -0
- package/node_modules/@hanamesh/lib-provision/bin/hanamesh-provision.js +0 -0
- package/package.json +61 -50
package/README.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
PX3 跨平台候选:Windows 可以运行插件/公开服务、attach 应用、受控只读 CLI 与普通包安装;`owned` 应用仍在创建进程、数据目录及请求凭据之前明确拒绝 `PLATFORM_UNSUPPORTED`。Windows 单测保留真实 attach 的 HTTP/租约/网关断言,并验证 owned 的持久失败、无进程/文件写入及不消费凭据;这些断言不构成 Windows owned 运行支持。独立 Node 可用不扩大 owned 支持;受管 Node 准备仍遵守原平台限制。Windows 本地快照采用文件 flush 加同目录 rename,不承诺断电后目录项持久;Coding OAuth Gateway 文件源因缺私有 ACL 证明继续拒绝,HTTP 提供方不变。各平台实际结果以本分支 GitHub Actions 矩阵收据为准。
|
|
2
|
+
|
|
3
|
+
R115返修(同0.6.23候选):登录shell按退出结果和封装JSON解析成功,忽略正常stderr;所选CLI保留管理器PATH与XDG目录/缺省,不再用空临时XDG隐藏全局版本。真实Bash/asdf/mise启动链回归与工具来源见 [安装状态](docs/INSTALLATION_STATUS.md)。
|
|
4
|
+
|
|
5
|
+
0.6.23 候选:install-target 对名单外有效网站提供精确CORS的固定403来源拒绝,GET预检以204和按需PNA头支持浏览器读回;不开放能力/清单/安装权限。同意检测后自动发现当前用户的 Claude Code / Codex 官方 CLI,无手动读取器入口;接受不低于 2.1.301 / 0.162.1 且版本格式与清单 JSON 校验通过的版本。安装布局、优先级、只读边界及具名预算见 [安装状态](docs/INSTALLATION_STATUS.md)。
|
|
6
|
+
|
|
7
|
+
PK3 开发供给:`vendor/hanamesh-core-0.3.33.tgz` 是 Core 分支提交 `969a89a1f7e383fca7d6aefa7f27507d3a1605d5` 的正常归档,仅供开发与公开合约测试;SHA256 和来源见 `vendor/core-fixture.json`,安装仍严格检查真实 peers。这不是已发布的 npm Core,也不进入运行时依赖。
|
|
8
|
+
|
|
9
|
+
PK1 兼容候选:开发输入更新为 DSH 0.2.2-rc.1 / Cordis 4.1.0 / Schemastery 3.19.0(适用者)。公开 peer 保留兼容范围,并显式包含 0.2.2 预发布;不锁定用户宿主版本。本分支尚未发布,安装和原生加载结果以 PK1 交件为准。
|
|
10
|
+
|
|
1
11
|
0.6.21 候选:普通市场和网站交接消费 Indexer 的固定 GitHub SHA/子目录/包名/构建名单。客户端沿原确认页展示来源、完整 commit 与本机构建提示;确认回传同一目标,目录更新不得换 SHA。执行仍走受控官方 DSH CLI / pnpm,读回精确来源与启用项后报告需完全退出重开;没有改上游或另写安装器。详见 [安装目标](docs/INSTALL_TARGET.md)。初轮已完成隔离 DSH、网站及真实 DTO 验收;本轮 R25 另修未匹配固定目标时的拒绝与退休目录版本字段,仍待复审,不宣称已发布。
|
|
2
12
|
|
|
3
13
|
0.6.22 候选:普通插件只执行目录固定目标;缺 installMethods 即拒绝。套件安装命令只消费 Core,删除普通包名的元数据查询和规则加载。
|
|
@@ -224,22 +234,14 @@ Actions runs on the standard public/free `macos-15` arm64 runner because the exi
|
|
|
224
234
|
|
|
225
235
|
## 发布检查:平台依赖闭包(不可关闭)
|
|
226
236
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
运行 `npm run release:check` 正常打包并检查;发布仅用 `npm run release:publish`,发布已检查的同一 tarball。直接目录 npm publish 会被 prepublishOnly 明确拒绝。开发工具 `@hanamesh/release-platform-gate` 仅 devDependency,先按接线请求复制经 SHA 固定的正常工具包并更新冻结锁;CI 必须接入这个发布门。详细输出包含不可关闭的原因、正常包/锁 SHA 和扫描路径;absent optional peer 按 npm 定义列明,不等同 optionalDependencies。
|
|
230
|
-
|
|
231
|
-
共享门已升级到 R2 PASS 的 0.1.10,原正常工具 tar SHA256 为 `c72010c96a5f22e36d65f6c1fd850e583dead7e843644eba1657b4416d9cbd0d`;最终 main 来源见 `vendor/release-platform-gate.json`。本仓接线仍须 R1 独审,不等于 npm 发布放行,本次不发布。
|
|
232
|
-
|
|
233
|
-
`verify:pack` 原有 tarball 参数保持;正常消费者验证之后通过共享公开 checkRelease 核对同一个归档,CI沿该入口必经硬门,不另打一个包代替原包。接线没有复制扫描实现。
|
|
234
|
-
|
|
235
|
-
有限依赖形态合同为正常开发工具包中的 `DEPENDENCY_SHAPES.md`(dependency-shapes-v1):支持 registry/git/file/link/tarball/workspace/bundled/vendor 的明确子集,不能当作 npm/pnpm 的无限全集。合同外一律 `UNSUPPORTED_DEPENDENCY_SHAPE`,逐项公开 `package/path/shape/why/action/contract`;缓存、bundle、环境变量或额外 CLI 开关都不能绕过准入。已知缺证据、冻结漂移、完整性和平台违规仍各自具名拒绝,不能以“任意失败”代替正确理由。
|
|
237
|
+
共享 `@hanamesh/release-platform-gate` **0.1.12** 已经 R119b 复审 PASS,来源 `hanamesh-docs/tools/release-platform-gate` 分支 `fix/pg1-leoventory-installer-risk-20261109`,提交 `95e54102f9380ea2e8e4d8a2e36eacd387c3f7e7`。正常工具 tgz SHA256 `caf78747649b1dd37cbe9ba331351c81f94a48dc9e779aee83c75336140068d1`;七载荷摘要、复审提交及冻结 SRI 见 `vendor/release-platform-gate.json`、`vendor/SHA256SUMS` 和锁。它仅为 devDependency,不进入运行时或产品 bundle。
|
|
236
238
|
|
|
237
|
-
|
|
239
|
+
`DSH_PNPM_PLATFORM_CLOSURE` 是不可关闭的正确性不变量(disableable:false)。历史 DSH bundled pnpm 11.7.0 位于 pnpm#12297 风险系列,修复收录于 pnpm 11.23.0。原始 HanaMesh 挂起进程首错仍未找回,不能宣称历史身份/归因已闭合。合法 optional 平台字段和完整强 SRI 也不能独自证明安装器会退出。
|
|
238
240
|
|
|
239
|
-
|
|
241
|
+
根及必需路径的 os/cpu/libc 继续 PLATFORM_FIELD 拒绝;可选子图先完整核锁、实际归档、名称/版本/范围/peer、选择器一致与强 SRI,再按 `dsh-pnpm-platform-exit-v1` 核安装矩阵。所有平台工件均扫描,不按检查机过滤。缺矩阵为 INSTALLATION_TARGETS_REQUIRED,旧风险组合为 PNPM_PLATFORM_HANG_RISK,未审组合为 INSTALLATION_TARGET_UNSUPPORTED;混入一个安全目标不能洗绿旧组合。未知依赖形态仍 UNSUPPORTED_DEPENDENCY_SHAPE,输出 shape / why / action / contract;有限 dependency-shapes-v1 与 INSTALLATION_RISK.md 均随正常工具包交付。没有 skip/关闭开关、环境推断、重试、超时、omit-optional 或白名单绕过。
|
|
240
242
|
|
|
241
|
-
|
|
243
|
+
本次发布资格的完整目标明确保存在 `release/installation-targets.json`:DSH **0.2.2-rc.1 / pnpm 11.28.5 / darwin-arm64、linux-arm64**。这是 R119b 有退出证据的两个组合,不是检查机自动选择,也不把公开宽 peer 范围当作已通过的安装矩阵;其他宿主/安装器/架构不计入本次资格,旧组合仍拒绝。开发冻结安装的 pnpm 10.33.0 不是 bundled 11.28.5 的实装证据;后续 GT19 两机门必须核实际 pnpm 版本和完整闭包。
|
|
242
244
|
|
|
243
|
-
|
|
245
|
+
`release:check` 与 `release:publish` 都显式传同一 `--installation-targets release/installation-targets.json`;`verify:pack` 在原消费者验证后通过共享公开 `checkRelease({installationTargets:...})` 检查同一归档,没有复制扫描实现或捕获忽略门错误。
|
|
244
246
|
|
|
245
|
-
|
|
247
|
+
运行 `npm run release:check` 产生正常归档并核验;`release:publish` 只发布同一已检查且复核 SHA 的 tar。`prepublishOnly` 的 source-guard 拒绝源码目录直接发布。CI 的既有 verify:pack / preflight 必经此门;无 continue-on-error 或旁路。此接线提交没有执行 npm publish、tag、部署或产品真机发布验收。
|
package/dist/client-ui.js
CHANGED
|
@@ -267,8 +267,8 @@ window.__ModuleLoader__.load({id:'@hanamesh/dsh-app-host',factory:function(requi
|
|
|
267
267
|
"copy261": "准备安装环境未完成。请查看技术详情中的准确原因;未自动换源或重试。",
|
|
268
268
|
"copy262": "只在本机 127.0.0.1 上监听,请求的 Host 必须是 127.0.0.1 或 localhost 加监听端口",
|
|
269
269
|
"copy263": "防止别的机器或 DNS 重绑定的网页冒充本机",
|
|
270
|
-
"copy264": "
|
|
271
|
-
"copy265": "
|
|
270
|
+
"copy264": "只有「允许交接的网站」能读取客户端能力、交接安装和交接状态;未许可网站仅能读取固定的来源拒绝提示",
|
|
271
|
+
"copy265": "拒绝提示只说明本机监听拒绝了该网站,不透露客户端类型、能力、安装情况或允许名单",
|
|
272
272
|
"copy266": "网站交接只会在应用库里弹出确认;只有你点「确认安装」才会安装",
|
|
273
273
|
"copy267": "网站不能替你决定装什么",
|
|
274
274
|
"copy268": "交接状态只给发起它的那个网站看",
|
|
@@ -568,8 +568,8 @@ window.__ModuleLoader__.load({id:'@hanamesh/dsh-app-host',factory:function(requi
|
|
|
568
568
|
"copy261": "Installation environment preparation did not complete. See Technical details for the exact reason. No automatic source switching or retry occurred.",
|
|
569
569
|
"copy262": "Listen only on 127.0.0.1. The request Host must be 127.0.0.1 or localhost with the listening port.",
|
|
570
570
|
"copy263": "Prevents remote machines or DNS-rebinding pages from impersonating this machine.",
|
|
571
|
-
"copy264": "Only allowed websites can
|
|
572
|
-
"copy265": "
|
|
571
|
+
"copy264": "Only allowed websites can read client capabilities, hand off installations or read handoff status. Other websites can read only a fixed origin-refusal message.",
|
|
572
|
+
"copy265": "The refusal reveals only that a local listener rejected the website, without client type, capabilities, installations or the allowlist.",
|
|
573
573
|
"copy266": "Website handoffs only open market confirmation. Installation requires your Confirm installation action.",
|
|
574
574
|
"copy267": "A website cannot decide what to install for you.",
|
|
575
575
|
"copy268": "Handoff status is visible only to the website that created it.",
|
package/dist/dsh-compat.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import semver from 'semver';
|
|
2
2
|
|
|
3
3
|
/** Public host compatibility; exact dependency versions in devDependencies remain reproducible test inputs. */
|
|
4
|
-
export const DSH_RANGE='>=0.2.0-rc.2 <0.3.0-0 || >=0.2.1-0 <0.3.0-0';
|
|
4
|
+
export const DSH_RANGE='>=0.2.0-rc.2 <0.3.0-0 || >=0.2.1-0 <0.3.0-0 || >=0.2.2-0 <0.3.0-0';
|
|
5
5
|
/** saveExact entered the public PluginManager contract in 0.2.1-alpha.2. Unknown versions fail closed. */
|
|
6
6
|
export function managerSupportsExactSave(version){return typeof version==='string'&&semver.satisfies(version,'>=0.2.1-alpha.2 <0.3.0-0',{includePrerelease:true});}
|
package/dist/dsh.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type {InstallationReaderConfig} from './installation-status.js';
|
|
2
1
|
import type { AppHost, AppDefinition, AuthenticatedSubject, Awaitable, DshStorageBinding } from './index.js';
|
|
3
2
|
export const name: 'hanamesh-app-host';
|
|
4
3
|
export const DSH_TARGET: '>=0.2.0-rc.2 <0.3.0-0 || >=0.2.1-0 <0.3.0-0';
|
|
@@ -13,7 +12,7 @@ export interface DshPluginConfig {
|
|
|
13
12
|
/** `sources` undefined → the HanaMesh catalog source; explicit `[]` → none. `profileDir`/`profileName`/`dshBin` (and
|
|
14
13
|
* `nodeBinary`) are inferred from the proven Desktop layout or plain DSH when undefined — see docs/CONTRACT.md 「应用库配置」; explicit values win. */
|
|
15
14
|
/** rc.28: the same keys serve the HanaMesh market (plugins + applications; routes `/hanamesh/library*`, see docs/CONTRACT.md「市场」). */
|
|
16
|
-
library?:{fixture?:string;sources?:Array<{manifestUrl:string;enabled?:boolean}>;profileDir?:string;profileName?:string;dshBin?:string;registry?:string;eventRetention?:number
|
|
15
|
+
library?:{fixture?:string;sources?:Array<{manifestUrl:string;enabled?:boolean}>;profileDir?:string;profileName?:string;dshBin?:string;registry?:string;eventRetention?:number};
|
|
17
16
|
applications?: AppDefinition[]; leaseTtlMs?: number; sweepIntervalMs?: number;
|
|
18
17
|
}
|
|
19
18
|
/** Adapt an open `ctx.storageDomain` handle (single layout, global snapshot) to the store binding. */
|
package/dist/dsh.js
CHANGED
|
@@ -76,8 +76,6 @@ export const Config = z.object({
|
|
|
76
76
|
registry:z.string(),
|
|
77
77
|
/** Publicly named event history capacity; a lost cursor reports resetRequired. */
|
|
78
78
|
eventRetention:z.number().default(512),
|
|
79
|
-
/** Explicit official CLI readers; unset means unknown, never inferred from PATH or private files. */
|
|
80
|
-
installationReaders:z.union([z.any(),z.const(undefined)]),
|
|
81
79
|
/** Website → client handoff listener (library/handoff.js). Unset fields use the defaults shown in the library
|
|
82
80
|
* settings page, where they can also be changed (saved values win over this config). */
|
|
83
81
|
handoff:z.object({enabled:z.boolean(),ports:z.union([z.array(z.number()),z.const(undefined)]),
|
|
@@ -260,7 +258,7 @@ export async function apply(ctx, config) {
|
|
|
260
258
|
};
|
|
261
259
|
const identity=await detectClient({desktop});
|
|
262
260
|
if(typeof ctx.logger?.info==='function')ctx.logger.info('hanamesh-app-host library: client %s (%s)',identity.client,identity.evidence);
|
|
263
|
-
library=createLibraryService({domain:{global:libraryDomain.global,close:closeLibraryDomain},host,config:libraryConfig,dataRoot,ledgerReader:provisionApi?.ledger??(async()=>({schema:1,items:{}})),installer,client:identity.client,unavailable,suiteSourceService:()=>suiteSourceService,installationStatus:nodeStatusBinding(()=>nodeRuntime.executable,workerNode=>createInstallationStatusReader({client:identity.client,core:()=>coreService,workerNode
|
|
261
|
+
library=createLibraryService({domain:{global:libraryDomain.global,close:closeLibraryDomain},host,config:libraryConfig,dataRoot,ledgerReader:provisionApi?.ledger??(async()=>({schema:1,items:{}})),installer,client:identity.client,unavailable,suiteSourceService:()=>suiteSourceService,installationStatus:nodeStatusBinding(()=>nodeRuntime.executable,workerNode=>createInstallationStatusReader({client:identity.client,core:()=>coreService,workerNode})),logger:ctx.logger});
|
|
264
262
|
own('library', () => library.close());
|
|
265
263
|
await library.init();
|
|
266
264
|
// rc.27: app bundles register plain `app.json` (no package name); the installed scan knows which npm package ships
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Live Windows tree anchor; its reaper closes the tree before removing trace files.
|
|
2
|
+
import {spawn,spawnSync} from 'node:child_process';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
const stop=()=>{spawnSync(join(process.env.SystemRoot,'System32','taskkill.exe'),['/PID',String(process.pid),'/T','/F'],{stdio:'ignore',windowsHide:true});process.exit(1);};
|
|
5
|
+
process.once('disconnect',stop);
|
|
6
|
+
process.once('message',({command,args,options})=>{
|
|
7
|
+
const {input,...spawnOptions}=options;
|
|
8
|
+
const child=spawn(command,args,{...spawnOptions,stdio:[input===undefined?'ignore':'pipe','inherit','inherit'],detached:false});
|
|
9
|
+
if(input!==undefined){child.stdin.on('error',()=>{});child.stdin.end(input);}
|
|
10
|
+
child.once('error',error=>{if(process.connected)process.send({error:{code:error.code??'SPAWN_FAILED'}});});
|
|
11
|
+
child.once('close',(code,signal)=>{if(process.connected)process.send({result:{code,status:code,signal}});});
|
|
12
|
+
});
|
package/dist/git-process.js
CHANGED
|
@@ -7,6 +7,11 @@ const policy=Object.freeze({GIT_CONFIG_NOSYSTEM:'1',GIT_TERMINAL_PROMPT:'0',GIT_
|
|
|
7
7
|
GIT_CONFIG_KEY_1:'credential.helper',GIT_CONFIG_VALUE_1:'!gh auth git-credential'});
|
|
8
8
|
export function safeGitEnv(base=process.env){
|
|
9
9
|
const env={...base};
|
|
10
|
+
// Nested guards share ownership only within the same explicitly selected temp root.
|
|
11
|
+
// A caller choosing a different scratch root starts an independent trace owner.
|
|
12
|
+
if(!env.HM_GIT_GUARD_PARENT_ROOT&&base.TMPDIR===process.env.TMPDIR&&process.env.HM_GIT_GUARD_PARENT_ROOT){
|
|
13
|
+
env.HM_GIT_GUARD_PARENT_ROOT=process.env.HM_GIT_GUARD_PARENT_ROOT;env.HM_GIT_GUARD_TMP_ROOT=process.env.HM_GIT_GUARD_TMP_ROOT;
|
|
14
|
+
}
|
|
10
15
|
for(const key of Object.keys(env))if(/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+|PARAMETERS)$/.test(key)||/^GIT_TRACE/.test(key)||['GIT_ASKPASS','SSH_ASKPASS'].includes(key))delete env[key];
|
|
11
16
|
return {...env,...policy,npm_config_manage_package_manager_versions:'false',pnpm_config_manage_package_manager_versions:'false'};
|
|
12
17
|
}
|
|
@@ -27,7 +32,7 @@ export function observeGitTrace(directory){
|
|
|
27
32
|
let event;try{event=JSON.parse(line);}catch{throw Object.assign(new Error('Invalid Git credential trace.'),{code:'GIT_TRACE_INVALID'});}
|
|
28
33
|
if(['child_start','exec'].includes(event.event)&&forbidden.test((event.argv??[]).join(' '))){failure=Object.assign(new Error('Forbidden macOS keychain helper was invoked; operation failed.'),{code:'GIT_KEYCHAIN_FORBIDDEN'});throw failure;}
|
|
29
34
|
}
|
|
30
|
-
},close(){if(closed)return;closed=true;try{closeSync(fd);}finally{rmSync(directory,{recursive:true,force:true});}}};
|
|
35
|
+
},close(){if(closed)return;closed=true;try{closeSync(fd);}finally{rmSync(directory,{recursive:true,force:true,maxRetries:4,retryDelay:25});}}};
|
|
31
36
|
}
|
|
32
37
|
export function guardNodeBinary({workerNode,versions=process.versions,execPath=process.execPath}={}){
|
|
33
38
|
if(workerNode!==undefined)return typeof workerNode==='string'&&isAbsolute(workerNode)?workerNode:undefined;
|
|
@@ -43,7 +48,7 @@ export async function spawnGitSafe(command,args,options={}){
|
|
|
43
48
|
try{
|
|
44
49
|
if(signal?.aborted)throw Object.assign(new Error('Guarded command aborted.'),{name:'AbortError',code:'ABORT_ERR'});
|
|
45
50
|
const stdio=options.stdio==='inherit'?['ignore','inherit','inherit','ipc']:['ignore','pipe','pipe','ipc'];
|
|
46
|
-
worker=fork(new URL('./git-worker.js',import.meta.url),[],{cwd:options.cwd,env:safeGitEnv(options.env??process.env),execArgv:[],execPath:nodeRuntime,detached:
|
|
51
|
+
worker=fork(new URL('./git-worker.js',import.meta.url),[],{cwd:options.cwd,env:safeGitEnv(options.env??process.env),execArgv:[],execPath:nodeRuntime,detached:true,windowsHide:true,stdio});
|
|
47
52
|
worker.stdout?.on('data',chunk=>{stdout=(stdout+chunk.toString(encoding)).slice(-4*1024*1024);});
|
|
48
53
|
worker.stderr?.on('data',chunk=>{stderr=(stderr+chunk.toString(encoding)).slice(-4*1024*1024);});
|
|
49
54
|
signal?.addEventListener('abort',abort,{once:true});
|
|
@@ -59,6 +64,6 @@ export async function spawnGitSafe(command,args,options={}){
|
|
|
59
64
|
}finally{
|
|
60
65
|
clearTimeout(deadline);clearTimeout(abortFallback);signal?.removeEventListener('abort',abort);
|
|
61
66
|
if(worker?.connected)worker.disconnect();
|
|
62
|
-
if(directory)rmSync(directory,{recursive:true,force:true});
|
|
67
|
+
if(directory)rmSync(directory,{recursive:true,force:true,maxRetries:4,retryDelay:25});
|
|
63
68
|
}
|
|
64
69
|
}
|
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);}
|
|
@@ -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
|
}
|
|
@@ -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/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
|
+
}
|