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 +55 -2
- package/bin/dsh-log-gate.js +26 -0
- package/client.js +42 -0
- package/cordis.patch.yml +6 -0
- package/index.js +111 -0
- package/package.json +31 -4
- package/src/audit.js +302 -0
- package/src/match.js +44 -0
- package/test/gate.test.js +119 -0
package/README.md
CHANGED
|
@@ -1,3 +1,56 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-log-gate
|
|
2
2
|
|
|
3
|
-
|
|
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)
|
package/cordis.patch.yml
ADDED
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.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|
+
})
|