@dsh-plus/secret-env 0.1.1 → 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.
@@ -30,23 +30,22 @@ 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-formGrid{display:grid;grid-template-columns:1fr 1fr;gap:0 16px}
34
- /* 会话注入控件(composer 工具行胶囊,对齐官方 chip 语言) */
35
- .dse-wrap{position:relative;align-items:center;display:inline-flex}
36
- .dse-chip{background:var(--dsw-alias-bg-module-platform);min-width:34px;color:var(--dsw-alias-label-secondary);cursor:pointer;border:none;border-radius:999px;align-items:center;gap:4px;padding:2px 8px;font-size:13px;font-weight:500;line-height:20px;display:inline-flex}
37
- .dse-chip:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
38
- .dse-chip:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
39
- .dse-chip:disabled{opacity:.6;cursor:default}
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;max-height:220px;overflow:auto;margin:0 -4px;padding:0 4px}
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}
48
+ .dse-formGrid{display:grid;grid-template-columns:1fr 1fr;gap:0 16px}
50
49
  /* $ 触发补全菜单(仿官方 input-trigger MenuView 的浮层语言) */
51
50
  .dse-menuWrap{position:relative}
52
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}
@@ -67,11 +66,9 @@ const EXTRA = `
67
66
  .dse-row{flex-wrap:wrap;padding:12px 12px}
68
67
  .dse-rowMain{flex-basis:100%}
69
68
  .dse-badges{margin-left:auto}
70
- .dse-pop{position:fixed;left:8px;right:8px;bottom:8px;top:auto;width:auto;max-width:none;max-height:70vh;overflow:auto}
71
- .dse-chip{min-height:32px}
69
+ .dse-panelCard{position:fixed;left:8px;right:8px;bottom:8px;top:auto;max-height:70vh}
72
70
  }
73
71
  @media (pointer:coarse){
74
- .dse-chip{min-height:32px}
75
72
  .dse-iconBtn{min-width:44px;min-height:44px;align-items:center;justify-content:center}
76
73
  }
77
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
- * 挂载即得:密钥以 $DSH_SECRET_* 变量名向 agent 暴露(dsh-shell-env 执行期
5
+ * 挂载即得:密钥以 $DSH_VAR_* 变量名向 agent 暴露(dsh-shell-env 执行期
6
6
  * 注入,与内建变量同一通道);值经 dsh-credentials seam 或会话内存持有,
7
7
  * 不进消息流、不动 prompt 前缀(缓存率零影响)。不新增工具与提示词——
8
8
  * 模型经 bash 原生能力(env | grep ^DSH_)发现变量名。
@@ -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
- * `DSH_SECRET_GITHUB_TOKEN`——即模型经 `env | grep ^DSH_` 可见、
5
- * 在 shell 命令中以 `$DSH_SECRET_*` 引用的那个名字。
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 = 'DSH_SECRET_'
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
- * - 注入臂:每个变量名一个 dsh-shell-env contributor,每次 shell 执行由
7
- * 注册表 collect 现取——写入/删除下一条命令即生效,不进消息流、不动前缀。
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
- /** 变量名contributor 注销器(shell-env 要求一键一主)。 */
89
- private readonly contributors = new Map<string, () => void>()
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
- /** 索引差量调和:新名建镜像,移出名撤镜像并回收其独占的 contributor。 */
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
- /** contributor 登记簿:有任一来源(全局镜像或任一会话桶)则注册,否则注销。 */
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
- const registered = this.contributors.has(envName)
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
- /** 注入解析:会话值优先,未命中回落全局镜像;once 命中即焚(注销推迟到微任务,避免 collect 迭代中改注册表)。 */
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 bucket = sessionId === undefined ? undefined : this.buckets.get(String(sessionId))
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<{ global: GlobalEntry[]; session: SessionEntry[] }> {
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 { global, session }
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
- for (const disposeContributor of this.contributors.values()) {
328
- disposeContributor()
329
- }
330
- this.contributors.clear()
343
+ this.book.dispose()
331
344
  }
332
345
  }