dsh-log-gate 0.0.0-stage → 0.1.0

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 CHANGED
@@ -1,3 +1,56 @@
1
- # Temporary Holding Version
1
+ # dsh-log-gate
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 第三方插件,不是 dsh 官方组件,也与 DeepSeek 无关。它只做一件事:按你的规则,让官方会话日志上传跳过某些会话。备份不是它的工作;文末提到的 UndoHaven 是另一个独立工具。
4
+
5
+ Third-party plugin. It is not an official dsh component and is not affiliated with DeepSeek. It asks the official session-log uploader to skip sessions you choose. Backup is a different tool.
6
+
7
+ ## 它实际调用的接口
8
+
9
+ dsh `0.2.2-rc.1` 的 `@deepseek-ai/dsh-session-log-deepseek` 公开了 `ctx.sessionLogDeepSeek.registerExclusion()`。判断函数是同步的,只能看到 `sessionId` 和可选的 `purpose`。返回 `true` 时,这次请求不带 `dsh_session_log`,也不会写下 `delivery-accepted`。
10
+
11
+ 本插件在判断时用 `ctx.sessions.get(sessionId)` 读取该实时会话的 `header.cwd`,再按项目名、目录或 glob 匹配。没有改 dsh,也没有拦 HTTP。
12
+
13
+ The installed package `@deepseek-ai/dsh-session-log-deepseek` exposes `registerExclusion()`. The predicate sees `sessionId` and an optional `purpose` only. This plugin reads `header.cwd` from the live session and matches project name, directory, or glob. It does not patch dsh and does not hook the network.
14
+
15
+ ## 配置
16
+
17
+ `mode: all`(默认)拦住每一个实时会话。`mode: listed` 只拦住 `rules` 命中的会话。
18
+
19
+ ```yaml
20
+ - id: log-gate
21
+ name: dsh-log-gate
22
+ config:
23
+ mode: listed
24
+ rules:
25
+ - secret-proj
26
+ - /home/me/work/private
27
+ - /home/me/clients/**
28
+ ```
29
+
30
+ - 没有斜杠、也没有 `* ? [` 的规则,按 cwd 的最后一段(项目名)匹配。
31
+ - 普通路径匹配该目录及其子目录。
32
+ - 带 glob 的规则用 Node 的 `path.matchesGlob`。
33
+
34
+ 设置页「日志闸门 / Log gate」和 `GET /api/log-gate/status`(仅本机)列出当前实时会话哪些会被拦住。
35
+
36
+ ## 做不到的事
37
+
38
+ - 判断函数拿不到路径。会话如果不在实时 store 里,`listed` 模式读不到 cwd,**不会**拦住它。`all` 模式只看 session id,仍然拦住。
39
+ - 匹配的是会话创建时的 cwd,不是 agent 后来走进去的每一个目录。
40
+ - 官方上传开关 `session-log-deepseek` 的 `enabled` 在排除规则之前生效。开关关掉时,排除规则不会被问到。本插件不替你改那个开关。
41
+ - 遥测(`session-telemetry-otel`,默认 `FEEDBACK_ONLY`)是另一个开关。这个插件不动它。
42
+ - 拦住不会删日志,也不会写 `delivery-accepted`。之后关掉闸门或删掉规则,下一次官方 API 请求可能把当时没传的后缀补传上去。这是官方实现里的行为,不是额外的补传通道。
43
+ - 不能保证桌面端一定渲染这个设置页。状态同时写在 dsh 日志里。
44
+
45
+ ## 体检
46
+
47
+ ```bash
48
+ dsh-log-gate audit
49
+ dsh-log-gate audit --json
50
+ ```
51
+
52
+ 报告列出官方会话日志开关、遥测相关的环境变量和补丁、本机目录大小,以及本地日志里越过最近一次 `delivery-accepted` 的后缀。最后有一句 UndoHaven 的说明和 https://undohaven.com 。
53
+
54
+ ## 验证
55
+
56
+ `npm test` 里的 `packages/log-gate/test/gate.test.js` 会加载本机安装的 `@deepseek-ai/dsh-session-log-deepseek`,注册本插件的排除规则,并确认命中的会话不会进入 `snapshotEvents`(官方 `prepare` 因此不会带上 `dsh_session_log`)。没有向官方 API 发请求。
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ import { auditHome, renderReport } from '../src/audit.js'
3
+
4
+ const args = process.argv.slice(2)
5
+ const command = args[0] === 'audit' || !args[0] || args[0] === 'help' ? (args[0] || 'audit') : args[0]
6
+ const flags = {}
7
+ for (let i = 0; i < args.length; i += 1) {
8
+ const arg = args[i]
9
+ if (arg === '--json') flags.json = true
10
+ else if (arg === '--dsh-home') flags.dshHome = args[++i]
11
+ else if (arg.startsWith('--dsh-home=')) flags.dshHome = arg.slice('--dsh-home='.length)
12
+ }
13
+
14
+ if (command === 'help' || args.includes('--help')) {
15
+ console.log('dsh-log-gate audit [--dsh-home PATH] [--json]\nThird-party local report. Not an official dsh command.')
16
+ process.exit(0)
17
+ }
18
+
19
+ if (command !== 'audit') {
20
+ console.error(`Unknown command ${command}. Try: dsh-log-gate audit`)
21
+ process.exit(1)
22
+ }
23
+
24
+ const report = await auditHome({ dshHome: flags.dshHome })
25
+ if (flags.json) console.log(JSON.stringify(report, null, 2))
26
+ else console.log(renderReport(report))
package/client.js ADDED
@@ -0,0 +1,42 @@
1
+ /* Third-party log gate settings tab. Not an official dsh panel. */
2
+ (function (global) {
3
+ var loader = global.__ModuleLoader__
4
+ if (!loader || typeof loader.load !== 'function') return
5
+ loader.load({
6
+ id: 'dsh-log-gate',
7
+ factory: function (require) {
8
+ var React = require('react')
9
+ function Tab() {
10
+ var state = React.useState(null)
11
+ var status = state[0]
12
+ var setStatus = state[1]
13
+ React.useEffect(function () {
14
+ fetch('/api/log-gate/status').then(function (res) { return res.json() }).then(setStatus).catch(function () { setStatus(null) })
15
+ }, [])
16
+ var rows = ((status && status.sessions) || []).map(function (row) {
17
+ return React.createElement('li', { key: row.id || row.cwd }, (row.blocked ? 'blocked / 已拦住 ' : 'eligible / 会上传 ') + (row.cwd || row.id || ''))
18
+ })
19
+ return React.createElement('section', null,
20
+ React.createElement('h2', null, '日志闸门'),
21
+ React.createElement('p', null, 'Third-party, not affiliated with DeepSeek. / 第三方插件,与 DeepSeek 无关。'),
22
+ React.createElement('p', null, status ? status.message : 'Loading / 正在读取'),
23
+ React.createElement('ul', null, rows.length ? rows : React.createElement('li', null, 'No live sessions / 没有实时会话'))
24
+ )
25
+ }
26
+ return {
27
+ name: 'dsh-log-gate',
28
+ inject: ['slots'],
29
+ apply: function (ctx) {
30
+ ctx.slots.inject('settings.plugins.tab', function () {
31
+ return ctx.slots.register({
32
+ name: 'settings.plugins.tab',
33
+ id: 'log-gate',
34
+ order: 70,
35
+ label: '日志闸门 / Log gate',
36
+ }, Tab)
37
+ })
38
+ },
39
+ }
40
+ },
41
+ })
42
+ })(typeof globalThis !== 'undefined' ? globalThis : this)
@@ -0,0 +1,6 @@
1
+ # Third-party session-log gate. Not part of the dsh distribution.
2
+ - id: log-gate
3
+ name: dsh-log-gate
4
+ config:
5
+ mode: all
6
+ rules: []
package/index.js ADDED
@@ -0,0 +1,111 @@
1
+ import Schema from '@deepseek-ai/schemastery'
2
+ import { decideBlock } from './src/match.js'
3
+
4
+ export const name = 'log-gate'
5
+ export const inject = ['sessionLogDeepSeek', 'sessions']
6
+
7
+ export const Config = Schema.object({
8
+ mode: Schema.union(['all', 'listed']).default('all').volatile(),
9
+ rules: Schema.array(Schema.string()).default([]).volatile(),
10
+ })
11
+
12
+ function read(value, fallback) {
13
+ if (value && typeof value === 'object' && typeof value.get === 'function') {
14
+ const got = value.get()
15
+ return got === undefined || got === null ? fallback : got
16
+ }
17
+ return value === undefined || value === null ? fallback : value
18
+ }
19
+
20
+ export function currentRules(config = {}) {
21
+ return {
22
+ mode: read(config.mode, 'all') === 'listed' ? 'listed' : 'all',
23
+ rules: [...read(config.rules, [])],
24
+ }
25
+ }
26
+
27
+ export function classifyLive(sessions, config) {
28
+ const rules = currentRules(config)
29
+ return [...sessions].map((session) => {
30
+ const cwd = session?.header?.cwd || null
31
+ const decision = decideBlock({ ...rules, cwd })
32
+ return {
33
+ id: session?.id || null,
34
+ cwd,
35
+ blocked: decision.blocked,
36
+ reason: decision.reason,
37
+ }
38
+ })
39
+ }
40
+
41
+ export function statusMessage(rows, config) {
42
+ const rules = currentRules(config)
43
+ const blocked = rows.filter((row) => row.blocked).length
44
+ const open = rows.length - blocked
45
+ if (rules.mode === 'all') {
46
+ return `dsh-log-gate: session-log upload is blocked for every live session (${blocked} blocked, ${open} still eligible). Telemetry is a separate switch. / 日志闸门:当前实时会话的官方会话日志上传都已拦住(拦住 ${blocked},仍会上传 ${open})。遥测是另一个开关。`
47
+ }
48
+ return `dsh-log-gate: listed rules, ${blocked} live session(s) blocked, ${open} still eligible. Unknown cwd is not blocked. / 日志闸门:按列出的规则,拦住 ${blocked} 个实时会话,${open} 个仍会上传。读不到 cwd 时不拦。`
49
+ }
50
+
51
+ function log(ctx, message) {
52
+ if (typeof ctx?.logger?.info === 'function') ctx.logger.info(message)
53
+ }
54
+
55
+ function send(res, status, body) {
56
+ const payload = Buffer.from(JSON.stringify(body))
57
+ res.writeHead(status, {
58
+ 'content-type': 'application/json; charset=utf-8',
59
+ 'cache-control': 'no-store',
60
+ 'content-length': payload.length,
61
+ })
62
+ res.end(payload)
63
+ }
64
+
65
+ export function mount(webServer, ctx, config) {
66
+ return webServer.register({
67
+ kind: 'prefix',
68
+ path: '/api/log-gate',
69
+ async handler(req, res) {
70
+ const address = req.socket?.remoteAddress || ''
71
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') {
72
+ send(res, 403, { error: 'loopback' })
73
+ return
74
+ }
75
+ const url = new URL(req.url || '/', 'http://127.0.0.1')
76
+ if (req.method === 'GET' && (url.pathname === '/api/log-gate/status' || url.pathname === '/status')) {
77
+ const live = typeof ctx.sessions?.list === 'function' ? ctx.sessions.list() : []
78
+ const rows = classifyLive(live, config)
79
+ send(res, 200, { ...currentRules(config), sessions: rows, message: statusMessage(rows, config) })
80
+ return
81
+ }
82
+ send(res, 404, { error: 'not_found' })
83
+ },
84
+ })
85
+ }
86
+
87
+ export function apply(ctx, config = {}) {
88
+ const exclude = (request) => {
89
+ const session = ctx.sessions?.get?.(request.sessionId)
90
+ return decideBlock({ ...currentRules(config), cwd: session?.header?.cwd }).blocked
91
+ }
92
+ const stop = ctx.sessionLogDeepSeek.registerExclusion(exclude)
93
+ const rows = classifyLive(typeof ctx.sessions?.list === 'function' ? ctx.sessions.list() : [], config)
94
+ log(ctx, statusMessage(rows, config))
95
+ let unmount = () => {}
96
+ const attach = (host) => {
97
+ if (host?.webServer?.register) unmount = mount(host.webServer, host, config) || (() => {})
98
+ }
99
+ if (ctx.webServer?.register) attach(ctx)
100
+ else if (typeof ctx.inject === 'function') {
101
+ try {
102
+ ctx.inject(['webServer'], (child) => { attach(child) })
103
+ } catch {
104
+ /* settings UI is optional */
105
+ }
106
+ }
107
+ return () => {
108
+ unmount()
109
+ return stop?.()
110
+ }
111
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "dsh-log-gate",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Unofficial third-party gate for dsh session-log upload. Not affiliated with DeepSeek.",
6
+ "engines": {
7
+ "node": ">=24.9.0",
8
+ "dsh": ">=0.2.1-rc.5 <0.3.0"
9
+ },
10
+ "bin": {
11
+ "dsh-log-gate": "./bin/dsh-log-gate.js"
12
+ },
13
+ "main": "./index.js",
14
+ "exports": {
15
+ ".": "./index.js",
16
+ "./client": "./client.js"
17
+ },
18
+ "dsh": {
19
+ "bundle": {
20
+ "patch": "./cordis.patch.yml"
21
+ },
22
+ "client": {
23
+ "platform": "web",
24
+ "inject": [
25
+ "@deepseek-ai/dsh-client-ui-settings"
26
+ ]
27
+ }
28
+ },
29
+ "dependencies": {
30
+ "@deepseek-ai/schemastery": "3.19.0"
31
+ },
32
+ "license": "MIT"
33
+ }
package/src/audit.js ADDED
@@ -0,0 +1,302 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises'
2
+ import { homedir } from 'node:os'
3
+ import { join, resolve } from 'node:path'
4
+ import { zstdDecompressSync } from 'node:zlib'
5
+ import { decideBlock } from './match.js'
6
+
7
+ const SESSION_LOG = /^session(?:\.v\d+)?\.jsonl(?:\.zstd)?$/
8
+ const READ_LIMIT = 32 * 1024 * 1024
9
+
10
+ export function resolveDshHome(env = process.env, flag) {
11
+ if (flag) return resolve(flag)
12
+ if (env.DSH_HOME) return resolve(env.DSH_HOME)
13
+ return join(homedir(), '.dsh')
14
+ }
15
+
16
+ /** Pull plugin config blocks out of a cordis patch list. Tolerates comments and nested config. */
17
+ export function readPatchPlugins(text) {
18
+ const plugins = []
19
+ let current = null
20
+ let inConfig = false
21
+ let inRules = false
22
+ for (const raw of String(text || '').split(/\r?\n/)) {
23
+ const line = raw.replace(/\t/g, ' ')
24
+ if (/^\s*#/.test(line) || !line.trim()) continue
25
+ const id = line.match(/^- id:\s*['"]?([A-Za-z0-9_-]+)['"]?\s*$/)
26
+ if (id) {
27
+ current = { id: id[1], config: {}, disabled: false }
28
+ plugins.push(current)
29
+ inConfig = false
30
+ inRules = false
31
+ continue
32
+ }
33
+ if (!current) continue
34
+ if (/^\s+disabled:\s*true\s*$/.test(line)) current.disabled = true
35
+ if (/^\s+config:\s*$/.test(line)) {
36
+ inConfig = true
37
+ inRules = false
38
+ continue
39
+ }
40
+ if (!inConfig) continue
41
+ if (/^\S/.test(line) || /^- /.test(line)) {
42
+ inConfig = false
43
+ inRules = false
44
+ continue
45
+ }
46
+ const enabled = line.match(/^\s+enabled:\s*(true|false)\s*$/)
47
+ if (enabled) current.config.enabled = enabled[1] === 'true'
48
+ const mode = line.match(/^\s+mode:\s*['"]?([A-Za-z0-9_-]+)['"]?\s*$/)
49
+ if (mode && !mode[1].startsWith('!!')) current.config.mode = mode[1]
50
+ if (/^\s+rules:\s*$/.test(line)) {
51
+ current.config.rules = []
52
+ inRules = true
53
+ continue
54
+ }
55
+ const rule = line.match(/^\s+-\s+['"]?(.+?)['"]?\s*$/)
56
+ if (inRules && rule) current.config.rules.push(rule[1])
57
+ else if (!/^\s+- /.test(line)) inRules = false
58
+ }
59
+ return plugins
60
+ }
61
+
62
+ function lastPlugin(plugins, id) {
63
+ return [...plugins].reverse().find((row) => row.id === id) || null
64
+ }
65
+
66
+ async function readMaybe(path) {
67
+ try {
68
+ return await readFile(path, 'utf8')
69
+ } catch (error) {
70
+ if (error.code === 'ENOENT') return null
71
+ throw error
72
+ }
73
+ }
74
+
75
+ async function dirSize(root, state = { bytes: 0, files: 0, capped: false }) {
76
+ let entries = []
77
+ try {
78
+ entries = await readdir(root, { withFileTypes: true })
79
+ } catch (error) {
80
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return state
81
+ throw error
82
+ }
83
+ for (const entry of entries) {
84
+ if (state.files > 20000) {
85
+ state.capped = true
86
+ return state
87
+ }
88
+ const path = join(root, entry.name)
89
+ if (entry.isSymbolicLink()) continue
90
+ if (entry.isDirectory()) await dirSize(path, state)
91
+ else if (entry.isFile()) {
92
+ const info = await stat(path)
93
+ state.bytes += info.size
94
+ state.files += 1
95
+ }
96
+ }
97
+ return state
98
+ }
99
+
100
+ async function walkLogs(sessionsRoot) {
101
+ const found = []
102
+ async function walk(dir) {
103
+ let entries = []
104
+ try {
105
+ entries = await readdir(dir, { withFileTypes: true })
106
+ } catch (error) {
107
+ if (error.code === 'ENOENT') return
108
+ throw error
109
+ }
110
+ for (const entry of entries) {
111
+ const path = join(dir, entry.name)
112
+ if (entry.isDirectory()) await walk(path)
113
+ else if (entry.isFile() && SESSION_LOG.test(entry.name)) found.push(path)
114
+ }
115
+ }
116
+ await walk(sessionsRoot)
117
+ return found
118
+ }
119
+
120
+ function parseLog(bytes) {
121
+ let text
122
+ if (bytes.length >= 4 && bytes[0] === 0x28 && bytes[1] === 0xb5 && bytes[2] === 0x2f && bytes[3] === 0xfd) {
123
+ text = zstdDecompressSync(bytes).toString('utf8')
124
+ } else {
125
+ text = bytes.toString('utf8')
126
+ }
127
+ let header = null
128
+ let maxSeq = -1
129
+ let accepted = -1
130
+ for (const line of text.split(/\r?\n/)) {
131
+ if (!line.trim()) continue
132
+ let row
133
+ try {
134
+ row = JSON.parse(line)
135
+ } catch {
136
+ continue
137
+ }
138
+ if (row.type === 'session' && header === null) header = row
139
+ if (typeof row.seq === 'number') maxSeq = Math.max(maxSeq, row.seq)
140
+ if (row.type === 'session-log-deepseek/delivery-accepted') {
141
+ const through = Number(row.data?.throughSeq)
142
+ if (Number.isFinite(through)) accepted = Math.max(accepted, through)
143
+ }
144
+ }
145
+ return { header, maxSeq, accepted, pending: maxSeq > accepted }
146
+ }
147
+
148
+ export async function inspectSessions(sessionsRoot) {
149
+ const files = await walkLogs(sessionsRoot)
150
+ const sessions = []
151
+ for (const file of files) {
152
+ const info = await stat(file)
153
+ if (info.size > READ_LIMIT) {
154
+ sessions.push({ file, unreadable: true, reason: 'too-large', bytes: info.size })
155
+ continue
156
+ }
157
+ try {
158
+ const parsed = parseLog(await readFile(file))
159
+ sessions.push({
160
+ file,
161
+ id: parsed.header?.id || null,
162
+ cwd: parsed.header?.cwd || null,
163
+ maxSeq: parsed.maxSeq,
164
+ acceptedThrough: parsed.accepted,
165
+ pending: parsed.pending,
166
+ bytes: info.size,
167
+ })
168
+ } catch {
169
+ sessions.push({ file, unreadable: true, reason: 'unreadable', bytes: info.size })
170
+ }
171
+ }
172
+ return sessions
173
+ }
174
+
175
+ function telemetryReport(env, plugins) {
176
+ const row = lastPlugin(plugins, 'session-telemetry-otel')
177
+ const disabledEnv = env.DSH_TELEMETRY_DISABLED
178
+ const envSet = disabledEnv != null && disabledEnv !== ''
179
+ const patchMode = row?.disabled ? 'disabled-row' : (row?.config?.mode || null)
180
+ return {
181
+ separateFromSessionLog: true,
182
+ defaultMode: 'FEEDBACK_ONLY',
183
+ patchMode,
184
+ envMode: env.DSH_TELEMETRY_MODE || null,
185
+ disabledEnv: envSet,
186
+ note: envSet
187
+ ? 'A non-empty DSH_TELEMETRY_DISABLED makes the launcher append a disabled row for session-telemetry-otel. Any value, including 0 or false, counts.'
188
+ : 'Session telemetry is a different switch from official session-log upload. The stock default is FEEDBACK_ONLY: a prefix leaves only after explicit feedback.',
189
+ }
190
+ }
191
+
192
+ function uploadSwitch(plugins) {
193
+ const row = lastPlugin(plugins, 'session-log-deepseek')
194
+ if (row?.disabled) return { enabled: false, source: 'row-disabled' }
195
+ if (typeof row?.config?.enabled === 'boolean') return { enabled: row.config.enabled, source: 'patch' }
196
+ return { enabled: true, source: 'default' }
197
+ }
198
+
199
+ function gateConfig(plugins) {
200
+ const row = lastPlugin(plugins, 'log-gate')
201
+ if (!row || row.disabled) return { installed: false, mode: null, rules: [] }
202
+ return {
203
+ installed: true,
204
+ mode: row.config.mode === 'listed' ? 'listed' : 'all',
205
+ rules: Array.isArray(row.config.rules) ? row.config.rules : [],
206
+ }
207
+ }
208
+
209
+ export async function auditHome(opts = {}) {
210
+ const env = opts.env || process.env
211
+ const dshHome = resolveDshHome(env, opts.dshHome)
212
+ const agentsHome = opts.agentsHome || (env.DSH_AGENTS_HOME ? resolve(env.DSH_AGENTS_HOME) : join(homedir(), '.agents'))
213
+ const profileRoot = join(dshHome, 'profiles')
214
+ let profileNames = []
215
+ try {
216
+ profileNames = (await readdir(profileRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name)
217
+ } catch {
218
+ profileNames = []
219
+ }
220
+ const layers = []
221
+ for (const name of profileNames) {
222
+ const text = await readMaybe(join(profileRoot, name, 'cordis.patch.yml'))
223
+ if (text != null) layers.push({ name: `profiles/${name}`, plugins: readPatchPlugins(text) })
224
+ }
225
+ const homeText = await readMaybe(join(dshHome, 'cordis.patch.yml'))
226
+ if (homeText != null) layers.push({ name: 'home', plugins: readPatchPlugins(homeText) })
227
+ const merged = layers.flatMap((layer) => layer.plugins)
228
+ const upload = uploadSwitch(merged)
229
+ const gate = gateConfig(merged)
230
+ const telemetry = telemetryReport(env, merged)
231
+ const sessionsRoot = join(dshHome, 'sessions')
232
+ const sessions = await inspectSessions(sessionsRoot)
233
+ const classified = sessions.map((session) => {
234
+ if (session.unreadable) return { ...session, blocked: null }
235
+ const decision = decideBlock({ mode: gate.installed ? gate.mode : 'off', rules: gate.rules, cwd: session.cwd })
236
+ const blocked = gate.installed ? decision.blocked : false
237
+ let catchUp = 'none'
238
+ if (session.pending && !upload.enabled) catchUp = 'held-by-official-switch'
239
+ else if (session.pending && blocked) catchUp = 'held-by-gate'
240
+ else if (session.pending) catchUp = 'would-upload'
241
+ return { ...session, blocked, reason: decision.reason, catchUp }
242
+ })
243
+ const [homeSize, sessionSize, agentsSize, anon] = await Promise.all([
244
+ dirSize(dshHome),
245
+ dirSize(sessionsRoot),
246
+ dirSize(agentsHome),
247
+ stat(join(dshHome, '.anonymous-user-id')).then((info) => ({ exists: true, bytes: info.size })).catch(() => ({ exists: false, bytes: 0 })),
248
+ ])
249
+ return {
250
+ dshHome,
251
+ agentsHome,
252
+ layers: layers.map((layer) => layer.name),
253
+ upload,
254
+ telemetry,
255
+ gate,
256
+ sizes: {
257
+ dshHome: homeSize,
258
+ sessions: sessionSize,
259
+ agents: agentsSize,
260
+ anonymousUserId: anon,
261
+ },
262
+ sessions: classified,
263
+ }
264
+ }
265
+
266
+ export function renderReport(report) {
267
+ const lines = []
268
+ lines.push('dsh-log-gate audit / 本机数据去向体检')
269
+ lines.push(`DSH_HOME ${report.dshHome}`)
270
+ lines.push(`agents ${report.agentsHome}`)
271
+ lines.push('')
272
+ lines.push(`Official session-log upload: ${report.upload.enabled ? 'on' : 'off'} (${report.upload.source})`)
273
+ lines.push(`官方会话日志上传:${report.upload.enabled ? '开' : '关'}(${report.upload.source})`)
274
+ lines.push(`Telemetry mode on disk: ${report.telemetry.patchMode || 'not set'}; env DSH_TELEMETRY_MODE=${report.telemetry.envMode || 'unset'}; DSH_TELEMETRY_DISABLED=${report.telemetry.disabledEnv ? 'set' : 'unset'}`)
275
+ lines.push(report.telemetry.note)
276
+ lines.push('')
277
+ if (!report.gate.installed) {
278
+ lines.push('dsh-log-gate is not enabled in the profile or home patch, so it is not omitting uploads.')
279
+ lines.push('配置里没有启用 dsh-log-gate,因此它现在不会拦截上传。')
280
+ } else {
281
+ lines.push(`dsh-log-gate mode: ${report.gate.mode}; rules: ${report.gate.rules.length ? report.gate.rules.join(', ') : '(none)'}`)
282
+ }
283
+ lines.push('')
284
+ lines.push(`Size home ${report.sizes.dshHome.bytes} B, sessions ${report.sizes.sessions.bytes} B, agents ${report.sizes.agents.bytes} B`)
285
+ lines.push(`Anonymous id file: ${report.sizes.anonymousUserId.exists ? `present, ${report.sizes.anonymousUserId.bytes} B` : 'absent'}`)
286
+ lines.push('')
287
+ const pending = report.sessions.filter((session) => session.catchUp && session.catchUp !== 'none')
288
+ if (pending.length === 0) {
289
+ lines.push('No stored session showed an unsent suffix past the last delivery-accepted mark.')
290
+ lines.push('没有发现越过最近一次 delivery-accepted 的未发送后缀。')
291
+ } else {
292
+ lines.push('Unsent suffixes stay in the local log. A blocked request does not write delivery-accepted, so opening the gate later can upload that suffix on a later official API request.')
293
+ lines.push('未发送的后缀仍在本地日志里。被拦住的请求不会写下 delivery-accepted,所以以后打开闸门时,下一次官方 API 请求可能补传这段。')
294
+ for (const session of pending.slice(0, 20)) {
295
+ lines.push(`- ${session.id || session.file} cwd=${session.cwd || '?'} ${session.catchUp} seq ${session.acceptedThrough} -> ${session.maxSeq}`)
296
+ }
297
+ }
298
+ lines.push('')
299
+ lines.push('UndoHaven can keep an encrypted snapshot of this home on the machine, and only copy it off the machine if you ask. https://undohaven.com')
300
+ lines.push('如果想给这份家目录留一份默认只在本机的加密快照,可以用 UndoHaven。https://undohaven.com')
301
+ return lines.join('\n')
302
+ }
package/src/match.js ADDED
@@ -0,0 +1,44 @@
1
+ import { basename, matchesGlob, resolve, sep } from 'node:path'
2
+
3
+ /** True when a rule uses glob metacharacters. */
4
+ export function hasGlob(rule) {
5
+ return /[*?[\]]/.test(rule)
6
+ }
7
+
8
+ /**
9
+ * Match a session cwd against one rule.
10
+ * A rule with no slash and no glob is a project name (the cwd basename).
11
+ * A path matches that directory and anything inside it.
12
+ * A glob is tested against the cwd and against a `/**` suffix.
13
+ */
14
+ export function ruleMatches(cwd, rule) {
15
+ const raw = String(rule || '').trim()
16
+ if (!raw) return false
17
+ if (raw === '*' || raw === '**') return true
18
+ const glob = hasGlob(raw)
19
+ if (!raw.includes('/') && !raw.includes('\\') && !glob) return basename(cwd) === raw
20
+ const abs = resolve(cwd)
21
+ if (!glob) {
22
+ const target = resolve(raw)
23
+ const prefix = target.endsWith(sep) ? target : `${target}${sep}`
24
+ if (abs === target || abs.startsWith(prefix)) return true
25
+ }
26
+ if (matchesGlob(abs, raw)) return true
27
+ if (!raw.endsWith('/**') && matchesGlob(abs, `${raw.replace(/\/$/, '')}/**`)) return true
28
+ return false
29
+ }
30
+
31
+ /**
32
+ * Decide whether one session's log upload should be omitted.
33
+ * `all` blocks every session id, even when cwd is unknown.
34
+ * `listed` blocks only matching rules. An unknown cwd is not blocked:
35
+ * the official exclusion callback does not include a path.
36
+ */
37
+ export function decideBlock({ mode, rules, cwd }) {
38
+ if (mode !== 'listed') return { blocked: true, reason: 'all' }
39
+ if (!cwd) return { blocked: false, reason: 'cwd-unknown' }
40
+ for (const rule of rules || []) {
41
+ if (ruleMatches(cwd, rule)) return { blocked: true, reason: String(rule) }
42
+ }
43
+ return { blocked: false, reason: 'no-match' }
44
+ }
@@ -0,0 +1,119 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import test from 'node:test'
6
+ import { pathToFileURL } from 'node:url'
7
+ import { zstdCompressSync } from 'node:zlib'
8
+ import { auditHome, readPatchPlugins, renderReport } from '../src/audit.js'
9
+ import { decideBlock, ruleMatches } from '../src/match.js'
10
+ import { Config, apply, classifyLive, currentRules } from '../index.js'
11
+
12
+ const DSH = process.env.UNDOHAVEN_DSH_PREFIX || '/tmp/hhy-dsh'
13
+
14
+ test('rules match project names, directories, and globs', () => {
15
+ assert.equal(ruleMatches('/home/me/secret-proj', 'secret-proj'), true)
16
+ assert.equal(ruleMatches('/home/me/secret-proj', 'other'), false)
17
+ assert.equal(ruleMatches('/home/me/work/private/app', '/home/me/work/private'), true)
18
+ assert.equal(ruleMatches('/home/me/clients/acme', '/home/me/clients/**'), true)
19
+ assert.equal(decideBlock({ mode: 'all', rules: [], cwd: null }).blocked, true)
20
+ assert.equal(decideBlock({ mode: 'listed', rules: ['secret-proj'], cwd: null }).blocked, false)
21
+ assert.equal(decideBlock({ mode: 'listed', rules: ['secret-proj'], cwd: '/x/secret-proj' }).reason, 'secret-proj')
22
+ })
23
+
24
+ test('settings schema defaults to blocking every session', () => {
25
+ const value = Config['~standard'].validate({}).value
26
+ assert.equal(currentRules(value).mode, 'all')
27
+ assert.deepEqual(currentRules(value).rules, [])
28
+ })
29
+
30
+ test('patch reader sees the official switches and this gate', () => {
31
+ const plugins = readPatchPlugins(`
32
+ - id: session-log-deepseek
33
+ config:
34
+ enabled: false
35
+ - id: session-telemetry-otel
36
+ config:
37
+ mode: DISABLED
38
+ - id: log-gate
39
+ config:
40
+ mode: listed
41
+ rules:
42
+ - secret-proj
43
+ `)
44
+ assert.equal(plugins.find((row) => row.id === 'session-log-deepseek').config.enabled, false)
45
+ assert.equal(plugins.find((row) => row.id === 'session-telemetry-otel').config.mode, 'DISABLED')
46
+ assert.deepEqual(plugins.find((row) => row.id === 'log-gate').config.rules, ['secret-proj'])
47
+ })
48
+
49
+ test('audit report names switches, catch-up, and UndoHaven', async () => {
50
+ const root = await mkdtemp(join(tmpdir(), 'log-gate-'))
51
+ const dsh = join(root, 'dsh')
52
+ const sessionDir = join(dsh, 'sessions', 'proj', 's1')
53
+ await mkdir(sessionDir, { recursive: true })
54
+ await mkdir(join(dsh, 'profiles', 'default'), { recursive: true })
55
+ await writeFile(join(dsh, 'profiles', 'default', 'cordis.patch.yml'), `
56
+ - id: session-log-deepseek
57
+ config:
58
+ enabled: true
59
+ - id: log-gate
60
+ config:
61
+ mode: listed
62
+ rules:
63
+ - secret-proj
64
+ `)
65
+ const lines = [
66
+ JSON.stringify({ type: 'session', version: 4, id: 's1', cwd: '/work/secret-proj', createdAt: 1, isSeeded: false, delegationDepth: 0 }),
67
+ JSON.stringify({ type: 'user/message', seq: 0, time: 1, data: {} }),
68
+ JSON.stringify({ type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2, data: { sessionId: 's1', throughSeq: 0 } }),
69
+ JSON.stringify({ type: 'user/message', seq: 2, time: 3, data: {} }),
70
+ ].join('\n')
71
+ await writeFile(join(sessionDir, 'session.v4.jsonl.zstd'), zstdCompressSync(Buffer.from(lines)))
72
+ const report = await auditHome({ dshHome: dsh, agentsHome: join(root, 'agents'), env: {} })
73
+ assert.equal(report.upload.enabled, true)
74
+ assert.equal(report.gate.mode, 'listed')
75
+ assert.equal(report.sessions[0].catchUp, 'held-by-gate')
76
+ const text = renderReport(report)
77
+ assert.match(text, /https:\/\/undohaven\.com/)
78
+ assert.match(text, /delivery-accepted/)
79
+ assert.match(text, /FEEDBACK_ONLY|Telemetry/)
80
+ await rm(root, { recursive: true, force: true })
81
+ })
82
+
83
+ test('real session-log service omits a blocked session before reading events', async () => {
84
+ const moduleUrl = pathToFileURL(join(DSH, 'node_modules/@deepseek-ai/dsh-session-log-deepseek/lib/index.js'))
85
+ let SessionLogDeepSeekService
86
+ try {
87
+ ;({ SessionLogDeepSeekService } = await import(moduleUrl))
88
+ } catch (error) {
89
+ assert.fail(`real dsh session-log package is required at ${moduleUrl.pathname}: ${error.message}`)
90
+ }
91
+ const sessions = new Map()
92
+ let prepare
93
+ const ctx = {
94
+ reflect: { provide(name, self) { ctx[name] = self; return self } },
95
+ deepseekLlmApiExtensions: { register(_field, handler) { prepare = handler.prepare; return () => {} } },
96
+ sessions: {
97
+ get(id) { return sessions.get(id) },
98
+ list() { return [...sessions.values()] },
99
+ },
100
+ effect(fn) { return fn() },
101
+ logger: { warn() {}, info() {} },
102
+ }
103
+ new SessionLogDeepSeekService(ctx, { enabled: { get() { return true } }, maxBytes: 8 * 1024 * 1024 })
104
+ const reads = []
105
+ const session = (id, cwd) => ({
106
+ id,
107
+ header: { cwd, version: 4 },
108
+ snapshotEvents() { reads.push(id); return [] },
109
+ })
110
+ sessions.set('secret', session('secret', '/work/secret-proj'))
111
+ sessions.set('open', session('open', '/work/public'))
112
+ apply(ctx, { mode: 'listed', rules: ['secret-proj'] })
113
+ assert.equal(prepare({ sessionId: 'secret' }), undefined)
114
+ assert.equal(prepare({ sessionId: 'open' }), undefined)
115
+ assert.deepEqual(reads, ['open'])
116
+ const rows = classifyLive(ctx.sessions.list(), { mode: 'listed', rules: ['secret-proj'] })
117
+ assert.equal(rows.find((row) => row.id === 'secret').blocked, true)
118
+ assert.equal(rows.find((row) => row.id === 'open').blocked, false)
119
+ })