dsh-managed-agent 0.0.0-stage → 0.2.1
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 +147 -2
- package/cordis.patch.yml +13 -0
- package/index.js +475 -0
- package/package.json +39 -4
package/README.md
CHANGED
|
@@ -1,3 +1,148 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-managed-agent —— 受管设备代理(M2)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> 员工机上唯一的受管插件。它把「这台机器是什么状态」报给集中管理平台,
|
|
4
|
+
> 并在本地提供一个**只读**状态页。
|
|
5
|
+
>
|
|
6
|
+
> **已发布**:<https://github.com/tableau-China/dsh-managed-agent>(当前 v0.2.1,MIT)。
|
|
7
|
+
> 本目录是**源码主本**(含集成测试与市场打包);GitHub 仓库是发布快照。
|
|
8
|
+
> 平台侧设计见 [集中管理平台方案设计](../../docs/集中管理平台方案设计.md) §7;
|
|
9
|
+
> 版本进度见 [实施进度](../../docs/集中管理平台-实施进度.md)。
|
|
10
|
+
|
|
11
|
+
## 它做什么(四件事,全是旁路)
|
|
12
|
+
|
|
13
|
+
| # | 行为 | 说明 |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| 1 | 读设备凭据 | `~/.dsh/managed-agent.json`(`node admin/enroll.mjs` 生成,0600) |
|
|
16
|
+
| 2 | 周期上报**事实** | DSH 版本、patch 指纹、插件清单、机器名 |
|
|
17
|
+
| 3 | 按平台返回处理状态 | `pending` 待审批 / `revoked` 已吊销 / `401` 凭据失效 / 网络不可达 |
|
|
18
|
+
| 4 | 本地只读状态页 | `/managed-agent/v1/`(挂在 DSH 自带 WebServer 上,受浏览器门禁保护) |
|
|
19
|
+
|
|
20
|
+
**绝不做的事**:不上报提示词、回复、文件内容、工具参数、工作区路径;不提供任何修改入口;
|
|
21
|
+
不在本机判定合规(判定权在服务端,见下文)。
|
|
22
|
+
|
|
23
|
+
## 安装(员工机)
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# 1) 把插件放进 profile(内网 npm 源或本地 tgz,M8 才做市场索引)
|
|
27
|
+
# 在 <DSH_HOME>/profiles/<profile>/package.json 的 dependencies 加:
|
|
28
|
+
# "dsh-managed-agent": "link:/path/to/dsh_enterprise_suite/plugins/dsh-managed-agent"
|
|
29
|
+
# 并在 dsh.profile.bundles 里追加 "dsh-managed-agent"
|
|
30
|
+
|
|
31
|
+
# 2) 让 Loader 插入插件(cordis.patch.yml)
|
|
32
|
+
- insert:
|
|
33
|
+
- id: managed-agent
|
|
34
|
+
name: dsh-managed-agent
|
|
35
|
+
config:
|
|
36
|
+
enabled: true
|
|
37
|
+
heartbeatMinutes: 15
|
|
38
|
+
|
|
39
|
+
# 3) 注册本机(拿到设备令牌)
|
|
40
|
+
node admin/enroll.mjs --platform https://dsh-admin.intra
|
|
41
|
+
|
|
42
|
+
# 4) 重启 DSH;状态页:http://127.0.0.1:<dsh 端口>/managed-agent/v1/
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 配置项
|
|
46
|
+
|
|
47
|
+
| 键 | 默认 | 说明 |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `enabled` | `true` | 关闭后不注册定时器,进程内零开销 |
|
|
50
|
+
| `endpoint` | `''` | 平台 API 前缀;留空用凭据文件里的 `endpoint` |
|
|
51
|
+
| `heartbeatMinutes` | `15` | 心跳周期(分钟) |
|
|
52
|
+
| `deviceTokenFile` | `''` | 凭据文件;留空用 `<DSH_HOME>/managed-agent.json` |
|
|
53
|
+
| `patchFile` | `''` | 指纹来源;留空用 `<DSH_HOME>/profiles/<profile>/cordis.patch.yml` |
|
|
54
|
+
| `profile` | `desktop` | profile 名 |
|
|
55
|
+
| `machineLabel` | hostname | 机器显示名 |
|
|
56
|
+
| `dshVersion` | 自动 | 版本号;留空依次尝试宿主服务 → 环境变量 → `<DSH_HOME>/app-version` 文件 |
|
|
57
|
+
| `statusPath` | `/managed-agent/v1` | 本地只读状态页前缀 |
|
|
58
|
+
| `timeoutMs` | `10000` | 单次请求超时 |
|
|
59
|
+
|
|
60
|
+
## 实现说明:为什么不导出 `Config`
|
|
61
|
+
|
|
62
|
+
本机 DSH 的 cordis 4 在 `resolveConfig` 里是这样读插件配置的:
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
runtime.Config["~standard"].validate(config) // Config 必须是 Standard Schema 对象
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
写成 `export const Config = (z) => z.object(...)`(工厂函数)会让 `Config["~standard"]` 是
|
|
69
|
+
`undefined`,插件在**加载阶段**就抛 `TypeError: Cannot read properties of undefined (reading 'validate')`,
|
|
70
|
+
DSH 插件列表里显示"异常"。官方插件(如 `@deepseek-ai/dsh-session-stats`)与
|
|
71
|
+
`dsh-workload-report` 都**不导出 Config**。
|
|
72
|
+
|
|
73
|
+
所以本插件把默认值与范围约束放在内部的 `normalizeConfig()`,在 `apply` 里先归一化再使用。
|
|
74
|
+
|
|
75
|
+
## 两个约定(踩过坑,写下来)
|
|
76
|
+
|
|
77
|
+
1. **`endpoint` 必须带 API 前缀**(`http://host:8788/api/v1`)。插件按 `${endpoint}/heartbeat` 拼地址;
|
|
78
|
+
老凭据若没有 `/api/v1`,插件会自动补齐(避免"凭据有效但一直 offline")。
|
|
79
|
+
2. **DSH 版本从 `<DSH_HOME>/app-version` 读**。官方没有稳定的版本服务,所以由部署脚本
|
|
80
|
+
(`deploy/install.mjs`)在安装时写入;读不到就报 `unknown`,平台不会因此误判"版本落后"。
|
|
81
|
+
|
|
82
|
+
## 上报内容(可审计清单)
|
|
83
|
+
|
|
84
|
+
```jsonc
|
|
85
|
+
{
|
|
86
|
+
"schema": 1,
|
|
87
|
+
"kind": "dsh-managed-agent/heartbeat",
|
|
88
|
+
"machineLabel": "dev-zhang.mbp",
|
|
89
|
+
"dshVersion": "0.2.1-rc.3",
|
|
90
|
+
"patchFingerprint": "sha256:…", // profile 的 cordis.patch.yml 内容哈希
|
|
91
|
+
"plugins": [{ "name": "dsh-managed-agent", "version": "0.2.1" }],
|
|
92
|
+
"reportedAt": "2026-10-28T…Z"
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**没有**工作区路径、会话 id、提示词、回复、文件内容、凭据。这一点有测试守着
|
|
97
|
+
(`test/verify.mjs` 里有一条断言:payload 不含 `cwd/prompt/content/workspace`)。
|
|
98
|
+
|
|
99
|
+
## 合规判定为什么在服务端
|
|
100
|
+
|
|
101
|
+
| 现象 | 谁判定 | 依据 |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| `ok` | 平台 | 指纹与基线一致、版本达标 |
|
|
104
|
+
| `drifted` | 平台 | 指纹 ≠ 基线(基线=平台统一值,或该设备首次心跳) |
|
|
105
|
+
| `outdated` | 平台 | `dshVersion` < `settings.compliance.minDshVersion` |
|
|
106
|
+
| `revoked` | 平台 | 设备被吊销(离职/手工撤销) |
|
|
107
|
+
|
|
108
|
+
插件只把平台返回的 `compliance` **原样展示**。理由:客户端能改,客户端永远不是判定方。
|
|
109
|
+
这是方案 §10「patch 漂移可被发现(发现≠阻止,是证据不是边界)」的落地方式。
|
|
110
|
+
|
|
111
|
+
## 失败与恢复
|
|
112
|
+
|
|
113
|
+
| 情况 | 行为 |
|
|
114
|
+
|---|---|
|
|
115
|
+
| 平台不可达 / 5xx | 记 `offline`,把**最新一份**心跳写入 `managed-agent-pending.json`,下个周期补发 |
|
|
116
|
+
| 401 凭据失效 | 记 `unauthorized`,保留待发,提示重新注册 |
|
|
117
|
+
| 403 `pending` | 记 `pending`,**不清凭据**(批准后自动恢复) |
|
|
118
|
+
| 403 `revoked` | 清除本地令牌(文件保留工号/机器信息用于展示),停止上报,提示联系平台组 |
|
|
119
|
+
|
|
120
|
+
**为什么只保留最新一份心跳、而不是事件队列**:心跳是状态快照,不是事件流。
|
|
121
|
+
重放三小时前的快照只会把 `lastSeenAt` 写成过去时,让平台误判这台机器"三小时前还活着"。
|
|
122
|
+
|
|
123
|
+
## 本地状态页
|
|
124
|
+
|
|
125
|
+
- `GET /managed-agent/v1/` → HTML(只读)
|
|
126
|
+
- `GET /managed-agent/v1/status` → JSON
|
|
127
|
+
- 其它方法 → `405 read-only`
|
|
128
|
+
|
|
129
|
+
显示:工号、机器 ID、平台地址、最后上报时间、上报结果、合规状态、DSH 版本、
|
|
130
|
+
patch 指纹前 23 位、插件数、待发心跳。**没有**任何修改入口(插件不是管控点)。
|
|
131
|
+
|
|
132
|
+
## 自检
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
node plugins/dsh-managed-agent/test/verify.mjs
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
34 项:单元(假宿主/假 fetch:采集内容、失败重放、pending/revoked/401 处理、状态页只读、
|
|
139
|
+
`apply` 路由与清理)+ 集成(真实服务端起注册与心跳,验证 **改坏 patch → 平台判 drifted**、
|
|
140
|
+
版本落后 → outdated、恢复 → ok、合规变化进审计、离职 → 插件清凭据)。
|
|
141
|
+
|
|
142
|
+
## 已知限制(M2 范围)
|
|
143
|
+
|
|
144
|
+
1. **没有 React 客户端设置页**:状态页由宿主直接提供 HTML(零构建、零依赖)。
|
|
145
|
+
若要做进 DSH 设置界面,需要 `dsh.client` + esbuild 打包 React 组件(后续版本)。
|
|
146
|
+
2. **插件清单**取 profile 的 `bundles + dependencies` 元数据,不是运行时加载器快照。
|
|
147
|
+
3. **上报不含用量**:用量是 M3 的事,且由 `dsh-usage-telemetry` 走另一条通道。
|
|
148
|
+
4. 待审批/吊销的提示只写日志与状态页,未做桌面通知。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# 把受管设备代理作为一行插入 profile 的 Loader。
|
|
2
|
+
# 员工机上只需要这一个插件;平台地址与设备凭据来自 ~/.dsh/managed-agent.json
|
|
3
|
+
# (由 `node admin/enroll.mjs` 生成,0600),不写进本 patch,避免配置漂移。
|
|
4
|
+
- insert:
|
|
5
|
+
- id: managed-agent
|
|
6
|
+
name: dsh-managed-agent
|
|
7
|
+
config:
|
|
8
|
+
enabled: true
|
|
9
|
+
# 平台地址;留空则读 ~/.dsh/managed-agent.json 里的 endpoint
|
|
10
|
+
endpoint: ''
|
|
11
|
+
heartbeatMinutes: 15
|
|
12
|
+
# 本地只读状态页前缀(挂在 DSH 自带的 WebServer 上)
|
|
13
|
+
statusPath: /managed-agent/v1
|
package/index.js
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-managed-agent —— 受管设备代理(Host 侧)
|
|
3
|
+
*
|
|
4
|
+
* 它只做四件事,全部是"旁路":
|
|
5
|
+
* 1. 读取本机设备凭据(`~/.dsh/managed-agent.json`,由 `node admin/enroll.mjs` 生成,0600)
|
|
6
|
+
* 2. 周期上报**事实**:DSH 版本、patch 指纹、插件清单、机器名 —— **不上报任何对话内容**
|
|
7
|
+
* 3. 按平台返回处理状态:pending(待审批)/ revoked(已吊销)/ unauthorized(凭据失效)
|
|
8
|
+
* 4. 提供一个**本地只读**状态页(`/managed-agent/v1/`),显示"我在不在管、最后一次上报成功没"
|
|
9
|
+
*
|
|
10
|
+
* 三条不可让步的实现约束(对应方案 §7.2):
|
|
11
|
+
* · **采集是旁路**:任何异常只记日志,绝不 throw 到宿主;绝不影响会话。
|
|
12
|
+
* · **不上报内容**:payload 里没有提示词、回复、文件内容、工具参数;连工作区路径都不带。
|
|
13
|
+
* · **不在本机做判定**:`drifted / outdated` 由**服务端**比对基线后给出,插件只显示结果。
|
|
14
|
+
* 客户端能改,所以客户端永远不是判定方 —— 这是"发现≠阻止"的一致性要求。
|
|
15
|
+
*
|
|
16
|
+
* 为什么失败时只保留"最后一次待发心跳",而不是事件队列:
|
|
17
|
+
* 心跳是**状态快照**,不是事件流。把三小时前的心跳重放上去,只会把 lastSeenAt 写成过去时,
|
|
18
|
+
* 让平台误判"这台机器三小时前还活着"。所以重放的正确语义是:**只补发最新那一份**。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createHash } from 'node:crypto'
|
|
22
|
+
import { existsSync, readFileSync, renameSync, writeFileSync, chmodSync } from 'node:fs'
|
|
23
|
+
import { hostname } from 'node:os'
|
|
24
|
+
import { dirname, join, resolve } from 'node:path'
|
|
25
|
+
|
|
26
|
+
export const name = 'managed-agent'
|
|
27
|
+
|
|
28
|
+
/** 需要宿主提供的能力:WebServer(挂本地只读状态页) */
|
|
29
|
+
export const inject = ['webServer']
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 配置归一化。
|
|
33
|
+
*
|
|
34
|
+
* 为什么不用 `export const Config = (z) => z.object(...)`:
|
|
35
|
+
* 本机 DSH 的 cordis 4 在 resolveConfig 里是这样读配置的 ——
|
|
36
|
+
* `runtime.Config["~standard"].validate(config)`
|
|
37
|
+
* 也就是说 `Config` 必须是**一个 Standard Schema 对象**,而不是"接收 z 的工厂函数"。
|
|
38
|
+
* 写成工厂函数会让 `Config["~standard"]` 是 undefined,插件在加载阶段直接抛错(界面上显示"异常")。
|
|
39
|
+
* 官方插件(如 @deepseek-ai/dsh-session-stats)与本机已装插件都**不导出 Config**,
|
|
40
|
+
* 所以这里也去掉它,改成在 apply 内部做默认值与范围约束 —— 少一层宿主契约,多一层自证。
|
|
41
|
+
*/
|
|
42
|
+
export function normalizeConfig(raw = {}) {
|
|
43
|
+
const text = (value, fallback = '') => (typeof value === 'string' ? value : fallback)
|
|
44
|
+
const integer = (value, fallback, min, max) => {
|
|
45
|
+
const parsed = Number(value)
|
|
46
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
47
|
+
return Math.min(Math.max(Math.round(parsed), min), max)
|
|
48
|
+
}
|
|
49
|
+
const statusPath = text(raw.statusPath, '/managed-agent/v1').trim()
|
|
50
|
+
return {
|
|
51
|
+
enabled: raw.enabled !== false,
|
|
52
|
+
endpoint: text(raw.endpoint).trim(),
|
|
53
|
+
heartbeatMinutes: integer(raw.heartbeatMinutes, 15, 1, 1440),
|
|
54
|
+
deviceTokenFile: text(raw.deviceTokenFile),
|
|
55
|
+
patchFile: text(raw.patchFile),
|
|
56
|
+
profile: text(raw.profile),
|
|
57
|
+
machineLabel: text(raw.machineLabel),
|
|
58
|
+
dshVersion: text(raw.dshVersion),
|
|
59
|
+
statusPath: statusPath.startsWith('/') ? statusPath : '/managed-agent/v1',
|
|
60
|
+
timeoutMs: integer(raw.timeoutMs, 10_000, 1000, 60_000),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
// 纯函数(可单独测试)
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export function dshHomeOf(ctx) {
|
|
69
|
+
return ctx?.get?.('dshHome') ?? process.env.DSH_HOME ?? join(process.env.HOME ?? '', '.dsh')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 计算文件 sha256;文件不存在返回 null(不是错误:新机器可能还没渲染 patch) */
|
|
73
|
+
export function fingerprintFile(file) {
|
|
74
|
+
try {
|
|
75
|
+
if (!file || !existsSync(file)) return null
|
|
76
|
+
return `sha256:${createHash('sha256').update(readFileSync(file)).digest('hex')}`
|
|
77
|
+
} catch {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 版本比较:只比较数字段,`0.2.1-rc.3` vs `0.2.0` → 1 */
|
|
83
|
+
export function compareVersions(a, b) {
|
|
84
|
+
const parse = (value) => String(value ?? '').replace(/^v/, '').split(/[-+]/)[0].split('.').map((part) => Number.parseInt(part, 10) || 0)
|
|
85
|
+
const left = parse(a)
|
|
86
|
+
const right = parse(b)
|
|
87
|
+
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
|
|
88
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0)
|
|
89
|
+
if (diff !== 0) return diff > 0 ? 1 : -1
|
|
90
|
+
}
|
|
91
|
+
return 0
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 读 profile 的插件清单(名称 + 版本)。只读元数据,不碰任何会话内容。 */
|
|
95
|
+
export function readPluginList(profileDir) {
|
|
96
|
+
try {
|
|
97
|
+
const pkgFile = join(profileDir, 'package.json')
|
|
98
|
+
if (!existsSync(pkgFile)) return []
|
|
99
|
+
const pkg = JSON.parse(readFileSync(pkgFile, 'utf8'))
|
|
100
|
+
const deps = Object.entries(pkg.dependencies ?? {}).map(([depName, version]) => ({
|
|
101
|
+
name: depName,
|
|
102
|
+
version: String(version).replace(/^link:/, 'link:'),
|
|
103
|
+
}))
|
|
104
|
+
const bundles = (pkg.dsh?.profile?.bundles ?? []).map((bundle) => ({ name: bundle, version: 'bundle' }))
|
|
105
|
+
return [...bundles, ...deps]
|
|
106
|
+
} catch {
|
|
107
|
+
return []
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 组装本次上报的"事实"。
|
|
113
|
+
* 注意这里**没有**工作区路径、会话 id、提示词 —— 这是合规承诺,不是遗漏。
|
|
114
|
+
*/
|
|
115
|
+
/**
|
|
116
|
+
* 解析 DSH 版本。
|
|
117
|
+
* 插件运行在宿主进程里,但官方没有稳定的"版本服务",所以按可靠性排序兜底:
|
|
118
|
+
* 1. 配置显式指定(最稳,部署脚本可以注入)
|
|
119
|
+
* 2. 宿主可能提供的服务(dshVersion / appVersion)
|
|
120
|
+
* 3. 环境变量(DSH_VERSION / DSH_APP_VERSION)
|
|
121
|
+
* 4. `<DSH_HOME>/app-version` 文件 —— **部署脚本在安装时写入**,这是推荐的长期做法
|
|
122
|
+
* 全都拿不到就报 unknown;平台侧不会因此误判"版本落后"。
|
|
123
|
+
*/
|
|
124
|
+
export function resolveDshVersion({ ctx, config, home }) {
|
|
125
|
+
if (config.dshVersion) return config.dshVersion
|
|
126
|
+
for (const key of ['dshVersion', 'appVersion']) {
|
|
127
|
+
const value = ctx?.get?.(key)
|
|
128
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
129
|
+
}
|
|
130
|
+
const env = process.env.DSH_VERSION || process.env.DSH_APP_VERSION
|
|
131
|
+
if (env && env.trim()) return env.trim()
|
|
132
|
+
try {
|
|
133
|
+
const file = join(home, 'app-version')
|
|
134
|
+
if (existsSync(file)) {
|
|
135
|
+
const text = readFileSync(file, 'utf8').trim()
|
|
136
|
+
if (text) return text
|
|
137
|
+
}
|
|
138
|
+
} catch { /* 忽略 */ }
|
|
139
|
+
return 'unknown'
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function collectFacts({ ctx, config, home }) {
|
|
143
|
+
const profile = config.profile || process.env.DSH_PROFILE || 'desktop'
|
|
144
|
+
const profileDir = join(home, 'profiles', profile)
|
|
145
|
+
const patchFile = config.patchFile || join(profileDir, 'cordis.patch.yml')
|
|
146
|
+
return {
|
|
147
|
+
schema: 1,
|
|
148
|
+
kind: 'dsh-managed-agent/heartbeat',
|
|
149
|
+
machineLabel: config.machineLabel || hostname().replace(/\.local$/, ''),
|
|
150
|
+
dshVersion: resolveDshVersion({ ctx, config, home }),
|
|
151
|
+
patchFingerprint: fingerprintFile(patchFile),
|
|
152
|
+
patchFile: config.patchFile ? resolve(config.patchFile) : null,
|
|
153
|
+
plugins: readPluginList(profileDir),
|
|
154
|
+
reportedAt: new Date().toISOString(),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
159
|
+
// 凭据与待发心跳
|
|
160
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
function readJsonFile(file) {
|
|
163
|
+
try {
|
|
164
|
+
if (!existsSync(file)) return null
|
|
165
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
166
|
+
} catch {
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function writeJsonFile(file, value, mode = 0o600) {
|
|
172
|
+
writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { mode })
|
|
173
|
+
try { chmodSync(file, mode) } catch { /* Windows 无 chmod 语义 */ }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
177
|
+
// 主逻辑
|
|
178
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 创建代理实例。apply() 只是把它挂到宿主上;测试可以直接构造它。
|
|
182
|
+
* 返回对象上的方法都是**安全**的:内部 catch 一切异常,只更新 state 与日志。
|
|
183
|
+
*/
|
|
184
|
+
export function createAgent(ctx, config, { fetchImpl = globalThis.fetch } = {}) {
|
|
185
|
+
const home = dshHomeOf(ctx)
|
|
186
|
+
const credentialFile = config.deviceTokenFile || join(home, 'managed-agent.json')
|
|
187
|
+
const pendingFile = join(dirname(credentialFile), 'managed-agent-pending.json')
|
|
188
|
+
const statusPath = (config.statusPath || '/managed-agent/v1').replace(/\/+$/, '')
|
|
189
|
+
const logger = ctx?.logger
|
|
190
|
+
|
|
191
|
+
const state = {
|
|
192
|
+
enrolled: false,
|
|
193
|
+
employee: null,
|
|
194
|
+
machineId: null,
|
|
195
|
+
endpoint: null,
|
|
196
|
+
lastHeartbeatAt: null,
|
|
197
|
+
lastAttemptAt: null,
|
|
198
|
+
lastResult: 'idle', // idle | ok | pending | revoked | unauthorized | offline | not-enrolled
|
|
199
|
+
lastError: null,
|
|
200
|
+
serverStatus: null,
|
|
201
|
+
compliance: 'unknown', // 服务端判定结果,原样展示
|
|
202
|
+
quotaState: null, // M3/M6 后由服务端下发
|
|
203
|
+
pendingSince: null,
|
|
204
|
+
heartbeatCount: 0,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const log = {
|
|
208
|
+
info: (msg) => logger?.info?.(`[managed-agent] ${msg}`),
|
|
209
|
+
warn: (msg) => logger?.warn?.(`[managed-agent] ${msg}`),
|
|
210
|
+
error: (msg) => logger?.error?.(`[managed-agent] ${msg}`),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function loadCredential() {
|
|
214
|
+
const credential = readJsonFile(credentialFile)
|
|
215
|
+
if (!credential?.deviceToken) {
|
|
216
|
+
state.enrolled = false
|
|
217
|
+
state.lastResult = 'not-enrolled'
|
|
218
|
+
return null
|
|
219
|
+
}
|
|
220
|
+
state.enrolled = true
|
|
221
|
+
state.employee = credential.employee ?? null
|
|
222
|
+
state.machineId = credential.machineId ?? null
|
|
223
|
+
// 容错:老版本 enroll 写入的 endpoint 可能没有 `/api/v1` 前缀。
|
|
224
|
+
// 插件按 `${endpoint}/heartbeat` 拼地址,所以这里统一补齐,避免"凭据有效但一直 offline"。
|
|
225
|
+
const rawEndpoint = String(config.endpoint || credential.endpoint || '').replace(/\/+$/, '')
|
|
226
|
+
state.endpoint = !rawEndpoint ? '' : (/\/api\/v\d+$/.test(rawEndpoint) ? rawEndpoint : `${rawEndpoint}/api/v1`)
|
|
227
|
+
return credential
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function savePending(payload) {
|
|
231
|
+
try {
|
|
232
|
+
writeJsonFile(pendingFile, payload)
|
|
233
|
+
state.pendingSince = state.pendingSince ?? new Date().toISOString()
|
|
234
|
+
} catch (error) {
|
|
235
|
+
log.warn(`写入待发心跳失败(忽略):${error?.message ?? error}`)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function clearPending() {
|
|
240
|
+
try {
|
|
241
|
+
if (existsSync(pendingFile)) writeJsonFile(pendingFile, { cleared: true })
|
|
242
|
+
state.pendingSince = null
|
|
243
|
+
} catch { /* 忽略 */ }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 吊销:清除本地令牌(保留工号/机器信息用于展示),并停止后续心跳 */
|
|
247
|
+
function markRevoked(credential) {
|
|
248
|
+
try {
|
|
249
|
+
writeJsonFile(credentialFile, {
|
|
250
|
+
...credential,
|
|
251
|
+
deviceToken: null,
|
|
252
|
+
revokedAt: new Date().toISOString(),
|
|
253
|
+
status: 'revoked',
|
|
254
|
+
})
|
|
255
|
+
} catch (error) {
|
|
256
|
+
log.warn(`清除本地凭据失败(忽略):${error?.message ?? error}`)
|
|
257
|
+
}
|
|
258
|
+
state.enrolled = false
|
|
259
|
+
state.lastResult = 'revoked'
|
|
260
|
+
log.warn('设备已被平台吊销,本地令牌已清除。请联系平台组重新注册。')
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function sendHeartbeat(payload) {
|
|
264
|
+
const credential = loadCredential()
|
|
265
|
+
if (!credential) return { skipped: true }
|
|
266
|
+
|
|
267
|
+
const url = `${state.endpoint}/heartbeat`
|
|
268
|
+
const controller = new AbortController()
|
|
269
|
+
const timer = setTimeout(() => controller.abort(), config.timeoutMs)
|
|
270
|
+
try {
|
|
271
|
+
state.lastAttemptAt = new Date().toISOString()
|
|
272
|
+
const res = await fetchImpl(url, {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
headers: {
|
|
275
|
+
'content-type': 'application/json',
|
|
276
|
+
authorization: `Bearer ${credential.deviceToken}`,
|
|
277
|
+
},
|
|
278
|
+
body: JSON.stringify(payload),
|
|
279
|
+
signal: controller.signal,
|
|
280
|
+
})
|
|
281
|
+
const body = await res.json().catch(() => null)
|
|
282
|
+
|
|
283
|
+
if (res.ok) {
|
|
284
|
+
state.lastHeartbeatAt = new Date().toISOString()
|
|
285
|
+
state.lastResult = 'ok'
|
|
286
|
+
state.lastError = null
|
|
287
|
+
state.serverStatus = body?.status ?? 'active'
|
|
288
|
+
state.compliance = body?.compliance ?? 'unknown'
|
|
289
|
+
state.quotaState = body?.quotaState ?? null
|
|
290
|
+
state.heartbeatCount += 1
|
|
291
|
+
clearPending()
|
|
292
|
+
return { ok: true, body }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (res.status === 403 && body?.error === 'pending') {
|
|
296
|
+
state.lastResult = 'pending'
|
|
297
|
+
state.lastError = body?.hint ?? '待平台组批准'
|
|
298
|
+
state.serverStatus = 'pending'
|
|
299
|
+
clearPending()
|
|
300
|
+
return { ok: false, pending: true }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (res.status === 403 && body?.error === 'revoked') {
|
|
304
|
+
markRevoked(credential)
|
|
305
|
+
clearPending()
|
|
306
|
+
return { ok: false, revoked: true }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (res.status === 401) {
|
|
310
|
+
state.lastResult = 'unauthorized'
|
|
311
|
+
state.lastError = body?.hint ?? '设备令牌无效'
|
|
312
|
+
savePending(payload)
|
|
313
|
+
return { ok: false, unauthorized: true }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 其它状态码:当成暂时失败,留待下次重试
|
|
317
|
+
state.lastResult = 'offline'
|
|
318
|
+
state.lastError = `HTTP ${res.status}${body?.error ? ` ${body.error}` : ''}`
|
|
319
|
+
savePending(payload)
|
|
320
|
+
return { ok: false, status: res.status }
|
|
321
|
+
} catch (error) {
|
|
322
|
+
state.lastResult = 'offline'
|
|
323
|
+
state.lastError = error?.name === 'AbortError' ? '请求超时' : String(error?.message ?? error)
|
|
324
|
+
savePending(payload)
|
|
325
|
+
return { ok: false, error }
|
|
326
|
+
} finally {
|
|
327
|
+
clearTimeout(timer)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** 一次心跳:优先补发上次没发出去的那份,否则采集新事实 */
|
|
332
|
+
async function tick(reason = 'interval') {
|
|
333
|
+
const pending = readJsonFile(pendingFile)
|
|
334
|
+
const payload = pending && pending.kind === 'dsh-managed-agent/heartbeat'
|
|
335
|
+
? { ...pending, replayedAt: new Date().toISOString() }
|
|
336
|
+
: collectFacts({ ctx, config, home })
|
|
337
|
+
const result = await sendHeartbeat(payload)
|
|
338
|
+
if (result?.ok) log.info(`心跳成功(${reason}):${state.compliance} / ${state.serverStatus}`)
|
|
339
|
+
else if (result?.pending) log.info(`心跳被受理但设备待审批(${reason})`)
|
|
340
|
+
else if (!result?.skipped) log.warn(`心跳未成功(${reason}):${state.lastResult} ${state.lastError ?? ''}`.trim())
|
|
341
|
+
return result
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── 本地只读状态页 ────────────────────────────────────────────────────
|
|
345
|
+
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
|
346
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
347
|
+
}[ch]))
|
|
348
|
+
|
|
349
|
+
function statusSnapshot() {
|
|
350
|
+
return {
|
|
351
|
+
schema: 1,
|
|
352
|
+
enrolled: state.enrolled,
|
|
353
|
+
employee: state.employee,
|
|
354
|
+
machineId: state.machineId,
|
|
355
|
+
endpoint: state.endpoint,
|
|
356
|
+
lastHeartbeatAt: state.lastHeartbeatAt,
|
|
357
|
+
lastAttemptAt: state.lastAttemptAt,
|
|
358
|
+
lastResult: state.lastResult,
|
|
359
|
+
lastError: state.lastError,
|
|
360
|
+
serverStatus: state.serverStatus,
|
|
361
|
+
compliance: state.compliance,
|
|
362
|
+
quotaState: state.quotaState,
|
|
363
|
+
pendingSince: state.pendingSince,
|
|
364
|
+
heartbeatCount: state.heartbeatCount,
|
|
365
|
+
facts: collectFacts({ ctx, config, home }),
|
|
366
|
+
readOnly: true,
|
|
367
|
+
note: '本页只读:设备状态由平台判定,插件不提供任何修改入口。',
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function statusHtml() {
|
|
372
|
+
const snap = statusSnapshot()
|
|
373
|
+
const resultLabel = {
|
|
374
|
+
idle: '尚未上报', ok: '上报正常', pending: '待平台组批准', revoked: '设备已被吊销',
|
|
375
|
+
unauthorized: '设备令牌失效', offline: '平台不可达', 'not-enrolled': '本机尚未注册',
|
|
376
|
+
}[snap.lastResult] ?? snap.lastResult
|
|
377
|
+
const complianceLabel = {
|
|
378
|
+
ok: '合规', drifted: '配置已漂移', outdated: '版本落后', revoked: '已吊销', unknown: '未知',
|
|
379
|
+
}[snap.compliance] ?? snap.compliance
|
|
380
|
+
const tone = snap.compliance === 'ok' ? '#0b6b43'
|
|
381
|
+
: snap.compliance === 'drifted' || snap.compliance === 'outdated' ? '#8a5a00'
|
|
382
|
+
: snap.compliance === 'revoked' ? '#a3231c' : '#5b6472'
|
|
383
|
+
const rows = [
|
|
384
|
+
['工号', snap.employee ?? '—'],
|
|
385
|
+
['机器 ID', snap.machineId ?? '—'],
|
|
386
|
+
['平台', snap.endpoint ?? '—'],
|
|
387
|
+
['最后上报', snap.lastHeartbeatAt ?? '—'],
|
|
388
|
+
['上报结果', resultLabel],
|
|
389
|
+
['合规状态', complianceLabel],
|
|
390
|
+
['DSH 版本', snap.facts.dshVersion],
|
|
391
|
+
['patch 指纹', snap.facts.patchFingerprint ? `${snap.facts.patchFingerprint.slice(0, 23)}…` : '未找到 patch 文件'],
|
|
392
|
+
['插件数', String(snap.facts.plugins.length)],
|
|
393
|
+
['待发心跳', snap.pendingSince ? `自 ${snap.pendingSince}` : '无'],
|
|
394
|
+
]
|
|
395
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
|
|
396
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"><meta name="referrer" content="no-referrer">
|
|
397
|
+
<title>受管设备状态</title>
|
|
398
|
+
<style>
|
|
399
|
+
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:#f2f5f9;color:#1c2531;
|
|
400
|
+
font:14px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
|
|
401
|
+
.card{width:100%;max-width:620px;background:#fff;border:1px solid #dbe2ec;border-radius:12px;padding:26px 30px}
|
|
402
|
+
h1{font-size:17px;margin:0 0 4px}.sub{color:#64748b;font-size:13px;margin:0 0 18px}
|
|
403
|
+
.bar{height:3px;width:56px;background:${tone};border-radius:2px;margin-bottom:16px}
|
|
404
|
+
dl{display:grid;grid-template-columns:110px 1fr;gap:8px 14px;margin:0 0 16px;padding:14px 16px;
|
|
405
|
+
background:#f7f9fc;border:1px solid #e5ebf3;border-radius:10px}
|
|
406
|
+
dt{color:#64748b;font-size:13px}dd{margin:0;word-break:break-all}
|
|
407
|
+
code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12.5px}
|
|
408
|
+
.note{padding:10px 12px;border-radius:8px;font-size:12.5px;background:#eef4fd;border:1px solid #cfe0f7;color:#1a4f8a}
|
|
409
|
+
</style></head><body><div class="card">
|
|
410
|
+
<div class="bar"></div><h1>受管设备状态</h1>
|
|
411
|
+
<p class="sub">只读页面:数据来自本机凭据与最近一次上报结果;状态判定在平台侧完成。</p>
|
|
412
|
+
<dl>${rows.map(([key, value]) => `<dt>${escapeHtml(key)}</dt><dd>${/指纹|机器 ID/.test(key) ? `<code>${escapeHtml(value)}</code>` : escapeHtml(value)}</dd>`).join('')}</dl>
|
|
413
|
+
<p class="note">${escapeHtml(snap.note)}</p>
|
|
414
|
+
</div></body></html>`
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function sendJson(res, status, body) {
|
|
418
|
+
const text = JSON.stringify(body, null, 2)
|
|
419
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
420
|
+
res.end(text)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function handle(req, res) {
|
|
424
|
+
try {
|
|
425
|
+
const url = new URL(req.url ?? '/', 'http://managed-agent.invalid')
|
|
426
|
+
const route = url.pathname.slice(statusPath.length) || '/'
|
|
427
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
428
|
+
sendJson(res, 405, { ok: false, error: 'read-only', hint: '本插件只提供只读状态,不提供任何修改接口。' })
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
if (route === '/' || route === '') {
|
|
432
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
|
433
|
+
res.end(statusHtml())
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
if (route === '/status') { sendJson(res, 200, { ok: true, ...statusSnapshot() }); return }
|
|
437
|
+
if (route === '/health') { sendJson(res, 200, { ok: true, enrolled: state.enrolled, lastResult: state.lastResult }); return }
|
|
438
|
+
sendJson(res, 404, { ok: false, error: 'not-found' })
|
|
439
|
+
} catch (error) {
|
|
440
|
+
try { sendJson(res, 500, { ok: false, error: 'internal', detail: String(error?.message ?? error) }) } catch { /* 忽略 */ }
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return { state, tick, handle, statusSnapshot, statusHtml, loadCredential, paths: { credentialFile, pendingFile }, statusPath }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function apply(ctx, rawConfig = {}) {
|
|
448
|
+
const config = normalizeConfig(rawConfig)
|
|
449
|
+
if (!config.enabled) {
|
|
450
|
+
ctx.logger?.info?.('[managed-agent] 已禁用')
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const agent = createAgent(ctx, config)
|
|
455
|
+
const credential = agent.loadCredential()
|
|
456
|
+
if (!credential) {
|
|
457
|
+
ctx.logger?.info?.(`[managed-agent] 本机尚未注册(缺少 ${agent.paths.credentialFile}),状态页仍可用;运行 node admin/enroll.mjs 完成注册`)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// 本地只读状态页:挂在 DSH 自带 WebServer 上,受浏览器门禁保护
|
|
461
|
+
ctx.effect(
|
|
462
|
+
() => ctx.webServer.register({ kind: 'prefix', path: agent.statusPath, handler: (req, res) => void agent.handle(req, res) }),
|
|
463
|
+
'managed-agent: read-only status page',
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
// 心跳定时器:先跑一次(尽快让平台看到这台机器),之后按周期
|
|
467
|
+
const intervalMs = config.heartbeatMinutes * 60_000
|
|
468
|
+
const timer = setInterval(() => { void agent.tick('interval') }, intervalMs)
|
|
469
|
+
timer.unref?.()
|
|
470
|
+
ctx.effect(() => () => clearInterval(timer), 'managed-agent: heartbeat timer')
|
|
471
|
+
void agent.tick('startup')
|
|
472
|
+
|
|
473
|
+
ctx.logger?.info?.(`[managed-agent] 心跳每 ${config.heartbeatMinutes} 分钟;状态页 ${agent.statusPath}/`)
|
|
474
|
+
return agent
|
|
475
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-managed-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "受管设备代理:读取本机设备凭据,周期上报 DSH 版本 / patch 指纹 / 插件清单,并提供本地只读状态页。只上报状态与计数,不上报提示词、回复、文件内容或凭据。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./package.json": "./package.json"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"cordis.patch.yml",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"dsh",
|
|
21
|
+
"deepseek-harness",
|
|
22
|
+
"plugin",
|
|
23
|
+
"managed",
|
|
24
|
+
"compliance",
|
|
25
|
+
"heartbeat"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dsh": {
|
|
29
|
+
"bundle": {
|
|
30
|
+
"patch": "./cordis.patch.yml"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/tableau-China/dsh-managed-agent.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/tableau-China/dsh-managed-agent#readme"
|
|
41
|
+
}
|