@dcrays/dcgchat-test 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcrays/dcgchat-test",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for 书灵墨宝 (WebSocket)",
6
6
  "main": "index.ts",
package/src/agent.ts CHANGED
@@ -1,7 +1,4 @@
1
1
  import axios from 'axios'
2
- /** @ts-ignore */
3
- import unzipper from 'unzipper'
4
- import { pipeline } from 'stream/promises'
5
2
  import fs from 'fs'
6
3
  import path from 'path'
7
4
  import { getWorkspaceDir } from './utils/global.js'
@@ -9,7 +6,7 @@ import { getWsConnection } from './utils/global.js'
9
6
  import { dcgLogger } from './utils/log.js'
10
7
  import { isWsOpen } from './transport.js'
11
8
  import { sendMessageToGateway } from './gateway/socket.js'
12
- import { decodeZipEntryPath } from './utils/zipPath.js'
9
+ import { extractZipBufferToDirectory } from './utils/zipExtract.js'
13
10
 
14
11
  type IAgentParams = {
15
12
  url: string
@@ -110,81 +107,13 @@ export async function createAgent(msgContent: Record<string, any>) {
110
107
  }
111
108
 
112
109
  try {
113
- // 下载 zip 文件
114
110
  const response = await axios({
115
111
  method: 'get',
116
112
  url,
117
- responseType: 'stream'
113
+ responseType: 'arraybuffer'
118
114
  })
119
- // 创建目标目录
120
115
  fs.mkdirSync(workspaceDir, { recursive: true })
121
- // 解压文件到目标目录,跳过顶层文件夹
122
- await new Promise((resolve, reject) => {
123
- const tasks: Promise<void>[] = []
124
- let rootDir: string | null = null
125
- let hasError = false
126
-
127
- response.data
128
- .pipe(unzipper.Parse())
129
- .on('entry', (entry: any) => {
130
- if (hasError) {
131
- entry.autodrain()
132
- return
133
- }
134
- try {
135
- const flags = entry.props?.flags ?? 0
136
- const entryPath = decodeZipEntryPath(entry.props?.pathBuffer, flags, entry.path)
137
- const pathParts = entryPath.split('/')
138
-
139
- // 检测根目录
140
- if (!rootDir && pathParts.length > 1) {
141
- rootDir = pathParts[0]
142
- }
143
- let newPath = entryPath
144
- // 移除顶层文件夹
145
- if (rootDir && entryPath.startsWith(rootDir + '/')) {
146
- newPath = entryPath.slice(rootDir.length + 1)
147
- }
148
-
149
- if (!newPath) {
150
- entry.autodrain()
151
- return
152
- }
153
-
154
- const targetPath = path.join(workspaceDir, newPath)
155
-
156
- if (entry.type === 'Directory') {
157
- fs.mkdirSync(targetPath, { recursive: true })
158
- entry.autodrain()
159
- } else {
160
- const parentDir = path.dirname(targetPath)
161
- fs.mkdirSync(parentDir, { recursive: true })
162
- const writeStream = fs.createWriteStream(targetPath)
163
- const task = pipeline(entry, writeStream).catch((err) => {
164
- hasError = true
165
- throw new Error(`解压文件失败 ${entryPath}: ${err.message}`)
166
- })
167
- tasks.push(task)
168
- }
169
- } catch (err) {
170
- hasError = true
171
- entry.autodrain()
172
- reject(new Error(`处理entry失败: ${err}`))
173
- }
174
- })
175
- .on('close', async () => {
176
- try {
177
- await Promise.all(tasks)
178
- resolve(null)
179
- } catch (err) {
180
- reject(err)
181
- }
182
- })
183
- .on('error', (err: { message: any }) => {
184
- hasError = true
185
- reject(new Error(`解压流错误: ${err.message}`))
186
- })
187
- })
116
+ await extractZipBufferToDirectory(Buffer.from(response.data), workspaceDir)
188
117
  await onCreateAgent(msgContent)
189
118
  } catch (error) {
190
119
  // 如果安装失败,清理目录
package/src/bot.ts CHANGED
@@ -235,10 +235,10 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
235
235
  const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
236
236
  let streamedTextLen = 0
237
237
 
238
- if (msg.content.skills_scope.length > 0) {
238
+ if (msg.content.skills_scope.length > 0 && !msg.content?.agent_clone_code) {
239
239
  const workspaceDir = getWorkspaceDir()
240
240
  const skillCode = msg.content.skills_scope.map((skill) => `${workspaceDir}/skills/${skill.skill_code}`).join('\n')
241
- const skillText = `技能${skillCode} 在目录${skillCode}下,在目录${skillCode}下查找技能 \n`
241
+ const skillText = `技能${skillCode} 在目录${skillCode}下,在目录${skillCode}下读取技能 \n`
242
242
  text = skillText ? `${skillText} \n ${text}` : text
243
243
  }
244
244
  const prefixContext = createReplyPrefixContext({
@@ -385,7 +385,9 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
385
385
  const prev = streamChunkIdxBySessionKey.get(dcgSessionKey) ?? 0
386
386
  streamChunkIdxBySessionKey.set(dcgSessionKey, prev + 1)
387
387
  sendChunk(delta, outboundCtx, prev)
388
- dcgLogger(`[stream]: chunkIdx=${prev} len=${delta.length} user=${outboundCtx.sessionId} ${delta.slice(0, 100)}`)
388
+ dcgLogger(
389
+ `[stream]: chunkIdx=${prev} len=${delta.length} sessionId=${outboundCtx.sessionId} ${delta.slice(0, 100)}`
390
+ )
389
391
  }
390
392
  streamedTextLen = payload.text.length
391
393
  } else {
package/src/skill.ts CHANGED
@@ -1,7 +1,4 @@
1
1
  import axios from 'axios'
2
- /** @ts-ignore */
3
- import unzipper from 'unzipper'
4
- import { pipeline } from 'stream/promises'
5
2
  import fs from 'fs'
6
3
  import path from 'path'
7
4
  import { getWorkspaceDir } from './utils/global.js'
@@ -9,7 +6,7 @@ import { getWsConnection } from './utils/global.js'
9
6
  import { dcgLogger } from './utils/log.js'
10
7
  import { isWsOpen } from './transport.js'
11
8
  import { sendMessageToGateway } from './gateway/socket.js'
12
- import { decodeZipEntryPath } from './utils/zipPath.js'
9
+ import { extractZipBufferToDirectory } from './utils/zipExtract.js'
13
10
 
14
11
  type ISkillParams = {
15
12
  path: string
@@ -47,81 +44,13 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
47
44
  }
48
45
 
49
46
  try {
50
- // 下载 zip 文件
51
47
  const response = await axios({
52
48
  method: 'get',
53
49
  url: cdnUrl,
54
- responseType: 'stream'
50
+ responseType: 'arraybuffer'
55
51
  })
56
- // 创建目标目录
57
52
  fs.mkdirSync(skillDir, { recursive: true })
58
- // 解压文件到目标目录,跳过顶层文件夹
59
- await new Promise((resolve, reject) => {
60
- const tasks: Promise<void>[] = []
61
- let rootDir: string | null = null
62
- let hasError = false
63
-
64
- response.data
65
- .pipe(unzipper.Parse())
66
- .on('entry', (entry: any) => {
67
- if (hasError) {
68
- entry.autodrain()
69
- return
70
- }
71
- try {
72
- const flags = entry.props?.flags ?? 0
73
- const entryPath = decodeZipEntryPath(entry.props?.pathBuffer, flags, entry.path)
74
- const pathParts = entryPath.split('/')
75
-
76
- // 检测根目录
77
- if (!rootDir && pathParts.length > 1) {
78
- rootDir = pathParts[0]
79
- }
80
- let newPath = entryPath
81
- // 移除顶层文件夹
82
- if (rootDir && entryPath.startsWith(rootDir + '/')) {
83
- newPath = entryPath.slice(rootDir.length + 1)
84
- }
85
-
86
- if (!newPath) {
87
- entry.autodrain()
88
- return
89
- }
90
-
91
- const targetPath = path.join(skillDir, newPath)
92
-
93
- if (entry.type === 'Directory') {
94
- fs.mkdirSync(targetPath, { recursive: true })
95
- entry.autodrain()
96
- } else {
97
- const parentDir = path.dirname(targetPath)
98
- fs.mkdirSync(parentDir, { recursive: true })
99
- const writeStream = fs.createWriteStream(targetPath)
100
- const task = pipeline(entry, writeStream).catch((err) => {
101
- hasError = true
102
- throw new Error(`解压文件失败 ${entryPath}: ${err.message}`)
103
- })
104
- tasks.push(task)
105
- }
106
- } catch (err) {
107
- hasError = true
108
- entry.autodrain()
109
- reject(new Error(`处理entry失败: ${err}`))
110
- }
111
- })
112
- .on('close', async () => {
113
- try {
114
- await Promise.all(tasks)
115
- resolve(null)
116
- } catch (err) {
117
- reject(err)
118
- }
119
- })
120
- .on('error', (err: { message: any }) => {
121
- hasError = true
122
- reject(new Error(`解压流错误: ${err.message}`))
123
- })
124
- })
53
+ await extractZipBufferToDirectory(Buffer.from(response.data), skillDir)
125
54
  sendEvent({ ...msgContent, status: 'ok' })
126
55
  sendMessageToGateway(JSON.stringify({ method: 'skills.status', params: {} }))
127
56
  } catch (error) {
@@ -18,15 +18,26 @@ function toPosixPath(p: string): string {
18
18
  return path.normalize(p.trim()).replace(/\\/g, '/')
19
19
  }
20
20
 
21
+ /** `filepath` 解析后在 `rootDir` 内或等于 `rootDir`(防 `..` 逃逸)。 */
22
+ function isPathInsideDir(filepath: string, rootDir: string): boolean {
23
+ const root = path.resolve(rootDir)
24
+ const resolved = path.resolve(filepath)
25
+ const rel = path.relative(root, resolved)
26
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return false
27
+ return true
28
+ }
29
+
21
30
  /**
22
- * 允许发送的挂载根:Unix/macOS/Linux 为 /workspace、/mobook;
23
- * Windows 为盘符路径如 C:/workspace、D:\\mobook\\...(规范化后比较)。
31
+ * 允许发送的路径:
32
+ * - 当前 Agent 工作区根及其子路径(`workspaceDir`,如 ~/.openclaw/workspace-xxx/output/...);
33
+ * - 兼容旧挂载:Unix `/workspace`、`/mobook`;Windows 盘符下 `workspace`、`mobook`。
24
34
  */
25
- function isSafePath(filepath: string): boolean {
35
+ function isSafePath(filepath: string, workspaceDir?: string): boolean {
36
+ const ws = workspaceDir?.trim()
37
+ if (ws && isPathInsideDir(filepath, ws)) return true
26
38
  const p = toPosixPath(filepath)
27
39
  if (p.startsWith('/workspace/') || p === '/workspace') return true
28
- if (p.startsWith('/mobook/') || p === '/mobook') return true
29
- // Windows: C:/workspace/...、c:/mobook/...
40
+ if (p === '/mobook') return true
30
41
  return /^[A-Za-z]:\/(workspace|mobook)(\/|$)/.test(p)
31
42
  }
32
43
 
@@ -64,7 +75,7 @@ const messageToolParameters = {
64
75
  file: {
65
76
  type: 'string',
66
77
  description:
67
- '文件路径。Unix/macOS/Linux /workspace/output/report.pdf;Windows 如 C:/workspace/output/report.pdf C:\\workspace\\output\\report.pdf'
78
+ '文件绝对路径:须在「当前 Agent 工作区」目录下(如 /root/.openclaw/workspace-xxx/output/28337/slices_result.json),或为兼容环境的 /workspace/、/mobook/(Windows 盘符下 workspace、mobook)'
68
79
  }
69
80
  },
70
81
  required: ['file']
@@ -74,12 +85,32 @@ const messageToolParameters = {
74
85
  oneOf: [{ required: ['content'] }, { required: ['media'] }]
75
86
  }
76
87
 
77
- function extractPaths(text?: string): string[] {
88
+ /** 从正文提取可发送的文件路径(固定挂载 + 当前工作区前缀)。 */
89
+ function extractPaths(text: string | undefined, workspaceDir?: string): string[] {
78
90
  if (!text) return []
79
91
  const unix = text.match(/\/workspace\/[^\s]+|\/mobook\/[^\s]+/g) ?? []
80
- // Windows: C:\workspace\...、C:/mobook/...(不含空白)
81
92
  const win = text.match(/[A-Za-z]:[/\\](?:workspace|mobook)[/\\][^\s]+/g) ?? []
82
- return [...new Set([...unix, ...win])]
93
+ const underWs: string[] = []
94
+ const ws = workspaceDir?.trim()
95
+ if (ws) {
96
+ const variants = new Set<string>()
97
+ variants.add(ws)
98
+ variants.add(toPosixPath(ws))
99
+ if (path.sep === '\\') variants.add(ws.replace(/\//g, '\\'))
100
+ for (const prefix of variants) {
101
+ if (!prefix) continue
102
+ let from = 0
103
+ while (from < text.length) {
104
+ const i = text.indexOf(prefix, from)
105
+ if (i === -1) break
106
+ let end = i + prefix.length
107
+ while (end < text.length && !/\s/.test(text[end])) end++
108
+ underWs.push(text.slice(i, end))
109
+ from = i + 1
110
+ }
111
+ }
112
+ }
113
+ return [...new Set([...unix, ...win, ...underWs])]
83
114
  }
84
115
 
85
116
  function isSafeFile(filepath: string) {
@@ -104,8 +135,8 @@ export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext):
104
135
  向用户发送消息。
105
136
  若传 target,target 必须是 sessionKey,不能是 userId。
106
137
  如果发送附件:必须使用 media 字段
107
- 支持路径目录(Unix/macOS/Linux:/workspace/、/mobook/;Windows:盘符下 workspace、mobook,如 C:/workspace/):
108
- 禁止直接输出路径文本
138
+ 文件路径须在当前 Agent 工作区目录下(随部署变化,如 ~/.openclaw/workspace-xxx/...),或为兼容环境的 /workspace/、/mobook/(Windows 盘符下 workspace、mobook)。
139
+ 禁止在正文中直接输出可访问路径(应通过 media 发送)
109
140
  `,
110
141
  parameters: messageToolParameters,
111
142
  execute: async (_toolCallId, args, signal) => {
@@ -123,12 +154,13 @@ export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext):
123
154
  try {
124
155
  const sentFiles = new Set<string>()
125
156
  const sentKeys = new Set<string>()
157
+ const workspaceDir = pluginCtx.workspaceDir
126
158
 
127
159
  if (args.media?.length) {
128
160
  for (const media of args.media) {
129
161
  const filepath = media.file
130
162
  if (!filepath) continue
131
- if (!isSafePath(filepath)) continue
163
+ if (!isSafePath(filepath, workspaceDir)) continue
132
164
  if (!isSafeFile(filepath)) continue
133
165
  const key = pathKey(filepath)
134
166
  if (sentKeys.has(key)) continue
@@ -139,9 +171,9 @@ export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext):
139
171
  }
140
172
  }
141
173
 
142
- const fallbackPaths = extractPaths(args.content)
174
+ const fallbackPaths = extractPaths(args.content, workspaceDir)
143
175
  for (const filepath of fallbackPaths) {
144
- if (!isSafePath(filepath)) continue
176
+ if (!isSafePath(filepath, workspaceDir)) continue
145
177
  if (!isSafeFile(filepath)) continue
146
178
  const key = pathKey(filepath)
147
179
  if (sentKeys.has(key)) continue
package/src/transport.ts CHANGED
@@ -169,7 +169,7 @@ export function sendChunk(text: string, ctx: IMsgParams, chunkIdx: number): bool
169
169
  }
170
170
 
171
171
  export function sendFinal(ctx: IMsgParams, tag: string): boolean {
172
- dcgLogger(` message handling complete state: final tag:${tag}`)
172
+ dcgLogger(` message handling complete state: to=${ctx.sessionId} final tag:${tag}`)
173
173
  return wsSend(ctx, { response: '', state: 'final' })
174
174
  }
175
175
 
@@ -0,0 +1,97 @@
1
+ import path from 'path'
2
+ import fs from 'fs'
3
+ /** @ts-ignore */
4
+ import unzipper from 'unzipper'
5
+ import { pipeline } from 'stream/promises'
6
+ import { decodeZipEntryPath } from './zipPath.js'
7
+
8
+ /**
9
+ * 若且唯若所有条目都在同一顶层目录下(如 GitHub 下载的 repo-name/...),返回该目录名;否则返回 null。
10
+ * 不能再用「第一个多段路径的第一段」推断,否则 ZIP 条目顺序变化时会误判(例如先出现 .github/ 或 src/)。
11
+ */
12
+ export function computeSharedZipRootPrefix(decodedPaths: string[]): string | null {
13
+ const normalized = decodedPaths
14
+ .map((p) => p.replace(/\\/g, '/').replace(/\/+$/, ''))
15
+ .filter((p) => p.length > 0)
16
+
17
+ if (normalized.length === 0) return null
18
+
19
+ const firstSegs = new Set<string>()
20
+ for (const p of normalized) {
21
+ const seg = p.split('/').filter(Boolean)[0]
22
+ if (seg) firstSegs.add(seg)
23
+ }
24
+ if (firstSegs.size !== 1) return null
25
+
26
+ const root = [...firstSegs][0]!
27
+ const prefix = `${root}/`
28
+ for (const p of normalized) {
29
+ if (p !== root && !p.startsWith(prefix)) return null
30
+ }
31
+ return root
32
+ }
33
+
34
+ function assertSafeZipTarget(destDir: string, relativePath: string): string {
35
+ const resolvedPath = path.resolve(destDir, relativePath)
36
+ const resolvedDest = path.resolve(destDir)
37
+ const rel = path.relative(resolvedDest, resolvedPath)
38
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
39
+ throw new Error(`zip 路径越界: ${relativePath}`)
40
+ }
41
+ return resolvedPath
42
+ }
43
+
44
+ type ZipEntry = {
45
+ path: string
46
+ pathBuffer: Buffer
47
+ flags: number
48
+ type: string
49
+ stream: (password?: string) => NodeJS.ReadableStream
50
+ }
51
+
52
+ /**
53
+ * 将已下载的 zip 解压到 destDir;顶层单根目录(若存在)会被剥掉,与原先流式 Parse 行为一致,但根目录由全量路径计算,与条目顺序无关。
54
+ */
55
+ export async function extractZipBufferToDirectory(buf: Buffer, destDir: string): Promise<void> {
56
+ const directory = await unzipper.Open.buffer(buf)
57
+ const files = (await directory.files) as ZipEntry[]
58
+
59
+ const decodedPaths = files.map((entry) =>
60
+ decodeZipEntryPath(entry.pathBuffer, entry.flags ?? 0, entry.path)
61
+ )
62
+ const rootDir = computeSharedZipRootPrefix(decodedPaths)
63
+
64
+ // 与 unzipper 默认 extract 一致:串行读各 entry,避免同一 buffer 上多路解压竞争
65
+ for (let i = 0; i < files.length; i++) {
66
+ const entry = files[i]!
67
+ const entryPath = decodedPaths[i]!
68
+ let newPath = entryPath.replace(/\\/g, '/')
69
+ if (rootDir) {
70
+ if (newPath === rootDir || newPath === `${rootDir}/`) {
71
+ continue
72
+ }
73
+ if (newPath.startsWith(`${rootDir}/`)) {
74
+ newPath = newPath.slice(rootDir.length + 1)
75
+ }
76
+ }
77
+ newPath = newPath.replace(/\/+$/, '')
78
+ if (!newPath) continue
79
+
80
+ const targetPath = assertSafeZipTarget(destDir, newPath)
81
+
82
+ if (entry.type === 'Directory') {
83
+ fs.mkdirSync(targetPath, { recursive: true })
84
+ continue
85
+ }
86
+
87
+ const parentDir = path.dirname(targetPath)
88
+ fs.mkdirSync(parentDir, { recursive: true })
89
+ const writeStream = fs.createWriteStream(targetPath)
90
+ try {
91
+ await pipeline(entry.stream(), writeStream)
92
+ } catch (err) {
93
+ const message = err instanceof Error ? err.message : String(err)
94
+ throw new Error(`解压文件失败 ${entryPath}: ${message}`)
95
+ }
96
+ }
97
+ }