@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.5
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/CLA.md +79 -0
- package/LICENSE-COMMERCIAL +78 -0
- package/LICENSE-CORE +661 -0
- package/LICENSE-SDK +21 -0
- package/LICENSES/cordis-MIT.txt +39 -0
- package/NOTICE +95 -0
- package/README.md +158 -2
- package/client.js +7287 -0
- package/cordis.patch.yml +163 -0
- package/defaults/umbrella-rules.yaml +113 -0
- package/docs/APPROVAL-DRILL.md +42 -0
- package/docs/CONTRACT-INTEROP.md +77 -0
- package/docs/CRASH-SAFETY.md +69 -0
- package/docs/DECISION-RELAY.md +46 -0
- package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
- package/docs/FORK-SEED.md +64 -0
- package/docs/MAIN-CITY-GATE.md +58 -0
- package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
- package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
- package/docs/PM-DECISION-POLICY.md +61 -0
- package/docs/WORKFLOW-UI.md +86 -0
- package/icon.svg +8 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -4
- package/sdk/README.md +65 -0
- package/sdk/examples/status-badge/README.md +16 -0
- package/sdk/examples/status-badge/client.js +60 -0
- package/sdk/examples/status-badge/cordis.patch.yml +7 -0
- package/sdk/examples/status-badge/index.js +12 -0
- package/sdk/examples/status-badge/package.json +24 -0
- package/sdk/templates/plugin/README.md +36 -0
- package/sdk/templates/plugin/client.js +35 -0
- package/sdk/templates/plugin/cordis.patch.yml +8 -0
- package/sdk/templates/plugin/icon.svg +5 -0
- package/sdk/templates/plugin/index.js +23 -0
- package/sdk/templates/plugin/locale/en.json +6 -0
- package/sdk/templates/plugin/package.json +36 -0
- package/src/compose.mjs +67 -0
- package/src/contract/VENDOR.json +16 -0
- package/src/contract/client.js +93 -0
- package/src/contract/constants.js +94 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +20 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/relay.js +223 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.mjs +11 -0
- package/src/kernel/core.mjs +100 -0
- package/src/kernel/extensions.mjs +61 -0
- package/src/kernel/fsx.mjs +349 -0
- package/src/kernel/index.mjs +21 -0
- package/src/kernel/ledger.mjs +94 -0
- package/src/kernel/paths.mjs +182 -0
- package/src/kernel/role.mjs +68 -0
- package/src/kernel/rounds.mjs +172 -0
- package/src/kernel/schema.mjs +5 -0
- package/src/kernel/types.mjs +266 -0
- package/src/kernel/vendor/cosmokit.mjs +421 -0
- package/src/kernel/vendor/schemastery.mjs +638 -0
- package/src/lib/adapter-client.mjs +383 -0
- package/src/lib/adapter-protocol.mjs +243 -0
- package/src/lib/ai-secret-store.mjs +202 -0
- package/src/lib/approval-drill.mjs +111 -0
- package/src/lib/boot-guard.mjs +186 -0
- package/src/lib/branch-store.mjs +84 -0
- package/src/lib/branch.mjs +74 -0
- package/src/lib/bridge-client.mjs +133 -0
- package/src/lib/broker-adapter.mjs +67 -0
- package/src/lib/broker.mjs +45 -0
- package/src/lib/business-producers.mjs +109 -0
- package/src/lib/command-cache.mjs +209 -0
- package/src/lib/compat.mjs +170 -0
- package/src/lib/composer-journal.mjs +128 -0
- package/src/lib/container/index.mjs +806 -0
- package/src/lib/decision-events.mjs +129 -0
- package/src/lib/decision-ingest-client.mjs +41 -0
- package/src/lib/decision-outbox.mjs +129 -0
- package/src/lib/decision-policy.mjs +52 -0
- package/src/lib/default-essence.mjs +61 -0
- package/src/lib/desktop-launch.mjs +138 -0
- package/src/lib/essence.mjs +1211 -0
- package/src/lib/fork-client.mjs +410 -0
- package/src/lib/fork-config-lock.mjs +166 -0
- package/src/lib/fork-seed.mjs +304 -0
- package/src/lib/fork-sessions.mjs +183 -0
- package/src/lib/fork-supervision-settings.mjs +58 -0
- package/src/lib/fork-supervisor.mjs +122 -0
- package/src/lib/fs-guard.mjs +28 -0
- package/src/lib/http-auth.mjs +162 -0
- package/src/lib/idle-audit.mjs +178 -0
- package/src/lib/interaction-poke.mjs +61 -0
- package/src/lib/main-gate.mjs +582 -0
- package/src/lib/mirror-etag.mjs +92 -0
- package/src/lib/mirror-official-source.mjs +93 -0
- package/src/lib/mirror.mjs +478 -0
- package/src/lib/mother-link.mjs +156 -0
- package/src/lib/mutex.mjs +31 -0
- package/src/lib/permissions.mjs +115 -0
- package/src/lib/pnpm-store.mjs +189 -0
- package/src/lib/preferences.mjs +212 -0
- package/src/lib/producer-contract.mjs +162 -0
- package/src/lib/reflow.mjs +41 -0
- package/src/lib/remote.mjs +408 -0
- package/src/lib/restart-contract.mjs +752 -0
- package/src/lib/roots.mjs +30 -0
- package/src/lib/rule-registry.mjs +1127 -0
- package/src/lib/runtime-capture.mjs +307 -0
- package/src/lib/semaphore.mjs +34 -0
- package/src/lib/session-bundle.mjs +138 -0
- package/src/lib/session-format.mjs +672 -0
- package/src/lib/sse-parser.mjs +102 -0
- package/src/lib/ssrf-guard.mjs +170 -0
- package/src/lib/status-store.mjs +257 -0
- package/src/lib/task-handle.mjs +622 -0
- package/src/lib/ui-actions.mjs +55 -0
- package/src/lib/umbrella.mjs +1661 -0
- package/src/lib/upgrade-coordinator.mjs +107 -0
- package/src/lib/upgrade-status.mjs +43 -0
- package/src/plugins/adapter.mjs +1468 -0
- package/src/plugins/approver.mjs +1210 -0
- package/src/plugins/auditor.mjs +330 -0
- package/src/plugins/buffer-keeper.mjs +522 -0
- package/src/plugins/decision-center.mjs +2581 -0
- package/src/plugins/forker.mjs +1610 -0
- package/src/plugins/healer.mjs +702 -0
- package/src/plugins/hot-reloader.mjs +274 -0
- package/src/plugins/imports.mjs +247 -0
- package/src/plugins/orchestrator.mjs +3979 -0
- package/src/plugins/restart-guard.mjs +1022 -0
- package/src/plugins/router.mjs +714 -0
- package/src/plugins/scorer.mjs +661 -0
- package/src/plugins/session-bridge.mjs +166 -0
- package/src/plugins/session-catalog.mjs +771 -0
- package/src/plugins/session-router.mjs +125 -0
- package/src/plugins/session-sync.mjs +171 -0
- package/src/plugins/umbrella.mjs +419 -0
- package/tools/approval-drill.mjs +21 -0
- package/tools/boot-guard.mjs +95 -0
- package/tools/decision-ingest.mjs +53 -0
- package/tools/desktop-launch.mjs +71 -0
- package/tools/fangctl.mjs +425 -0
- package/tools/gate/main-city.mjs +204 -0
- package/tools/mcp/server.mjs +121 -0
- package/tools/mcp/tools.mjs +65 -0
- package/tools/recover-queue.mjs +133 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// fang-link(v2.9.0)— 母体连接描述 + 连通性探测(纯逻辑,供 orchestrator 服务与 HTTP 路由复用)。
|
|
4
|
+
//
|
|
5
|
+
// 边界:
|
|
6
|
+
// * 只描述、不持有连接状态:分身的 token 由 Forker 在 spawn 时注入,fang 不落盘、不回显;
|
|
7
|
+
// * 探测是只读 GET `${target}${prefix}/api/status`,带超时、不抛异常,失败以结构化错误返回;
|
|
8
|
+
// * 目标只接受 http(s) origin,拒绝凭据/查询串/非 http 协议(避免把 token 写进 URL)。
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
|
|
13
|
+
export const MOTHER_LINK_PATHS = Object.freeze({
|
|
14
|
+
DESCRIBE: '/api/mother-link',
|
|
15
|
+
PROBE: '/api/mother-link/probe',
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_LINK_TIMEOUT_MS = 3000
|
|
19
|
+
export const MAX_LINK_TIMEOUT_MS = 10000
|
|
20
|
+
export const MIN_LINK_TIMEOUT_MS = 200
|
|
21
|
+
|
|
22
|
+
/** @param {unknown} raw @returns {string} */
|
|
23
|
+
export function normalizePrefix(raw) {
|
|
24
|
+
const text = String(raw ?? '/fang').trim().replace(/\/+$/, '')
|
|
25
|
+
if (text.length === 0) return '/fang'
|
|
26
|
+
return text.startsWith('/') ? text : `/${text}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 校验并归一化母体地址(只保留 origin,丢弃 path/query/hash)。
|
|
31
|
+
* @param {unknown} raw
|
|
32
|
+
* @returns {{ok:true, origin:string, host:string} | {ok:false, error:string}}
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeLinkOrigin(raw) {
|
|
35
|
+
const text = String(raw ?? '').trim()
|
|
36
|
+
if (text.length === 0) return { ok: false, error: 'target-empty' }
|
|
37
|
+
if (text.length > 512) return { ok: false, error: 'target-too-long' }
|
|
38
|
+
let url
|
|
39
|
+
try {
|
|
40
|
+
url = new URL(text)
|
|
41
|
+
} catch {
|
|
42
|
+
return { ok: false, error: 'target-invalid-url' }
|
|
43
|
+
}
|
|
44
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return { ok: false, error: 'target-scheme' }
|
|
45
|
+
if (url.username.length > 0 || url.password.length > 0) return { ok: false, error: 'target-credentials' }
|
|
46
|
+
if (url.search.length > 0 || url.hash.length > 0) return { ok: false, error: 'target-query' }
|
|
47
|
+
return { ok: true, origin: `${url.protocol}//${url.host}`, host: url.host }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 母体侧读取 profile 的“共享配置摘要”(只读、失败降级为 null。
|
|
52
|
+
* @param {{profileDir?: string, env?: Record<string, string|undefined>}} [options]
|
|
53
|
+
* @returns {{layer:'profile', profile:string|null, profileDir:string|null, bundles:number|null, name?:string}}
|
|
54
|
+
*/
|
|
55
|
+
export function readSharedProfileSummary(options = {}) {
|
|
56
|
+
const env = options.env ?? process.env
|
|
57
|
+
const profile = typeof env.DSH_PROFILE === 'string' && env.DSH_PROFILE.length > 0 ? env.DSH_PROFILE : null
|
|
58
|
+
const profileDir = String(options.profileDir ?? '').trim()
|
|
59
|
+
const fallback = { layer: 'profile', profile, profileDir: profileDir.length > 0 ? profileDir : null, bundles: null }
|
|
60
|
+
if (profileDir.length === 0) return fallback
|
|
61
|
+
try {
|
|
62
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(profileDir, 'package.json'), 'utf8'))
|
|
63
|
+
const bundles = Array.isArray(pkg?.dsh?.profile?.bundles) ? pkg.dsh.profile.bundles.length : null
|
|
64
|
+
return {
|
|
65
|
+
...fallback,
|
|
66
|
+
bundles,
|
|
67
|
+
name: typeof pkg?.name === 'string' ? pkg.name : undefined,
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
return fallback
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 生成母体连接描述(纯函数)。
|
|
76
|
+
* @param {{role?: string, version?: string, prefix?: string, profileDir?: string, sharedConfig?: object, now?: () => number}} options
|
|
77
|
+
* @returns {Record<string, unknown>}
|
|
78
|
+
*/
|
|
79
|
+
export function describeMotherLink(options = {}) {
|
|
80
|
+
const now = options.now ?? Date.now
|
|
81
|
+
const prefix = normalizePrefix(options.prefix ?? '/fang')
|
|
82
|
+
const sharedConfig = options.sharedConfig ?? readSharedProfileSummary({ profileDir: options.profileDir })
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
role: typeof options.role === 'string' ? options.role : 'mother',
|
|
86
|
+
version: typeof options.version === 'string' ? options.version : '',
|
|
87
|
+
at: now(),
|
|
88
|
+
prefix,
|
|
89
|
+
motherApiBase: `${prefix}/api`,
|
|
90
|
+
adapterBase: `${prefix}/adapter`,
|
|
91
|
+
probe: {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
path: `${prefix}${MOTHER_LINK_PATHS.PROBE}`,
|
|
94
|
+
body: { target: 'http(s)://<mother-host>', timeoutMs: DEFAULT_LINK_TIMEOUT_MS },
|
|
95
|
+
},
|
|
96
|
+
tokenPolicy: 'fork adapter token is injected by Forker at spawn time; fang never persists or echoes it',
|
|
97
|
+
sharedConfig,
|
|
98
|
+
forkInstructions: [
|
|
99
|
+
'打开母体 web 门户(/fang/panel)或瓮城面板的「母体连接」卡片',
|
|
100
|
+
'分身只需母体地址;令牌由 Forker 注入 DSH_ADAPTER_TOKEN,不需要手工填写',
|
|
101
|
+
'连通性测试调用 POST /fang/api/mother-link/probe,不会写入任何配置',
|
|
102
|
+
],
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 探测母体 `/api/status`(一次 GET,超时上限 10s;任何失败都返回结构化错误,不抛)。
|
|
108
|
+
* @param {{target?: unknown, prefix?: string, timeoutMs?: number, timeoutMsLimit?: number, fetchImpl?: typeof fetch, now?: () => number}} options
|
|
109
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
110
|
+
*/
|
|
111
|
+
export async function probeLink(options = {}) {
|
|
112
|
+
const now = options.now ?? Date.now
|
|
113
|
+
const normalized = normalizeLinkOrigin(options.target)
|
|
114
|
+
if (!normalized.ok) {
|
|
115
|
+
return { ok: false, target: String(options.target ?? ''), error: normalized.error, latencyMs: 0, checkedAt: now() }
|
|
116
|
+
}
|
|
117
|
+
const rawTimeout = Number.isFinite(options.timeoutMs) ? Math.trunc(options.timeoutMs) : DEFAULT_LINK_TIMEOUT_MS
|
|
118
|
+
const timeoutMs = Math.min(Math.max(rawTimeout, MIN_LINK_TIMEOUT_MS), MAX_LINK_TIMEOUT_MS)
|
|
119
|
+
const prefix = normalizePrefix(options.prefix ?? '/fang')
|
|
120
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch
|
|
121
|
+
if (typeof fetchImpl !== 'function') {
|
|
122
|
+
return { ok: false, target: normalized.origin, error: 'fetch-unavailable', latencyMs: 0, checkedAt: now() }
|
|
123
|
+
}
|
|
124
|
+
const started = now()
|
|
125
|
+
try {
|
|
126
|
+
const response = await fetchImpl(`${normalized.origin}${prefix}/api/status`, {
|
|
127
|
+
method: 'GET',
|
|
128
|
+
headers: { accept: 'application/json' },
|
|
129
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
130
|
+
})
|
|
131
|
+
const latencyMs = Math.max(0, now() - started)
|
|
132
|
+
let payload = null
|
|
133
|
+
try {
|
|
134
|
+
payload = await response.json()
|
|
135
|
+
} catch {
|
|
136
|
+
payload = null
|
|
137
|
+
}
|
|
138
|
+
const version = typeof payload?.version === 'string' ? payload.version : null
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
return { ok: false, target: normalized.origin, httpStatus: response.status, latencyMs, version, error: 'status-http-error', checkedAt: now() }
|
|
141
|
+
}
|
|
142
|
+
return { ok: true, target: normalized.origin, httpStatus: response.status, latencyMs, version, checkedAt: now() }
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const name = error instanceof Error ? error.name : ''
|
|
145
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
146
|
+
const timedOut = name === 'TimeoutError' || name === 'AbortError'
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
target: normalized.origin,
|
|
150
|
+
error: timedOut ? 'timeout' : 'network-error',
|
|
151
|
+
message: message.slice(0, 200),
|
|
152
|
+
latencyMs: Math.max(0, now() - started),
|
|
153
|
+
checkedAt: now(),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// dsh-Fang v2.12.0 — per-key 串行锁(零依赖)。
|
|
3
|
+
// 用途:同一原身的 ensureFresh/重算串行化;不同 key 互不阻塞。
|
|
4
|
+
// 失败不阻断链:then(fn, fn) 保证前一个任务 reject 后下一个仍执行。
|
|
5
|
+
|
|
6
|
+
export class KeyedMutex {
|
|
7
|
+
constructor() {
|
|
8
|
+
/** @type {Map<string, Promise<unknown>>} */
|
|
9
|
+
this.chains = new Map()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @template T
|
|
14
|
+
* @param {string} key
|
|
15
|
+
* @param {() => (T|Promise<T>)} fn
|
|
16
|
+
* @returns {Promise<T>}
|
|
17
|
+
*/
|
|
18
|
+
run(key, fn) {
|
|
19
|
+
const previous = this.chains.get(key) ?? Promise.resolve()
|
|
20
|
+
const next = previous.then(fn, fn).finally(() => {
|
|
21
|
+
if (this.chains.get(key) === next) this.chains.delete(key)
|
|
22
|
+
})
|
|
23
|
+
this.chains.set(key, next)
|
|
24
|
+
return next
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 当前是否还有排队/执行中的 key(诊断用)。 */
|
|
28
|
+
size() {
|
|
29
|
+
return this.chains.size
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// dsh-Fang v3.0.4 — 三层产品形态的权限契约(fail-closed)。
|
|
3
|
+
//
|
|
4
|
+
// 母体版 Mirror:只有官方插件,不能开会话/任务;
|
|
5
|
+
// 原身版 Essence:可装第三方插件,不能开会话/任务;
|
|
6
|
+
// 分身版 Derived:不能装插件,可开会话/任务。
|
|
7
|
+
//
|
|
8
|
+
// 说明:真正的硬隔离靠“profile 里装不装对应插件”(第一道门);
|
|
9
|
+
// 本模块是宿主/Adapter 侧的第二道门(防止直连 API 绕过 UI)。
|
|
10
|
+
|
|
11
|
+
export const ENV_TYPE = Object.freeze({
|
|
12
|
+
MIRROR: 'mirror',
|
|
13
|
+
ESSENCE: 'essence',
|
|
14
|
+
DERIVED: 'derived',
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export const ENV_TYPES = Object.freeze(Object.values(ENV_TYPE))
|
|
18
|
+
|
|
19
|
+
/** @type {Record<string, {installPlugins:boolean, openSessions:boolean}>} */
|
|
20
|
+
export const PERMISSION_RULES = Object.freeze({
|
|
21
|
+
mirror: Object.freeze({ installPlugins: false, openSessions: false }),
|
|
22
|
+
essence: Object.freeze({ installPlugins: true, openSessions: false }),
|
|
23
|
+
derived: Object.freeze({ installPlugins: false, openSessions: true }),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export const ACTION_GROUPS = Object.freeze({
|
|
27
|
+
installPlugins: Object.freeze(['plugin-install', 'plugin-uninstall', 'plugin-update', 'plugin-set']),
|
|
28
|
+
openSessions: Object.freeze(['session-create', 'task-enqueue', 'agent-run', 'chat-send']),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export const PERMISSION_DENIED = 'permission-denied'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 规范化环境类型;未知值一律视为 unknown(后续 fail-closed 拒绝)。
|
|
35
|
+
* @param {unknown} value
|
|
36
|
+
* @returns {'mirror'|'essence'|'derived'|'unknown'}
|
|
37
|
+
*/
|
|
38
|
+
export function resolveEnvType(value) {
|
|
39
|
+
const text = String(value ?? '')
|
|
40
|
+
return ENV_TYPES.includes(text) ? text : 'unknown'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 检查某个动作在某环境下是否允许。
|
|
45
|
+
* 未知环境 / 未知动作 → 拒绝(fail-closed)。
|
|
46
|
+
* @param {unknown} envType
|
|
47
|
+
* @param {string} action
|
|
48
|
+
* @returns {{allowed:boolean, envType:string, action:string, code?:string, message?:string}}
|
|
49
|
+
*/
|
|
50
|
+
export function checkPermission(envType, action) {
|
|
51
|
+
const env = resolveEnvType(envType)
|
|
52
|
+
const name = String(action ?? '')
|
|
53
|
+
const rule = PERMISSION_RULES[env]
|
|
54
|
+
if (rule === undefined) {
|
|
55
|
+
return {
|
|
56
|
+
allowed: false, envType: env, action: name, code: PERMISSION_DENIED,
|
|
57
|
+
message: `未知环境类型:${String(envType)}(fail-closed)`,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const group = Object.keys(ACTION_GROUPS).find(key => ACTION_GROUPS[key].includes(name))
|
|
61
|
+
if (group === undefined) {
|
|
62
|
+
return {
|
|
63
|
+
allowed: false, envType: env, action: name, code: PERMISSION_DENIED,
|
|
64
|
+
message: `未知动作:${name}(fail-closed)`,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (rule[group] !== true) {
|
|
68
|
+
const label = group === 'installPlugins' ? '安装/修改插件' : '开启会话或任务'
|
|
69
|
+
const envLabel = { mirror: '母体版', essence: '原身版', derived: '分身版' }[env]
|
|
70
|
+
return {
|
|
71
|
+
allowed: false, envType: env, action: name, code: PERMISSION_DENIED,
|
|
72
|
+
message: `[权限拒绝] ${envLabel} 不支持${label}`,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { allowed: true, envType: env, action: name }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 权限拒绝错误(Adapter/Guard 抛出用)。 */
|
|
79
|
+
export class PermissionDeniedError extends Error {
|
|
80
|
+
constructor(decision) {
|
|
81
|
+
super(decision?.message ?? 'permission denied')
|
|
82
|
+
this.name = 'PermissionDeniedError'
|
|
83
|
+
this.code = PERMISSION_DENIED
|
|
84
|
+
this.envType = decision?.envType ?? 'unknown'
|
|
85
|
+
this.action = decision?.action ?? ''
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 创建一个绑定环境的守卫:返回 decision;`assert` 形式抛错。
|
|
91
|
+
* @param {unknown} envType
|
|
92
|
+
*/
|
|
93
|
+
export function createEnvironmentGuard(envType) {
|
|
94
|
+
const env = resolveEnvType(envType)
|
|
95
|
+
const decide = (action) => checkPermission(env, action)
|
|
96
|
+
return {
|
|
97
|
+
envType: env,
|
|
98
|
+
can: (action) => decide(action).allowed,
|
|
99
|
+
check: decide,
|
|
100
|
+
assert(action) {
|
|
101
|
+
const decision = decide(action)
|
|
102
|
+
if (!decision.allowed) throw new PermissionDeniedError(decision)
|
|
103
|
+
return decision
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 供 UI 展示的能力摘要。 */
|
|
109
|
+
export function describePermissions(envType) {
|
|
110
|
+
const env = resolveEnvType(envType)
|
|
111
|
+
const rule = PERMISSION_RULES[env]
|
|
112
|
+
return rule === undefined
|
|
113
|
+
? { envType: env, installPlugins: false, openSessions: false, known: false }
|
|
114
|
+
: { envType: env, installPlugins: rule.installPlugins, openSessions: rule.openSessions, known: true }
|
|
115
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// dsh-Fang lib — pnpm settings for the essence runtime profile.
|
|
4
|
+
//
|
|
5
|
+
// DSH 0.2.2-rc.1 bundles pnpm 11, and pnpm 11 no longer reads non-auth settings
|
|
6
|
+
// (`store-dir`, `package-import-method`, …) from a project `.npmrc`: only auth and
|
|
7
|
+
// network keys are read from that file, the rest moved to `pnpm-workspace.yaml`
|
|
8
|
+
// (camelCase). Fang used to write only `.npmrc`, so every pnpm operation inside an
|
|
9
|
+
// essence runtime picked the per-drive default store (`D:\.pnpm-store\v11` on a D:
|
|
10
|
+
// project) while the module tree recorded the mother's C: store, and pnpm failed with
|
|
11
|
+
// [ERR_PNPM_UNEXPECTED_STORE] Unexpected store location ...
|
|
12
|
+
// Before removing/updating any plugin (v3.1.8 report).
|
|
13
|
+
//
|
|
14
|
+
// This module writes the settings in the form pnpm 11 actually reads, keeps the legacy
|
|
15
|
+
// `.npmrc` for older pnpm/npm, and aligns the modules manifest so pnpm's compatibility
|
|
16
|
+
// check accepts the shared module tree: the store must match and the virtual store must
|
|
17
|
+
// be this project's own `node_modules/.pnpm` (the copied manifest still named the
|
|
18
|
+
// mother's virtual store).
|
|
19
|
+
import fs from 'node:fs'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
|
|
22
|
+
/** The project settings file pnpm 11 reads (non-auth settings). */
|
|
23
|
+
export const PNPM_WORKSPACE_FILE = 'pnpm-workspace.yaml'
|
|
24
|
+
/** Legacy npm/pnpm config file; still written for older toolchains. */
|
|
25
|
+
export const NPMRC_FILE = '.npmrc'
|
|
26
|
+
/** The modules manifest pnpm writes into `node_modules`. */
|
|
27
|
+
export const MODULES_MANIFEST_FILE = '.modules.yaml'
|
|
28
|
+
|
|
29
|
+
function readText(file, fallback = '') {
|
|
30
|
+
try { return fs.readFileSync(file, 'utf8') } catch { return fallback }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readJson(file, fallback = null) {
|
|
34
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return fallback }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeText(file, text) {
|
|
38
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
39
|
+
fs.writeFileSync(file, text, 'utf8')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read the store + virtual store recorded for a module tree.
|
|
44
|
+
* @param {string} modulesDir `node_modules` directory (its `.modules.yaml` is read).
|
|
45
|
+
* @returns {{storeDir:string, virtualStoreDir:string|null, layoutVersion:number|null, file:string}|null}
|
|
46
|
+
*/
|
|
47
|
+
export function readModulesManifest(modulesDir) {
|
|
48
|
+
const file = path.join(String(modulesDir ?? ''), MODULES_MANIFEST_FILE)
|
|
49
|
+
const manifest = readJson(file, null)
|
|
50
|
+
if (!manifest || typeof manifest !== 'object') return null
|
|
51
|
+
return {
|
|
52
|
+
storeDir: typeof manifest.storeDir === 'string' ? manifest.storeDir : '',
|
|
53
|
+
virtualStoreDir: typeof manifest.virtualStoreDir === 'string' ? manifest.virtualStoreDir : null,
|
|
54
|
+
layoutVersion: Number.isFinite(manifest.layoutVersion) ? manifest.layoutVersion : null,
|
|
55
|
+
file,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The linker an existing module tree was laid out with, from the profile that owns it.
|
|
61
|
+
* DSH's desktop profile declares `nodeLinker: hoisted`; a runtime profile cloned from it
|
|
62
|
+
* must keep the same linker or pnpm would want to rebuild the modules directory.
|
|
63
|
+
* @param {string} profileDir Profile directory whose pnpm-workspace.yaml is read.
|
|
64
|
+
* @param {string} [fallback='hoisted'] Value used when the file has no nodeLinker.
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
export function readNodeLinker(profileDir, fallback = 'hoisted') {
|
|
68
|
+
const text = readText(path.join(String(profileDir ?? ''), PNPM_WORKSPACE_FILE))
|
|
69
|
+
const match = /^nodeLinker:\s*([A-Za-z-]+)\s*$/m.exec(text)
|
|
70
|
+
return match ? match[1] : fallback
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Upsert top-level keys in a pnpm settings YAML file, preserving every other line
|
|
75
|
+
* (settings, comments, order). The values written here are machine paths and simple
|
|
76
|
+
* scalars, so a line-oriented upsert is enough and keeps the file readable.
|
|
77
|
+
* @param {string} file pnpm-workspace.yaml path.
|
|
78
|
+
* @param {Record<string,string>} settings Keys to write (camelCase).
|
|
79
|
+
* @returns {string[]} Keys written.
|
|
80
|
+
*/
|
|
81
|
+
export function upsertPnpmWorkspaceSettings(file, settings = {}) {
|
|
82
|
+
const entries = Object.entries(settings).filter(([, value]) => typeof value === 'string' && value !== '')
|
|
83
|
+
if (entries.length === 0) return []
|
|
84
|
+
const keys = new Set(entries.map(([key]) => key))
|
|
85
|
+
const original = readText(file)
|
|
86
|
+
const kept = original.split(/\r?\n/).filter((line) => {
|
|
87
|
+
const match = /^([A-Za-z][A-Za-z0-9_]*):/.exec(line)
|
|
88
|
+
return !(match && keys.has(match[1]))
|
|
89
|
+
})
|
|
90
|
+
const trimmed = kept.join('\n').replace(/[ \t]+$/gm, '').replace(/^\n+|\n+$/g, '')
|
|
91
|
+
const appended = entries.map(([key, value]) => `${key}: ${value}`).join('\n')
|
|
92
|
+
writeText(file, `${trimmed ? `${trimmed}\n` : ''}${appended}\n`)
|
|
93
|
+
return entries.map(([key]) => key)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Upsert legacy `.npmrc` keys. pnpm 11 ignores these, but npm and older pnpm honor
|
|
98
|
+
* them and the runtime profile may outlive a DSH downgrade.
|
|
99
|
+
* @param {string} profileDir Profile directory.
|
|
100
|
+
* @param {{storeDir:string, packageImportMethod:string}} options
|
|
101
|
+
* @returns {string[]}
|
|
102
|
+
*/
|
|
103
|
+
export function upsertNpmrc(profileDir, { storeDir, packageImportMethod = 'copy' } = {}) {
|
|
104
|
+
const file = path.join(String(profileDir ?? ''), NPMRC_FILE)
|
|
105
|
+
const pairs = [
|
|
106
|
+
['store-dir', storeDir],
|
|
107
|
+
['package-import-method', packageImportMethod],
|
|
108
|
+
].filter(([, value]) => typeof value === 'string' && value !== '')
|
|
109
|
+
if (pairs.length === 0) return []
|
|
110
|
+
const written = []
|
|
111
|
+
let text = readText(file)
|
|
112
|
+
for (const [key, value] of pairs) {
|
|
113
|
+
const re = new RegExp(`^${key}=.*$`, 'm')
|
|
114
|
+
if (re.test(text)) text = text.replace(re, `${key}=${value}`)
|
|
115
|
+
else text = `${text && !text.endsWith('\n') ? `${text}\n` : text}${key}=${value}\n`
|
|
116
|
+
written.push(key)
|
|
117
|
+
}
|
|
118
|
+
writeText(file, text.replace(/^\n+/, ''))
|
|
119
|
+
return written
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Align a modules manifest with the store and virtual store pnpm will compute for this
|
|
124
|
+
* project. pnpm's `checkCompatibility` refuses a modules directory whose recorded store
|
|
125
|
+
* differs from the chosen store, or whose recorded `virtualStoreDir` differs from the
|
|
126
|
+
* project's own — the runtime tree was copied from the mother, so it names the mother's
|
|
127
|
+
* virtual store. Both become this project's values; the rest of the manifest is kept.
|
|
128
|
+
* @param {string} modulesDir `node_modules` directory.
|
|
129
|
+
* @param {{storeDir:string, virtualStoreDir:string}} options
|
|
130
|
+
* @returns {{changed:boolean, storeDir?:string, virtualStoreDir?:string, error?:string}}
|
|
131
|
+
*/
|
|
132
|
+
export function patchModulesManifest(modulesDir, { storeDir, virtualStoreDir } = {}) {
|
|
133
|
+
const manifest = readModulesManifest(modulesDir)
|
|
134
|
+
if (!manifest) return { changed: false, error: 'modules-manifest-missing' }
|
|
135
|
+
if (!storeDir || !virtualStoreDir) return { changed: false, error: 'store-or-virtual-store-missing' }
|
|
136
|
+
const raw = readJson(manifest.file, null)
|
|
137
|
+
if (!raw) return { changed: false, error: 'modules-manifest-unreadable' }
|
|
138
|
+
const sameStore = raw.storeDir === storeDir
|
|
139
|
+
const sameVirtual = raw.virtualStoreDir === virtualStoreDir
|
|
140
|
+
if (sameStore && sameVirtual) return { changed: false, storeDir, virtualStoreDir }
|
|
141
|
+
raw.storeDir = storeDir
|
|
142
|
+
raw.virtualStoreDir = virtualStoreDir
|
|
143
|
+
writeText(manifest.file, `${JSON.stringify(raw, null, 2)}\n`)
|
|
144
|
+
return { changed: true, storeDir, virtualStoreDir }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Make an essence runtime profile's pnpm operations work off the shared module tree.
|
|
149
|
+
*
|
|
150
|
+
* Writes the settings pnpm 11 reads (`pnpm-workspace.yaml`), refreshes the legacy
|
|
151
|
+
* `.npmrc`, and aligns `node_modules/.modules.yaml` with the store and this project's
|
|
152
|
+
* virtual store. The store location is taken from the module tree's own manifest, so the
|
|
153
|
+
* chosen store always matches what the installed packages were linked from.
|
|
154
|
+
* @param {{profileDir:string, modulesDir:string, sourceModulesDir?:string, nodeLinker?:string}} options
|
|
155
|
+
* @returns {{ok:boolean, storeDir?:string, virtualStoreDir?:string, nodeLinker?:string, settings?:string[], npmrc?:string[], manifest?:object, error?:string}}
|
|
156
|
+
*/
|
|
157
|
+
export function ensureEssenceRuntimePnpmConfig({ profileDir, modulesDir, sourceModulesDir = '', nodeLinker = 'hoisted' } = {}) {
|
|
158
|
+
const profile = String(profileDir ?? '')
|
|
159
|
+
const modules = String(modulesDir ?? '')
|
|
160
|
+
if (!profile || !modules) return { ok: false, error: 'profile-or-modules-missing' }
|
|
161
|
+
const source = String(sourceModulesDir ?? '') || modules
|
|
162
|
+
const manifest = readModulesManifest(source) ?? readModulesManifest(modules)
|
|
163
|
+
if (!manifest) return { ok: false, error: 'modules-manifest-missing' }
|
|
164
|
+
const storeDir = manifest.storeDir
|
|
165
|
+
if (!storeDir) return { ok: false, error: 'store-dir-unknown' }
|
|
166
|
+
const virtualStoreDir = path.join(modules, '.pnpm')
|
|
167
|
+
const settings = upsertPnpmWorkspaceSettings(path.join(profile, PNPM_WORKSPACE_FILE), {
|
|
168
|
+
nodeLinker,
|
|
169
|
+
storeDir,
|
|
170
|
+
packageImportMethod: 'copy',
|
|
171
|
+
// v3.1.11:元宝运行态是“用户显式更新插件”的交互场景,pnpm 11 默认的
|
|
172
|
+
// minimumReleaseAge(供应链观察期)会把刚发布的自家插件判为过新:
|
|
173
|
+
// ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION(3.1.10 / queue-journal 0.1.4 实测)。
|
|
174
|
+
// 母体的供应链策略不在这里改动;只在运行态关闭观察期,保证插件更新/卸载可用。
|
|
175
|
+
minimumReleaseAge: '0',
|
|
176
|
+
})
|
|
177
|
+
const npmrc = upsertNpmrc(profile, { storeDir, packageImportMethod: 'copy' })
|
|
178
|
+
const patched = patchModulesManifest(modules, { storeDir, virtualStoreDir })
|
|
179
|
+
return {
|
|
180
|
+
ok: patched.error === undefined,
|
|
181
|
+
storeDir,
|
|
182
|
+
virtualStoreDir,
|
|
183
|
+
nodeLinker,
|
|
184
|
+
settings,
|
|
185
|
+
npmrc,
|
|
186
|
+
manifest: patched,
|
|
187
|
+
...(patched.error === undefined ? {} : { error: patched.error }),
|
|
188
|
+
}
|
|
189
|
+
}
|