@2en/clawly-plugins 1.28.1 → 1.29.0-beta.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/gateway/cron-telemetry.ts +1 -1
- package/gateway/index.ts +4 -2
- package/gateway/message-log.ts +156 -0
- package/gateway/node-dangerous-allowlist.ts +81 -0
- package/gateway/otel.test.ts +9 -1
- package/gateway/otel.ts +9 -4
- package/gateway/plugins.ts +16 -373
- package/gateway/presence.ts +1 -4
- package/gateway/telemetry-config.test.ts +3 -0
- package/gateway/telemetry-config.ts +2 -0
- package/index.ts +3 -0
- package/lib/manualPluginInstall.test.ts +121 -0
- package/lib/manualPluginInstall.ts +148 -0
- package/openclaw.plugin.json +1 -0
- package/outbound.ts +12 -0
- package/package.json +3 -2
- package/skills/read-office-file/SKILL.md +41 -0
- package/tools/clawly-msg-break.ts +32 -0
- package/tools/index.ts +2 -0
- package/gateway/node-browser-allowlist.ts +0 -62
- package/gateway/plugins.test.ts +0 -472
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual plugin install that bypasses OpenClaw's hardened extraction pipeline.
|
|
3
|
+
*
|
|
4
|
+
* OpenClaw ≥3.12 uses `copyFileWithinRoot` → `runPinnedWriteHelper` (python3)
|
|
5
|
+
* during tar extraction, which fails on sprite overlay/JuiceFS filesystems
|
|
6
|
+
* with `SafeOpenError: path is not a regular file under root`.
|
|
7
|
+
*
|
|
8
|
+
* Supports both npm spec and local absolute path. Install uses rsync:
|
|
9
|
+
* `rsync -a --delete` syncs source into targetDir — overwrites changed files,
|
|
10
|
+
* adds new files, and removes stale files not in source. The target directory
|
|
11
|
+
* is never moved or deleted, so the plugin stays functional during the sync.
|
|
12
|
+
* Staging artifacts (tgz, pkg) are cleaned up in finally.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from 'node:assert'
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import {$} from 'zx'
|
|
19
|
+
|
|
20
|
+
const PLUGIN_SENTINEL = 'openclaw.plugin.json'
|
|
21
|
+
|
|
22
|
+
interface Logger {
|
|
23
|
+
info?: (msg: string) => void
|
|
24
|
+
warn?: (msg: string) => void
|
|
25
|
+
error?: (msg: string) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function withRetry<T>(fn: () => Promise<T>, maxAttempts: number): Promise<T> {
|
|
29
|
+
let lastErr: unknown
|
|
30
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
31
|
+
try {
|
|
32
|
+
return await fn()
|
|
33
|
+
} catch (err) {
|
|
34
|
+
lastErr = err
|
|
35
|
+
if (attempt < maxAttempts) {
|
|
36
|
+
await new Promise((r) => setTimeout(r, 500 * attempt))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw lastErr
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function manualPluginInstall(params: {
|
|
44
|
+
spec: string
|
|
45
|
+
targetDir: string
|
|
46
|
+
stagingDir: string
|
|
47
|
+
logger?: Logger
|
|
48
|
+
}): Promise<string> {
|
|
49
|
+
const {spec, targetDir, stagingDir, logger} = params
|
|
50
|
+
const isNpm = !path.isAbsolute(spec)
|
|
51
|
+
const pkgDir = path.join(stagingDir, 'pkg')
|
|
52
|
+
|
|
53
|
+
// 1. Ensure targetDir exists
|
|
54
|
+
fs.mkdirSync(targetDir, {recursive: true})
|
|
55
|
+
|
|
56
|
+
// 2. Create stagingDir
|
|
57
|
+
fs.mkdirSync(stagingDir, {recursive: true})
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
let sourceDir: string
|
|
61
|
+
if (isNpm) {
|
|
62
|
+
// 3a. npm pack → stagingDir (retry 3x)
|
|
63
|
+
logger?.info?.(`downloading ${spec}...`)
|
|
64
|
+
const packResult = await withRetry(async () => {
|
|
65
|
+
const result = await $`npm pack ${spec} --pack-destination ${stagingDir}`
|
|
66
|
+
return result
|
|
67
|
+
}, 3)
|
|
68
|
+
const lastLine = packResult.stdout.trim().split('\n').pop() ?? ''
|
|
69
|
+
const tgzName = path.basename(lastLine)
|
|
70
|
+
if (!tgzName.endsWith('.tgz')) throw new Error(`unexpected npm pack output: ${lastLine}`)
|
|
71
|
+
const tgzPath = path.join(stagingDir, tgzName)
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
// 3b. Extract to stagingDir/pkg
|
|
75
|
+
fs.mkdirSync(pkgDir, {recursive: true})
|
|
76
|
+
logger?.info?.(`extracting to ${pkgDir}...`)
|
|
77
|
+
await $`tar xzf ${tgzPath} --strip-components=1 -C ${pkgDir}`
|
|
78
|
+
} finally {
|
|
79
|
+
try {
|
|
80
|
+
fs.unlinkSync(tgzPath)
|
|
81
|
+
} catch {}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 3c. npm install in pkg (retry 3x)
|
|
85
|
+
const pkgPath = path.join(pkgDir, 'package.json')
|
|
86
|
+
if (fs.existsSync(pkgPath)) {
|
|
87
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
|
|
88
|
+
if (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) {
|
|
89
|
+
logger?.info?.('installing dependencies...')
|
|
90
|
+
await withRetry(
|
|
91
|
+
() => $({cwd: pkgDir})`npm install --omit=dev --omit=peer --ignore-scripts`,
|
|
92
|
+
3,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
sourceDir = pkgDir
|
|
97
|
+
} else {
|
|
98
|
+
sourceDir = spec
|
|
99
|
+
if (!fs.existsSync(sourceDir)) {
|
|
100
|
+
throw new Error(`local spec path does not exist: ${sourceDir}`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
assert(
|
|
105
|
+
fs.existsSync(path.join(sourceDir, 'package.json')),
|
|
106
|
+
`plugin source missing package.json: ${sourceDir}`,
|
|
107
|
+
)
|
|
108
|
+
assert(
|
|
109
|
+
fs.existsSync(path.join(sourceDir, PLUGIN_SENTINEL)),
|
|
110
|
+
`plugin source missing openclaw.plugin.json: ${sourceDir}`,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
// 4. rsync source → targetDir (overlay + delete stale files)
|
|
114
|
+
// npm pack output is already clean; local path needs dev file exclusions
|
|
115
|
+
logger?.info?.(`syncing to ${targetDir}...`)
|
|
116
|
+
if (isNpm) {
|
|
117
|
+
await $`rsync -a --delete --checksum ${sourceDir}/ ${targetDir}/`
|
|
118
|
+
} else {
|
|
119
|
+
await $`rsync -a --delete --checksum --exclude=node_modules --exclude=.git ${sourceDir}/ ${targetDir}/`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 5. Verify sentinel
|
|
123
|
+
if (!fs.existsSync(path.join(targetDir, PLUGIN_SENTINEL))) {
|
|
124
|
+
throw new Error(`plugin files missing after install (${PLUGIN_SENTINEL} not found)`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 6. Local path: install production deps (rsync excludes node_modules)
|
|
128
|
+
if (!isNpm) {
|
|
129
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(targetDir, 'package.json'), 'utf-8'))
|
|
130
|
+
if (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) {
|
|
131
|
+
logger?.info?.('installing dependencies...')
|
|
132
|
+
await withRetry(
|
|
133
|
+
() => $({cwd: targetDir})`npm install --omit=dev --omit=peer --ignore-scripts`,
|
|
134
|
+
3,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return `Installed ${spec} to ${targetDir}`
|
|
140
|
+
} finally {
|
|
141
|
+
// 6. Remove stagingDir (tgz + pkg artifacts)
|
|
142
|
+
try {
|
|
143
|
+
if (fs.existsSync(stagingDir)) {
|
|
144
|
+
fs.rmSync(stagingDir, {recursive: true, force: true})
|
|
145
|
+
}
|
|
146
|
+
} catch {}
|
|
147
|
+
}
|
|
148
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
"name": "Clawly Plugins",
|
|
4
4
|
"description": "Clawly utility RPC methods (clawly.*): file access, presence, push notifications, agent messaging, memory browser, and ClawHub CLI bridge.",
|
|
5
5
|
"version": "0.3.0",
|
|
6
|
+
"skills": ["skills/read-office-file"],
|
|
6
7
|
"uiHints": {
|
|
7
8
|
"memoryDir": {
|
|
8
9
|
"label": "Memory directory",
|
package/outbound.ts
CHANGED
|
@@ -190,6 +190,18 @@ export function registerOutboundHttpRoute(api: PluginApi) {
|
|
|
190
190
|
path: '/clawly/file/outbound',
|
|
191
191
|
auth: 'plugin',
|
|
192
192
|
handler: async (_req: IncomingMessage, res: ServerResponse) => {
|
|
193
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
194
|
+
|
|
195
|
+
// Handle CORS preflight
|
|
196
|
+
if (_req.method === 'OPTIONS') {
|
|
197
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')
|
|
198
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
|
199
|
+
res.setHeader('Access-Control-Max-Age', '86400')
|
|
200
|
+
res.statusCode = 204
|
|
201
|
+
res.end()
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
193
205
|
const url = new URL(_req.url ?? '/', 'http://localhost')
|
|
194
206
|
if (!guardHttpAuth(api, _req, res, url)) return
|
|
195
207
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@2en/clawly-plugins",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.29.0-beta.1",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"resolve-gateway-credentials.ts",
|
|
37
37
|
"skill-command-restore.ts",
|
|
38
38
|
"openclaw.plugin.json",
|
|
39
|
-
"internal"
|
|
39
|
+
"internal",
|
|
40
|
+
"skills"
|
|
40
41
|
],
|
|
41
42
|
"publishConfig": {
|
|
42
43
|
"access": "public"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: read-office-file
|
|
3
|
+
description: Convert Word, Excel, or PowerPoint files to Markdown using MarkItDown.
|
|
4
|
+
metadata: { "openclaw": { "emoji": "📄" } }
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Read Office File
|
|
8
|
+
|
|
9
|
+
Convert Word (.docx), Excel (.xlsx), or PowerPoint (.pptx) files to Markdown using [MarkItDown](https://github.com/microsoft/markitdown).
|
|
10
|
+
|
|
11
|
+
## When to use
|
|
12
|
+
|
|
13
|
+
- User asks to read, summarize, or extract content from a `.docx`, `.xlsx`, or `.pptx` file.
|
|
14
|
+
- User shares or references an Office file path.
|
|
15
|
+
|
|
16
|
+
## Steps
|
|
17
|
+
|
|
18
|
+
1. Convert the file to Markdown (use the extra matching the file type):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# .docx
|
|
22
|
+
uvx 'markitdown[docx]' <file_path>
|
|
23
|
+
|
|
24
|
+
# .xlsx
|
|
25
|
+
uvx 'markitdown[xlsx]' <file_path>
|
|
26
|
+
|
|
27
|
+
# .pptx
|
|
28
|
+
uvx 'markitdown[pptx]' <file_path>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
2. Read the Markdown output and respond to the user's request.
|
|
32
|
+
|
|
33
|
+
## Supported formats
|
|
34
|
+
|
|
35
|
+
| Format | Extensions |
|
|
36
|
+
|---|---|
|
|
37
|
+
| Word | `.docx` |
|
|
38
|
+
| Excel | `.xlsx` |
|
|
39
|
+
| PowerPoint | `.pptx` |
|
|
40
|
+
|
|
41
|
+
Legacy formats (`.doc`, `.xls`, `.ppt`) are **not supported**. Ask the user to convert to the modern format first.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent tool: clawly_msg_break — split the current response into multiple messages.
|
|
3
|
+
*
|
|
4
|
+
* When called, this tool acts as a structural boundary: the text before the
|
|
5
|
+
* tool call becomes one assistant message, and the text after becomes a new one.
|
|
6
|
+
* The client relies on the existing "text reset" mechanism (post-tool-call text
|
|
7
|
+
* detection) to create the split — the tool itself is a no-op.
|
|
8
|
+
*
|
|
9
|
+
* This is preferred over in-band separator tokens (e.g. [MSG_BREAK]) because
|
|
10
|
+
* tool calls are structured and never leak to other channels (Telegram, etc.).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {PluginApi} from '../types'
|
|
14
|
+
|
|
15
|
+
const TOOL_NAME = 'clawly_msg_break'
|
|
16
|
+
|
|
17
|
+
export function registerMsgBreakTool(api: PluginApi) {
|
|
18
|
+
api.registerTool({
|
|
19
|
+
name: TOOL_NAME,
|
|
20
|
+
description:
|
|
21
|
+
'Split the current response into multiple messages. Call this between distinct thoughts or topics to send them as separate message bubbles. The text you wrote before this call becomes one message; continue writing after the call to start a new message.',
|
|
22
|
+
parameters: {
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {},
|
|
25
|
+
},
|
|
26
|
+
async execute() {
|
|
27
|
+
return {content: [{type: 'text', text: JSON.stringify({ok: true})}]}
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
api.logger.info(`tool: registered ${TOOL_NAME} agent tool`)
|
|
32
|
+
}
|
package/tools/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {PluginApi} from '../types'
|
|
2
2
|
import {registerCalendarTools} from './clawly-calendar'
|
|
3
3
|
import {registerIsUserOnlineTool} from './clawly-is-user-online'
|
|
4
|
+
import {registerMsgBreakTool} from './clawly-msg-break'
|
|
4
5
|
import {registerDeepSearchTool, registerGrokSearchTool, registerSearchTool} from './clawly-search'
|
|
5
6
|
import {registerSendAppPushTool} from './clawly-send-app-push'
|
|
6
7
|
import {registerSendImageTool} from './clawly-send-image'
|
|
@@ -9,6 +10,7 @@ import {registerSendMessageTool} from './clawly-send-message'
|
|
|
9
10
|
export function registerTools(api: PluginApi) {
|
|
10
11
|
registerCalendarTools(api)
|
|
11
12
|
registerIsUserOnlineTool(api)
|
|
13
|
+
registerMsgBreakTool(api)
|
|
12
14
|
registerSearchTool(api)
|
|
13
15
|
registerDeepSearchTool(api)
|
|
14
16
|
registerGrokSearchTool(api)
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ensures browser.* node commands are in gateway.nodes.allowCommands
|
|
3
|
-
* so the AI agent can invoke them on connected Clawly Mac nodes.
|
|
4
|
-
*
|
|
5
|
-
* Runs on gateway_start. Writes directly to openclaw.json via
|
|
6
|
-
* writeOpenclawConfig (no restart triggered — gateway.* changes are
|
|
7
|
-
* classified as "none" by the config watcher).
|
|
8
|
-
*/
|
|
9
|
-
import path from 'node:path'
|
|
10
|
-
|
|
11
|
-
import type {PluginApi} from '../types'
|
|
12
|
-
import {readOpenclawConfig, writeOpenclawConfig} from '../model-gateway-setup'
|
|
13
|
-
|
|
14
|
-
const BROWSER_COMMANDS = [
|
|
15
|
-
'browser.proxy',
|
|
16
|
-
'browser.navigate',
|
|
17
|
-
'browser.click',
|
|
18
|
-
'browser.type',
|
|
19
|
-
'browser.screenshot',
|
|
20
|
-
'browser.read',
|
|
21
|
-
'browser.tabs',
|
|
22
|
-
'browser.back',
|
|
23
|
-
'browser.scroll',
|
|
24
|
-
'browser.evaluate',
|
|
25
|
-
]
|
|
26
|
-
|
|
27
|
-
export function registerNodeBrowserAllowlist(api: PluginApi) {
|
|
28
|
-
api.on('gateway_start', async () => {
|
|
29
|
-
let stateDir: string
|
|
30
|
-
try {
|
|
31
|
-
stateDir = api.runtime.state.resolveStateDir()
|
|
32
|
-
} catch {
|
|
33
|
-
api.logger.warn('node-browser-allowlist: cannot resolve state dir, skipping')
|
|
34
|
-
return
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const configPath = path.join(stateDir, 'openclaw.json')
|
|
38
|
-
const config = readOpenclawConfig(configPath)
|
|
39
|
-
|
|
40
|
-
// Ensure gateway.nodes.allowCommands includes browser.* commands
|
|
41
|
-
const gateway = (config.gateway as Record<string, unknown>) ?? {}
|
|
42
|
-
const nodes = (gateway.nodes as Record<string, unknown>) ?? {}
|
|
43
|
-
const existing: string[] = Array.isArray(nodes.allowCommands)
|
|
44
|
-
? (nodes.allowCommands as string[])
|
|
45
|
-
: []
|
|
46
|
-
|
|
47
|
-
const existingSet = new Set(existing)
|
|
48
|
-
const toAdd = BROWSER_COMMANDS.filter((cmd) => !existingSet.has(cmd))
|
|
49
|
-
|
|
50
|
-
if (toAdd.length === 0) {
|
|
51
|
-
api.logger.info('node-browser-allowlist: browser commands already allowlisted')
|
|
52
|
-
return
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
nodes.allowCommands = [...existing, ...toAdd]
|
|
56
|
-
gateway.nodes = nodes
|
|
57
|
-
config.gateway = gateway
|
|
58
|
-
|
|
59
|
-
writeOpenclawConfig(configPath, config)
|
|
60
|
-
api.logger.info(`node-browser-allowlist: added ${toAdd.length} browser commands to allowlist`)
|
|
61
|
-
})
|
|
62
|
-
}
|