@dsh-plus/secret-env 0.1.0 → 0.1.2
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/lib/client.js +1222 -338
- package/lib/index.d.ts +40 -8
- package/lib/index.js +211 -57
- package/package.json +8 -4
- package/src/api.ts +24 -1
- package/src/client/api.ts +18 -0
- package/src/client/client.ts +59 -14
- package/src/client/command.ts +62 -0
- package/src/client/common.ts +14 -1
- package/src/client/i18n.ts +67 -33
- package/src/client/menu-core.ts +103 -0
- package/src/client/menu.tsx +301 -0
- package/src/client/panel-bus.ts +23 -0
- package/src/client/panel-host.tsx +63 -0
- package/src/client/panel.tsx +385 -0
- package/src/client/section.tsx +194 -38
- package/src/client/styles.ts +25 -14
- package/src/config.ts +4 -0
- package/src/contributors.ts +115 -0
- package/src/index.ts +1 -1
- package/src/inventory.ts +101 -0
- package/src/names.ts +3 -3
- package/src/service.ts +107 -94
- package/src/client/composer.tsx +0 -260
package/src/client/styles.ts
CHANGED
|
@@ -30,34 +30,45 @@ const EXTRA = `
|
|
|
30
30
|
.dse-delBtn:hover{color:var(--dsw-alias-label-error)}
|
|
31
31
|
.dse-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;padding:18px 14px;margin:0;background:var(--dsw-alias-bg-layer-3)}
|
|
32
32
|
.dse-form{border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-3);padding:4px 14px 12px;margin-top:10px}
|
|
33
|
-
.dse-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
.dse-
|
|
37
|
-
.dse-
|
|
38
|
-
.dse-
|
|
39
|
-
.dse-
|
|
40
|
-
.dse-count{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-bg-base);border-radius:999px;min-width:16px;text-align:center;font-size:11px;line-height:16px;padding:0 4px}
|
|
41
|
-
.dse-pop{position:absolute;bottom:calc(100% + 8px);right:0;z-index:60;width:320px;max-width:calc(100vw - 24px);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 8px 28px rgba(0,0,0,.18);padding:10px 12px 12px}
|
|
33
|
+
.dse-inputError{border-color:var(--dsw-alias-label-error)}
|
|
34
|
+
.dse-hintError{color:var(--dsw-alias-label-error)}
|
|
35
|
+
/* 会话变量面板(/var 命令唤起,overlay 槽浮层)与内嵌共用件 */
|
|
36
|
+
.dse-panelWrap{position:relative}
|
|
37
|
+
.dse-panelCard{position:absolute;bottom:calc(100% + 6px);left:0;right:0;z-index:60;max-height:min(60vh,560px);overflow:auto;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 8px 28px rgba(0,0,0,.18);padding:10px 12px 12px}
|
|
38
|
+
.dse-maskBtnOn{color:var(--dsw-alias-label-error)}
|
|
39
|
+
.dse-envNameDim{opacity:.55;text-decoration:line-through}
|
|
42
40
|
.dse-popHead{align-items:center;gap:8px;display:flex;padding:2px 0 8px}
|
|
43
41
|
.dse-popTitle{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;flex:1;margin:0}
|
|
44
42
|
.dse-popHint{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5;margin:0 0 8px}
|
|
45
|
-
.dse-popList{flex-direction:column;display:flex;
|
|
43
|
+
.dse-popList{flex-direction:column;display:flex;margin:0 -4px;padding:0 4px}
|
|
46
44
|
.dse-popList .dse-row{background:0 0;border-radius:8px;padding:8px 8px}
|
|
47
45
|
.dse-popList .dse-row+.dse-row{border-top:1px solid var(--dsw-alias-border-l2)}
|
|
48
46
|
.dse-popForm{border-top:1px solid var(--dsw-alias-border-l2);margin-top:8px;padding-top:4px}
|
|
49
47
|
.dse-foot{justify-content:flex-end;gap:8px;display:flex;padding-top:8px}
|
|
50
|
-
|
|
48
|
+
.dse-formGrid{display:grid;grid-template-columns:1fr 1fr;gap:0 16px}
|
|
49
|
+
/* $ 触发补全菜单(仿官方 input-trigger MenuView 的浮层语言) */
|
|
50
|
+
.dse-menuWrap{position:relative}
|
|
51
|
+
.dse-menu{position:absolute;bottom:calc(100% + 6px);left:8px;z-index:60;min-width:280px;max-width:min(420px,calc(100vw - 32px));max-height:320px;overflow:auto;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 8px 28px rgba(0,0,0,.18);padding:4px}
|
|
52
|
+
.dse-menuTitle{color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:600;padding:6px 10px 4px}
|
|
53
|
+
.dse-menuItem{appearance:none;border:none;background:0 0;cursor:pointer;align-items:center;gap:8px;width:100%;padding:7px 10px;border-radius:8px;display:flex;text-align:left}
|
|
54
|
+
.dse-menuItem:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
55
|
+
.dse-menuItemActive{background:var(--dsw-alias-interactive-bg-hover)}
|
|
56
|
+
.dse-menuName{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-primary);font-size:13px;flex:none}
|
|
57
|
+
.dse-menuDesc{color:var(--dsw-alias-label-tertiary);font-size:12px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
58
|
+
.dse-menuBadges{align-items:center;gap:4px;flex:none;display:flex}
|
|
59
|
+
@media (max-width:767px){
|
|
60
|
+
.dse-menu{left:0;right:0;min-width:0;max-width:none;max-height:45vh}
|
|
61
|
+
.dse-menuItem{min-height:44px}
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
@media (max-width:767px){
|
|
52
65
|
.dse-formGrid{grid-template-columns:1fr}
|
|
53
66
|
.dse-row{flex-wrap:wrap;padding:12px 12px}
|
|
54
67
|
.dse-rowMain{flex-basis:100%}
|
|
55
68
|
.dse-badges{margin-left:auto}
|
|
56
|
-
.dse-
|
|
57
|
-
.dse-chip{min-height:32px}
|
|
69
|
+
.dse-panelCard{position:fixed;left:8px;right:8px;bottom:8px;top:auto;max-height:70vh}
|
|
58
70
|
}
|
|
59
71
|
@media (pointer:coarse){
|
|
60
|
-
.dse-chip{min-height:32px}
|
|
61
72
|
.dse-iconBtn{min-width:44px;min-height:44px;align-items:center;justify-content:center}
|
|
62
73
|
}
|
|
63
74
|
`
|
package/src/config.ts
CHANGED
|
@@ -22,6 +22,10 @@ const SecretMetaSchema: any = z.object({
|
|
|
22
22
|
|
|
23
23
|
export const Config = z.object({
|
|
24
24
|
secrets: z.array(SecretMetaSchema).description('全局密钥元数据索引(值存凭据库)').default([]),
|
|
25
|
+
masked: z
|
|
26
|
+
.array(z.string())
|
|
27
|
+
.description('全局屏蔽的变量名后缀(继承变量等;注入时跳过)')
|
|
28
|
+
.default([]),
|
|
25
29
|
})
|
|
26
30
|
|
|
27
31
|
export type SecretEnvConfig = Schemastery.TypeT<typeof Config>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shell-env contributor 登记簿(从 service 拆出,规约模块规模):
|
|
3
|
+
* 受管与继承两条线共用一张表,注册表要求一键一主——受管注册前
|
|
4
|
+
* 同名继承 contributor 先让位;继承只在「宿主环境有该名且未被索引」时存在。
|
|
5
|
+
* 所有屏蔽判定都下沉到 resolve 回调(由 service 注入),登记簿只管生命周期。
|
|
6
|
+
* @module secret-env/contributors
|
|
7
|
+
*/
|
|
8
|
+
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
|
9
|
+
|
|
10
|
+
import { SecretEnvError } from './errors.ts'
|
|
11
|
+
import { envNameOf } from './names.ts'
|
|
12
|
+
|
|
13
|
+
/** 受管键(结构等价 dsh-shell 的 DshEnvironmentKey,避免仅为类型多挂一个依赖)。 */
|
|
14
|
+
type ManagedKey = `DSH_${string}`
|
|
15
|
+
|
|
16
|
+
/** dsh-shell-env 注册面的结构子集。 */
|
|
17
|
+
export interface ShellEnvLike {
|
|
18
|
+
register(spec: {
|
|
19
|
+
name: string
|
|
20
|
+
variables: Readonly<Record<string, { description: string }>>
|
|
21
|
+
resolve(execution: ToolExecution): Readonly<Record<string, string>>
|
|
22
|
+
}): () => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ContributorBookDeps {
|
|
26
|
+
readonly shellEnv: ShellEnvLike
|
|
27
|
+
/** 受管变量描述(注册表拒绝空描述,调用方已做回落)。 */
|
|
28
|
+
describeManaged(suffix: string): string
|
|
29
|
+
/** 受管值解析:undefined = 本次执行不设置。 */
|
|
30
|
+
resolveManaged(suffix: string, execution: ToolExecution): string | undefined
|
|
31
|
+
/** 继承 contributor 是否应存在(宿主环境有该名且未被索引)。 */
|
|
32
|
+
shouldForwardInherited(suffix: string): boolean
|
|
33
|
+
/** 继承值解析(屏蔽名单在此生效):undefined/空串 = 不设置。 */
|
|
34
|
+
resolveInherited(suffix: string, execution: ToolExecution): string | undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class ContributorBook {
|
|
38
|
+
private readonly managed = new Map<string, () => void>()
|
|
39
|
+
private readonly inherited = new Map<string, () => void>()
|
|
40
|
+
private readonly deps: ContributorBookDeps
|
|
41
|
+
|
|
42
|
+
constructor(deps: ContributorBookDeps) {
|
|
43
|
+
this.deps = deps
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 受管登记:有任一来源值则注册,否则注销。 */
|
|
47
|
+
syncManaged(suffix: string, hasValue: boolean): void {
|
|
48
|
+
const envName = envNameOf(suffix)
|
|
49
|
+
const registered = this.managed.has(envName)
|
|
50
|
+
if (hasValue && !registered) {
|
|
51
|
+
this.registerManaged(suffix, envName)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (!hasValue && registered) {
|
|
55
|
+
this.managed.get(envName)?.()
|
|
56
|
+
this.managed.delete(envName)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 继承登记:按 shouldForwardInherited 现判注册/注销。 */
|
|
61
|
+
syncInherited(suffix: string): void {
|
|
62
|
+
const envName = envNameOf(suffix)
|
|
63
|
+
const registered = this.inherited.get(envName)
|
|
64
|
+
if (this.deps.shouldForwardInherited(suffix) && registered === undefined) {
|
|
65
|
+
const dispose = this.deps.shellEnv.register({
|
|
66
|
+
name: `secret-env-inherited:${suffix}`,
|
|
67
|
+
variables: { [envName as ManagedKey]: { description: 'inherited variable' } },
|
|
68
|
+
resolve: (execution: ToolExecution) => {
|
|
69
|
+
const value = this.deps.resolveInherited(suffix, execution)
|
|
70
|
+
return value === undefined || value === '' ? {} : { [envName as ManagedKey]: value }
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
this.inherited.set(envName, dispose)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
if (!this.deps.shouldForwardInherited(suffix) && registered !== undefined) {
|
|
77
|
+
registered()
|
|
78
|
+
this.inherited.delete(envName)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private registerManaged(suffix: string, envName: string): void {
|
|
83
|
+
// 同名继承 contributor 先让位(一键一主),受管接管该变量。
|
|
84
|
+
const inherited = this.inherited.get(envName)
|
|
85
|
+
if (inherited !== undefined) {
|
|
86
|
+
inherited()
|
|
87
|
+
this.inherited.delete(envName)
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const dispose = this.deps.shellEnv.register({
|
|
91
|
+
name: `secret-env:${suffix}`,
|
|
92
|
+
variables: { [envName as ManagedKey]: { description: this.deps.describeManaged(suffix) } },
|
|
93
|
+
resolve: (execution: ToolExecution) => {
|
|
94
|
+
const value = this.deps.resolveManaged(suffix, execution)
|
|
95
|
+
return value === undefined ? {} : { [envName as ManagedKey]: value }
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
this.managed.set(envName, dispose)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new SecretEnvError(
|
|
101
|
+
'conflict',
|
|
102
|
+
`variable ${envName} conflicts with an existing contributor: ${
|
|
103
|
+
error instanceof Error ? error.message : String(error)
|
|
104
|
+
}`,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
dispose(): void {
|
|
110
|
+
for (const dispose of this.managed.values()) dispose()
|
|
111
|
+
this.managed.clear()
|
|
112
|
+
for (const dispose of this.inherited.values()) dispose()
|
|
113
|
+
this.inherited.clear()
|
|
114
|
+
}
|
|
115
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* dsh-plus secret-env 插件(node 半)。浏览器半见 src/client/client.ts,
|
|
3
3
|
* 经 ./client 导出 + tsdown 构建,由 dsh-client-modules 扫描进 __DSH_BOOT__。
|
|
4
4
|
*
|
|
5
|
-
* 挂载即得:密钥以 $
|
|
5
|
+
* 挂载即得:密钥以 $DSH_VAR_* 变量名向 agent 暴露(dsh-shell-env 执行期
|
|
6
6
|
* 注入,与内建变量同一通道);值经 dsh-credentials seam 或会话内存持有,
|
|
7
7
|
* 不进消息流、不动 prompt 前缀(缓存率零影响)。不新增工具与提示词——
|
|
8
8
|
* 模型经 bash 原生能力(env | grep ^DSH_)发现变量名。
|
package/src/inventory.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 索引解析与端点线型(从 service 拆出,规约模块规模;纯函数+DTO,零依赖)。
|
|
3
|
+
* @module secret-env/inventory
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SecretMeta } from './config.ts'
|
|
7
|
+
import { envNameOf, suffixOf } from './names.ts'
|
|
8
|
+
|
|
9
|
+
/** 一条会话级密钥(内存态;once = 首次注入后自毁)。 */
|
|
10
|
+
export interface SessionSecret {
|
|
11
|
+
value: string
|
|
12
|
+
description: string
|
|
13
|
+
once: boolean
|
|
14
|
+
createdAt: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 列表端点的全局条目(describe 视图,绝无值)。 */
|
|
18
|
+
export interface GlobalEntry {
|
|
19
|
+
name: string
|
|
20
|
+
envName: string
|
|
21
|
+
description: string
|
|
22
|
+
configured: boolean
|
|
23
|
+
source?: string
|
|
24
|
+
writable: boolean
|
|
25
|
+
/** 会话视图:该变量在本会话被屏蔽(无 sessionId 的调用恒为 false)。 */
|
|
26
|
+
masked: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 列表端点的会话条目。 */
|
|
30
|
+
export interface SessionEntry {
|
|
31
|
+
name: string
|
|
32
|
+
envName: string
|
|
33
|
+
description: string
|
|
34
|
+
once: boolean
|
|
35
|
+
createdAt: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 列表端点的继承条目:宿主进程环境里已存在的 DSH_VAR_* 变量
|
|
40
|
+
* (非本插件索引管理;默认纳入注入)。masked = 生效屏蔽态,
|
|
41
|
+
* globallyMasked = 是否由全局屏蔽导致(会话视图里据此禁用会话级开关)。
|
|
42
|
+
*/
|
|
43
|
+
export interface InheritedEntry {
|
|
44
|
+
name: string
|
|
45
|
+
envName: string
|
|
46
|
+
masked: boolean
|
|
47
|
+
globallyMasked: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 从索引数据解析全局密钥元数据列表(宽容过滤非法项)。 */
|
|
51
|
+
export function asMeta(value: unknown): SecretMeta[] {
|
|
52
|
+
if (value === undefined || value === null || typeof value !== 'object') return []
|
|
53
|
+
const list = (value as { secrets?: unknown }).secrets
|
|
54
|
+
if (!Array.isArray(list)) return []
|
|
55
|
+
return list
|
|
56
|
+
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
|
|
57
|
+
.map((item) => ({
|
|
58
|
+
name: typeof item.name === 'string' ? item.name : '',
|
|
59
|
+
description: typeof item.description === 'string' ? item.description : '',
|
|
60
|
+
createdAt: typeof item.createdAt === 'string' ? item.createdAt : '',
|
|
61
|
+
}))
|
|
62
|
+
.filter((item) => item.name.length > 0)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 从同一索引数据解析全局屏蔽名单(与 secrets 共一个 settings 命名空间)。 */
|
|
66
|
+
export function asMasked(value: unknown): string[] {
|
|
67
|
+
if (value === undefined || value === null || typeof value !== 'object') return []
|
|
68
|
+
const list = (value as { masked?: unknown }).masked
|
|
69
|
+
if (!Array.isArray(list)) return []
|
|
70
|
+
return list.filter((item): item is string => typeof item === 'string' && item.length > 0)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 继承变量后缀:给定进程环境里的 DSH_VAR_* 且不在索引内(按名排序)。 */
|
|
74
|
+
export function inheritedSuffixesOf(
|
|
75
|
+
env: Record<string, string | undefined>,
|
|
76
|
+
indexed: ReadonlySet<string>,
|
|
77
|
+
): string[] {
|
|
78
|
+
const found: string[] = []
|
|
79
|
+
for (const key of Object.keys(env)) {
|
|
80
|
+
const suffix = suffixOf(key)
|
|
81
|
+
if (suffix !== undefined && !indexed.has(suffix)) found.push(suffix)
|
|
82
|
+
}
|
|
83
|
+
return found.sort()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 拼装继承条目列表(list 端点的 inherited 段)。 */
|
|
87
|
+
export function inheritedEntriesOf(
|
|
88
|
+
suffixes: readonly string[],
|
|
89
|
+
globalMasked: ReadonlySet<string>,
|
|
90
|
+
sessionMask: ReadonlySet<string> | undefined,
|
|
91
|
+
): InheritedEntry[] {
|
|
92
|
+
return suffixes.map((name) => {
|
|
93
|
+
const globally = globalMasked.has(name)
|
|
94
|
+
return {
|
|
95
|
+
name,
|
|
96
|
+
envName: envNameOf(name),
|
|
97
|
+
masked: globally || sessionMask?.has(name) === true,
|
|
98
|
+
globallyMasked: globally,
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
}
|
package/src/names.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 变量名语法(纯函数,零依赖;node 半与浏览器半共用):
|
|
3
3
|
* 用户给出后缀(如 `GITHUB_TOKEN`),插件拼出完整受管变量名
|
|
4
|
-
* `
|
|
5
|
-
* 在 shell 命令中以 `$
|
|
4
|
+
* `DSH_VAR_GITHUB_TOKEN`——即模型经 `env | grep ^DSH_` 可见、
|
|
5
|
+
* 在 shell 命令中以 `$DSH_VAR_*` 引用的那个名字。
|
|
6
6
|
* @module secret-env/names
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
/** 受管变量名前缀(属 dsh-shell-env 的 DSH_* 受管命名空间)。 */
|
|
10
|
-
export const ENV_PREFIX = '
|
|
10
|
+
export const ENV_PREFIX = 'DSH_VAR_'
|
|
11
11
|
|
|
12
12
|
/** 后缀语法:POSIX 环境变量名,大写字母开头,总长(含前缀)不超过 79。 */
|
|
13
13
|
const SUFFIX_PATTERN = /^[A-Z][A-Z0-9_]*$/
|
package/src/service.ts
CHANGED
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
* - 全局臂:值经 dsh-credentials seam 持久($DSH_HOME/.credentials.yaml),
|
|
4
4
|
* 服务内存中维护同步镜像(contributor.resolve 是同步签名,无法现查异步 seam);
|
|
5
5
|
* - 会话臂:Map<SessionId, Map<后缀, 值>> 纯内存,session/disposed 清除;
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
6
|
+
* - 屏蔽臂:全局屏蔽名单持久于 settings(继承变量主要开关),
|
|
7
|
+
* 会话级屏蔽纯内存;两级都在 contributor resolve 时现判;
|
|
8
|
+
* - 注入臂:每个变量名一个 dsh-shell-env contributor(ContributorBook),
|
|
9
|
+
* 每次 shell 执行由注册表 collect 现取——写入/删除/屏蔽下一条命令即生效,
|
|
10
|
+
* 不进消息流、不动前缀。继承变量(宿主环境 DSH_VAR_*)平台默认剥离,
|
|
11
|
+
* 本服务按「默认纳入」逐一转发,可被屏蔽名单拦截。
|
|
8
12
|
*
|
|
9
13
|
* 红线:值只流经「端点 → 本服务 → dshEnv」,任何日志/事件/返回值不带值。
|
|
10
14
|
* @module secret-env/service
|
|
@@ -18,40 +22,21 @@ import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
|
|
18
22
|
|
|
19
23
|
import { registerSecretEnvApi } from './api.ts'
|
|
20
24
|
import { Config, type SecretEnvConfig, type SecretMeta } from './config.ts'
|
|
25
|
+
import { ContributorBook } from './contributors.ts'
|
|
21
26
|
import { SecretEnvError } from './errors.ts'
|
|
27
|
+
import {
|
|
28
|
+
asMasked,
|
|
29
|
+
asMeta,
|
|
30
|
+
type GlobalEntry,
|
|
31
|
+
type InheritedEntry,
|
|
32
|
+
inheritedEntriesOf,
|
|
33
|
+
inheritedSuffixesOf,
|
|
34
|
+
type SessionEntry,
|
|
35
|
+
type SessionSecret,
|
|
36
|
+
} from './inventory.ts'
|
|
22
37
|
import { envNameOf, suffixOf } from './names.ts'
|
|
23
38
|
import { SETTINGS_NS } from './ns.ts'
|
|
24
39
|
|
|
25
|
-
/** 受管键(结构等价 dsh-shell 的 DshEnvironmentKey,避免仅为类型多挂一个依赖)。 */
|
|
26
|
-
type ManagedKey = `DSH_${string}`
|
|
27
|
-
|
|
28
|
-
/** 一条会话级密钥(内存态;once = 首次注入后自毁)。 */
|
|
29
|
-
export interface SessionSecret {
|
|
30
|
-
value: string
|
|
31
|
-
description: string
|
|
32
|
-
once: boolean
|
|
33
|
-
createdAt: string
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** 列表端点的全局条目(describe 视图,绝无值)。 */
|
|
37
|
-
export interface GlobalEntry {
|
|
38
|
-
name: string
|
|
39
|
-
envName: string
|
|
40
|
-
description: string
|
|
41
|
-
configured: boolean
|
|
42
|
-
source?: string
|
|
43
|
-
writable: boolean
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** 列表端点的会话条目。 */
|
|
47
|
-
export interface SessionEntry {
|
|
48
|
-
name: string
|
|
49
|
-
envName: string
|
|
50
|
-
description: string
|
|
51
|
-
once: boolean
|
|
52
|
-
createdAt: string
|
|
53
|
-
}
|
|
54
|
-
|
|
55
40
|
interface SettingsLike {
|
|
56
41
|
get(ns: string): unknown
|
|
57
42
|
update(ns: string, patch: Record<string, unknown>): Promise<void>
|
|
@@ -64,20 +49,6 @@ interface SettingsLike {
|
|
|
64
49
|
): void
|
|
65
50
|
}
|
|
66
51
|
|
|
67
|
-
function asMeta(value: unknown): SecretMeta[] {
|
|
68
|
-
if (value === undefined || value === null || typeof value !== 'object') return []
|
|
69
|
-
const list = (value as { secrets?: unknown }).secrets
|
|
70
|
-
if (!Array.isArray(list)) return []
|
|
71
|
-
return list
|
|
72
|
-
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
|
|
73
|
-
.map((item) => ({
|
|
74
|
-
name: typeof item.name === 'string' ? item.name : '',
|
|
75
|
-
description: typeof item.description === 'string' ? item.description : '',
|
|
76
|
-
createdAt: typeof item.createdAt === 'string' ? item.createdAt : '',
|
|
77
|
-
}))
|
|
78
|
-
.filter((item) => item.name.length > 0)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
52
|
export class SecretEnvService extends Service {
|
|
82
53
|
static [Context.inject] = ['credentials', 'shellEnv']
|
|
83
54
|
|
|
@@ -85,8 +56,10 @@ export class SecretEnvService extends Service {
|
|
|
85
56
|
private readonly globalMirror = new Map<string, string>()
|
|
86
57
|
/** 会话桶:sessionId → 后缀 → 条目。 */
|
|
87
58
|
private readonly buckets = new Map<string, Map<string, SessionSecret>>()
|
|
88
|
-
/**
|
|
89
|
-
private readonly
|
|
59
|
+
/** 会话级屏蔽名单:sessionId → 后缀集合(纯内存,随会话终结清除)。 */
|
|
60
|
+
private readonly sessionMasks = new Map<string, Set<string>>()
|
|
61
|
+
/** contributor 登记簿(受管/继承两条线,一键一主)。 */
|
|
62
|
+
private readonly book: ContributorBook
|
|
90
63
|
/** 行级 config(settings 缺席时的索引来源)。 */
|
|
91
64
|
private readonly base: SecretEnvConfig
|
|
92
65
|
/** installSection 给的实时 getter(scope.get 的活视图,读即最新,无时序竞态)。 */
|
|
@@ -99,7 +72,26 @@ export class SecretEnvService extends Service {
|
|
|
99
72
|
|
|
100
73
|
constructor(ctx: Context, config: SecretEnvConfig | undefined) {
|
|
101
74
|
super(ctx, 'secretEnv')
|
|
102
|
-
this.base = config ?? { secrets: [] }
|
|
75
|
+
this.base = config ?? { secrets: [], masked: [] }
|
|
76
|
+
this.book = new ContributorBook({
|
|
77
|
+
shellEnv: ctx.shellEnv,
|
|
78
|
+
describeManaged: (suffix) => {
|
|
79
|
+
// 注册表拒绝空描述("must describe");空白描述回落为通用文案。
|
|
80
|
+
const found = this.currentMeta().find((item) => item.name === suffix)?.description
|
|
81
|
+
return found !== undefined && found.trim() !== '' ? found : 'secret variable'
|
|
82
|
+
},
|
|
83
|
+
resolveManaged: (suffix, execution) => this.resolveFor(suffix, execution),
|
|
84
|
+
shouldForwardInherited: (suffix) =>
|
|
85
|
+
process.env[envNameOf(suffix)] !== undefined && !this.isIndexed(suffix),
|
|
86
|
+
resolveInherited: (suffix, execution) => {
|
|
87
|
+
if (this.currentMasked().has(suffix)) return undefined
|
|
88
|
+
const agentId = execution.agent?.id
|
|
89
|
+
if (agentId !== undefined && this.sessionMasks.get(String(agentId))?.has(suffix) === true) {
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
return process.env[envNameOf(suffix)]
|
|
93
|
+
},
|
|
94
|
+
})
|
|
103
95
|
// 官方 installSection 范式(同 usage-panel):setSource 收到的是
|
|
104
96
|
// () => scope.get() 活视图——索引读取永远走 currentMeta() 现取,
|
|
105
97
|
// onChange 仅触发差量调和,因此用户层加载早晚都不会被写覆盖。
|
|
@@ -117,6 +109,7 @@ export class SecretEnvService extends Service {
|
|
|
117
109
|
void this.reconcile()
|
|
118
110
|
})
|
|
119
111
|
ctx.root.on('session/disposed', (session: { id: string }) => {
|
|
112
|
+
this.sessionMasks.delete(session.id)
|
|
120
113
|
this.dropBucket(session.id)
|
|
121
114
|
})
|
|
122
115
|
ctx.on('credentials/reference-updated', (ref) => {
|
|
@@ -127,6 +120,10 @@ export class SecretEnvService extends Service {
|
|
|
127
120
|
ctx.inject(['webServer'], (webCtx) => {
|
|
128
121
|
registerSecretEnvApi(webCtx as Context, this)
|
|
129
122
|
})
|
|
123
|
+
// 继承变量启动登记(平台默认剥离宿主 DSH_*,本插件显式 opt-in 转发)。
|
|
124
|
+
for (const suffix of this.inheritedSuffixes()) {
|
|
125
|
+
this.book.syncInherited(suffix)
|
|
126
|
+
}
|
|
130
127
|
this.ready = this.reconcile()
|
|
131
128
|
}
|
|
132
129
|
|
|
@@ -135,12 +132,22 @@ export class SecretEnvService extends Service {
|
|
|
135
132
|
return asMeta(this.readIndex !== undefined ? this.readIndex() : this.base)
|
|
136
133
|
}
|
|
137
134
|
|
|
135
|
+
/** 当前全局屏蔽名单(与索引同源,活读取)。 */
|
|
136
|
+
private currentMasked(): Set<string> {
|
|
137
|
+
return new Set(asMasked(this.readIndex !== undefined ? this.readIndex() : this.base))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 继承变量后缀:宿主进程环境里已存在的 DSH_VAR_* 且不在本插件索引内。 */
|
|
141
|
+
private inheritedSuffixes(): string[] {
|
|
142
|
+
return inheritedSuffixesOf(process.env, new Set(this.currentMeta().map((item) => item.name)))
|
|
143
|
+
}
|
|
144
|
+
|
|
138
145
|
/** 索引成员判定(索引是全局名管理面的唯一事实源)。 */
|
|
139
146
|
private isIndexed(suffix: string): boolean {
|
|
140
147
|
return this.currentMeta().some((item) => item.name === suffix)
|
|
141
148
|
}
|
|
142
149
|
|
|
143
|
-
/**
|
|
150
|
+
/** 索引差量调和:新名建镜像,移出名撤镜像并回落继承登记(若宿主仍有同名)。 */
|
|
144
151
|
private async reconcile(): Promise<void> {
|
|
145
152
|
const next = this.currentMeta()
|
|
146
153
|
const removed = this.lastMeta.filter((item) => !next.some((n) => n.name === item.name))
|
|
@@ -149,6 +156,7 @@ export class SecretEnvService extends Service {
|
|
|
149
156
|
for (const item of removed) {
|
|
150
157
|
this.globalMirror.delete(item.name)
|
|
151
158
|
this.syncContributor(item.name)
|
|
159
|
+
this.book.syncInherited(item.name)
|
|
152
160
|
}
|
|
153
161
|
for (const item of added) {
|
|
154
162
|
await this.refreshMirror(item.name)
|
|
@@ -166,9 +174,8 @@ export class SecretEnvService extends Service {
|
|
|
166
174
|
this.syncContributor(suffix)
|
|
167
175
|
}
|
|
168
176
|
|
|
169
|
-
/**
|
|
177
|
+
/** 受管登记簿同步:有任一来源(全局镜像或任一会话桶)则注册,否则注销。 */
|
|
170
178
|
private syncContributor(suffix: string): void {
|
|
171
|
-
const envName = envNameOf(suffix)
|
|
172
179
|
const hasGlobal = this.globalMirror.has(suffix)
|
|
173
180
|
let hasSession = false
|
|
174
181
|
for (const bucket of this.buckets.values()) {
|
|
@@ -177,45 +184,18 @@ export class SecretEnvService extends Service {
|
|
|
177
184
|
break
|
|
178
185
|
}
|
|
179
186
|
}
|
|
180
|
-
|
|
181
|
-
if ((hasGlobal || hasSession) && !registered) {
|
|
182
|
-
this.registerContributor(suffix, envName)
|
|
183
|
-
return
|
|
184
|
-
}
|
|
185
|
-
if (!hasGlobal && !hasSession && registered) {
|
|
186
|
-
this.contributors.get(envName)?.()
|
|
187
|
-
this.contributors.delete(envName)
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
private registerContributor(suffix: string, envName: string): void {
|
|
192
|
-
// 注册表拒绝空描述("must describe");空白描述回落为通用文案。
|
|
193
|
-
const found = this.currentMeta().find((item) => item.name === suffix)?.description
|
|
194
|
-
const description = found !== undefined && found.trim() !== '' ? found : 'secret variable'
|
|
195
|
-
try {
|
|
196
|
-
const dispose = this.ctx.shellEnv.register({
|
|
197
|
-
name: `secret-env:${suffix}`,
|
|
198
|
-
variables: { [envName as ManagedKey]: { description } },
|
|
199
|
-
resolve: (execution: ToolExecution) => {
|
|
200
|
-
const value = this.resolveFor(suffix, execution)
|
|
201
|
-
return value === undefined ? {} : { [envName as ManagedKey]: value }
|
|
202
|
-
},
|
|
203
|
-
})
|
|
204
|
-
this.contributors.set(envName, dispose)
|
|
205
|
-
} catch (error) {
|
|
206
|
-
throw new SecretEnvError(
|
|
207
|
-
'conflict',
|
|
208
|
-
`variable ${envName} conflicts with an existing contributor: ${
|
|
209
|
-
error instanceof Error ? error.message : String(error)
|
|
210
|
-
}`,
|
|
211
|
-
)
|
|
212
|
-
}
|
|
187
|
+
this.book.syncManaged(suffix, hasGlobal || hasSession)
|
|
213
188
|
}
|
|
214
189
|
|
|
215
|
-
/**
|
|
190
|
+
/**
|
|
191
|
+
* 受管注入解析:会话屏蔽名单优先(任何来源都拦),会话值次之,全局屏蔽再次,
|
|
192
|
+
* 未命中回落全局镜像;once 命中即焚(注销推迟到微任务,避免 collect 迭代中改登记簿)。
|
|
193
|
+
*/
|
|
216
194
|
private resolveFor(suffix: string, execution: ToolExecution): string | undefined {
|
|
217
195
|
const sessionId = execution.agent?.id
|
|
218
|
-
const
|
|
196
|
+
const sid = sessionId === undefined ? undefined : String(sessionId)
|
|
197
|
+
if (sid !== undefined && this.sessionMasks.get(sid)?.has(suffix) === true) return undefined
|
|
198
|
+
const bucket = sid === undefined ? undefined : this.buckets.get(sid)
|
|
219
199
|
const entry = bucket?.get(suffix)
|
|
220
200
|
if (entry !== undefined) {
|
|
221
201
|
if (entry.once && bucket !== undefined) {
|
|
@@ -224,6 +204,7 @@ export class SecretEnvService extends Service {
|
|
|
224
204
|
}
|
|
225
205
|
return entry.value
|
|
226
206
|
}
|
|
207
|
+
if (this.currentMasked().has(suffix)) return undefined
|
|
227
208
|
return this.globalMirror.get(suffix)
|
|
228
209
|
}
|
|
229
210
|
|
|
@@ -243,7 +224,12 @@ export class SecretEnvService extends Service {
|
|
|
243
224
|
}
|
|
244
225
|
|
|
245
226
|
/** 列表(值永不出现;global 条目来自 credentials.describe 的安全视图)。 */
|
|
246
|
-
async list(sessionId?: string): Promise<{
|
|
227
|
+
async list(sessionId?: string): Promise<{
|
|
228
|
+
global: GlobalEntry[]
|
|
229
|
+
session: SessionEntry[]
|
|
230
|
+
inherited: InheritedEntry[]
|
|
231
|
+
}> {
|
|
232
|
+
const sessionMask = sessionId === undefined ? undefined : this.sessionMasks.get(sessionId)
|
|
247
233
|
const global = await Promise.all(
|
|
248
234
|
this.currentMeta().map(async (item) => {
|
|
249
235
|
const info: CredentialInfo = await this.ctx.credentials.describe(
|
|
@@ -256,6 +242,7 @@ export class SecretEnvService extends Service {
|
|
|
256
242
|
configured: info.configured,
|
|
257
243
|
source: info.source,
|
|
258
244
|
writable: info.writable,
|
|
245
|
+
masked: sessionMask?.has(item.name) === true,
|
|
259
246
|
}
|
|
260
247
|
}),
|
|
261
248
|
)
|
|
@@ -267,7 +254,35 @@ export class SecretEnvService extends Service {
|
|
|
267
254
|
once: entry.once,
|
|
268
255
|
createdAt: entry.createdAt,
|
|
269
256
|
}))
|
|
270
|
-
return {
|
|
257
|
+
return {
|
|
258
|
+
global,
|
|
259
|
+
session,
|
|
260
|
+
inherited: inheritedEntriesOf(this.inheritedSuffixes(), this.currentMasked(), sessionMask),
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 屏蔽/恢复一个变量:sessionId 缺席时为全局屏蔽(持久于 settings,
|
|
266
|
+
* 主要面向继承变量);否则为会话级屏蔽(纯内存,会话终结即清)。
|
|
267
|
+
* 两级屏蔽都在 contributor resolve 时现判,下一条命令即生效。
|
|
268
|
+
*/
|
|
269
|
+
async setMask(suffix: string, masked: boolean, sessionId?: string): Promise<void> {
|
|
270
|
+
if (sessionId !== undefined) {
|
|
271
|
+
if (sessionId.length === 0) throw new SecretEnvError('no-session', 'sessionId required')
|
|
272
|
+
const set = this.sessionMasks.get(sessionId) ?? new Set<string>()
|
|
273
|
+
if (masked) set.add(suffix)
|
|
274
|
+
else set.delete(suffix)
|
|
275
|
+
if (set.size === 0) this.sessionMasks.delete(sessionId)
|
|
276
|
+
else this.sessionMasks.set(sessionId, set)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
const next = asMasked(this.readIndex !== undefined ? this.readIndex() : this.base).filter(
|
|
280
|
+
(item) => item !== suffix,
|
|
281
|
+
)
|
|
282
|
+
if (masked) next.push(suffix)
|
|
283
|
+
if (this.settingsRef !== undefined) {
|
|
284
|
+
await this.settingsRef.update(SETTINGS_NS, { masked: next })
|
|
285
|
+
}
|
|
271
286
|
}
|
|
272
287
|
|
|
273
288
|
/** 写入全局值(seam 拒绝继承环境遮蔽与空值,此处先行给出结构化错误)。 */
|
|
@@ -289,7 +304,7 @@ export class SecretEnvService extends Service {
|
|
|
289
304
|
await this.refreshMirror(suffix)
|
|
290
305
|
}
|
|
291
306
|
|
|
292
|
-
/**
|
|
307
|
+
/** 删除全局值与元数据;宿主环境仍有同名变量时回落为继承变量继续纳入。 */
|
|
293
308
|
async unsetGlobal(suffix: string): Promise<void> {
|
|
294
309
|
await this.ctx.credentials.unset(credentialRef(envNameOf(suffix)))
|
|
295
310
|
const next = this.currentMeta().filter((item) => item.name !== suffix)
|
|
@@ -297,6 +312,7 @@ export class SecretEnvService extends Service {
|
|
|
297
312
|
this.lastMeta = next
|
|
298
313
|
this.globalMirror.delete(suffix)
|
|
299
314
|
this.syncContributor(suffix)
|
|
315
|
+
this.book.syncInherited(suffix)
|
|
300
316
|
}
|
|
301
317
|
|
|
302
318
|
/** 写入会话级值(纯内存;host 重启或会话终结即失)。 */
|
|
@@ -324,9 +340,6 @@ export class SecretEnvService extends Service {
|
|
|
324
340
|
}
|
|
325
341
|
|
|
326
342
|
dispose(): void {
|
|
327
|
-
|
|
328
|
-
disposeContributor()
|
|
329
|
-
}
|
|
330
|
-
this.contributors.clear()
|
|
343
|
+
this.book.dispose()
|
|
331
344
|
}
|
|
332
345
|
}
|