@2en/clawly-plugins 1.26.0-beta.0 → 1.26.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.
@@ -0,0 +1,73 @@
1
+ import {afterEach, beforeEach, describe, expect, mock, test} from 'bun:test'
2
+
3
+ let constructorArgs: {apiKey: string; host: string} | null = null
4
+ let captures: Array<{distinctId: string; event: string; properties?: Record<string, unknown>}> = []
5
+ let shutdownCount = 0
6
+
7
+ mock.module('posthog-node', () => ({
8
+ PostHog: class PostHog {
9
+ constructor(apiKey: string, opts: {host: string}) {
10
+ constructorArgs = {apiKey, host: opts.host}
11
+ }
12
+ capture(payload: {distinctId: string; event: string; properties?: Record<string, unknown>}) {
13
+ captures.push(payload)
14
+ }
15
+ async shutdown() {
16
+ shutdownCount++
17
+ }
18
+ },
19
+ }))
20
+
21
+ const {captureEvent, initPostHog, shutdownPostHog} = await import('./posthog')
22
+
23
+ describe('initPostHog', () => {
24
+ beforeEach(async () => {
25
+ await shutdownPostHog()
26
+ constructorArgs = null
27
+ captures = []
28
+ shutdownCount = 0
29
+ delete process.env.PLUGIN_POSTHOG_API_KEY
30
+ delete process.env.PLUGIN_POSTHOG_HOST
31
+ delete process.env.INSTANCE_ID
32
+ })
33
+
34
+ afterEach(async () => {
35
+ await shutdownPostHog()
36
+ })
37
+
38
+ test('prefers pluginConfig over legacy env vars', () => {
39
+ process.env.PLUGIN_POSTHOG_API_KEY = 'legacy-key'
40
+ process.env.PLUGIN_POSTHOG_HOST = 'https://legacy.posthog.com'
41
+ process.env.INSTANCE_ID = 'legacy-inst'
42
+
43
+ expect(
44
+ initPostHog({
45
+ posthogApiKey: 'cfg-key',
46
+ posthogHost: 'https://cfg.posthog.com',
47
+ instanceId: 'inst-1',
48
+ }),
49
+ ).toBe(true)
50
+
51
+ expect(constructorArgs).toEqual({
52
+ apiKey: 'cfg-key',
53
+ host: 'https://cfg.posthog.com',
54
+ })
55
+ })
56
+
57
+ test('falls back to legacy env vars', () => {
58
+ process.env.PLUGIN_POSTHOG_API_KEY = 'legacy-key'
59
+ process.env.INSTANCE_ID = 'legacy-inst'
60
+
61
+ expect(initPostHog()).toBe(true)
62
+ expect(constructorArgs).toEqual({
63
+ apiKey: 'legacy-key',
64
+ host: 'https://us.i.posthog.com',
65
+ })
66
+ })
67
+
68
+ test('returns false when api key is missing', () => {
69
+ expect(initPostHog({instanceId: 'inst-1'})).toBe(false)
70
+ captureEvent('cron.deleted')
71
+ expect(captures).toHaveLength(0)
72
+ })
73
+ })
@@ -0,0 +1,61 @@
1
+ /**
2
+ * PostHog analytics provider for plugin telemetry.
3
+ *
4
+ * Reads telemetry config provisioned by Fleet via pluginConfig:
5
+ *
6
+ * posthogApiKey=<PostHog project API key>
7
+ * posthogHost=<PostHog ingest URL> (optional, defaults to https://us.i.posthog.com)
8
+ * instanceId=<distinct_id>
9
+ *
10
+ * Falls back to legacy PLUGIN_POSTHOG_* / INSTANCE_ID env vars for backward
11
+ * compatibility and manual debug sessions.
12
+ *
13
+ * The distinct_id for all events is the provisioned instanceId (one sprite = one user).
14
+ */
15
+
16
+ import {PostHog} from 'posthog-node'
17
+ import pkg from '../package.json'
18
+ import {readTelemetryPluginConfig} from './telemetry-config'
19
+
20
+ let client: PostHog | null = null
21
+ let distinctId: string | null = null
22
+ let superProperties: Record<string, unknown> = {}
23
+
24
+ export function initPostHog(pluginConfig?: Record<string, unknown>): boolean {
25
+ const cfg = readTelemetryPluginConfig(pluginConfig)
26
+ const apiKey = cfg.posthogApiKey ?? process.env.PLUGIN_POSTHOG_API_KEY
27
+ if (!apiKey) return false
28
+ if (client) return true
29
+
30
+ const host = cfg.posthogHost ?? process.env.PLUGIN_POSTHOG_HOST ?? 'https://us.i.posthog.com'
31
+ distinctId = cfg.instanceId ?? process.env.INSTANCE_ID ?? null
32
+ if (!distinctId) return false
33
+
34
+ client = new PostHog(apiKey, {host})
35
+ superProperties = {plugin_version: pkg.version}
36
+ return true
37
+ }
38
+
39
+ /** Set the OpenClaw version (read from api.config after gateway init). */
40
+ export function setOpenClawVersion(version: string): void {
41
+ superProperties.openclaw_version = version
42
+ }
43
+
44
+ const EVENT_PREFIX = 'plugin.'
45
+
46
+ export function captureEvent(event: string, properties?: Record<string, unknown>): void {
47
+ if (!client || !distinctId) return
48
+ client.capture({
49
+ distinctId,
50
+ event: `${EVENT_PREFIX}${event}`,
51
+ properties: {...superProperties, ...properties},
52
+ })
53
+ }
54
+
55
+ export async function shutdownPostHog(): Promise<void> {
56
+ if (client) {
57
+ await client.shutdown()
58
+ client = null
59
+ distinctId = null
60
+ }
61
+ }
@@ -0,0 +1,58 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {readTelemetryPluginConfig} from './telemetry-config'
3
+
4
+ describe('readTelemetryPluginConfig', () => {
5
+ test('reads telemetry fields from pluginConfig', () => {
6
+ expect(
7
+ readTelemetryPluginConfig({
8
+ instanceId: 'inst-1',
9
+ otelToken: 'otel-token',
10
+ otelDataset: 'clawly-otel-logs-dev',
11
+ posthogApiKey: 'ph-key',
12
+ posthogHost: 'https://us.i.posthog.com',
13
+ }),
14
+ ).toEqual({
15
+ instanceId: 'inst-1',
16
+ otelToken: 'otel-token',
17
+ otelDataset: 'clawly-otel-logs-dev',
18
+ posthogApiKey: 'ph-key',
19
+ posthogHost: 'https://us.i.posthog.com',
20
+ })
21
+ })
22
+
23
+ test('trims surrounding whitespace from string values', () => {
24
+ expect(
25
+ readTelemetryPluginConfig({
26
+ instanceId: ' inst-1 ',
27
+ otelToken: ' otel-token ',
28
+ otelDataset: ' clawly-otel-logs-dev ',
29
+ posthogApiKey: ' ph-key ',
30
+ posthogHost: ' https://us.i.posthog.com ',
31
+ }),
32
+ ).toEqual({
33
+ instanceId: 'inst-1',
34
+ otelToken: 'otel-token',
35
+ otelDataset: 'clawly-otel-logs-dev',
36
+ posthogApiKey: 'ph-key',
37
+ posthogHost: 'https://us.i.posthog.com',
38
+ })
39
+ })
40
+
41
+ test('drops empty and non-string values', () => {
42
+ expect(
43
+ readTelemetryPluginConfig({
44
+ instanceId: ' ',
45
+ otelToken: null,
46
+ otelDataset: 1,
47
+ posthogApiKey: '',
48
+ posthogHost: undefined,
49
+ }),
50
+ ).toEqual({
51
+ instanceId: undefined,
52
+ otelToken: undefined,
53
+ otelDataset: undefined,
54
+ posthogApiKey: undefined,
55
+ posthogHost: undefined,
56
+ })
57
+ })
58
+ })
@@ -0,0 +1,27 @@
1
+ export interface TelemetryPluginConfig {
2
+ instanceId?: string
3
+ otelToken?: string
4
+ otelDataset?: string
5
+ posthogApiKey?: string
6
+ posthogHost?: string
7
+ }
8
+
9
+ function readString(value: unknown): string | undefined {
10
+ if (typeof value !== 'string') return undefined
11
+ const trimmed = value.trim()
12
+ return trimmed.length > 0 ? trimmed : undefined
13
+ }
14
+
15
+ export function readTelemetryPluginConfig(
16
+ pluginConfig?: Record<string, unknown> | null,
17
+ ): TelemetryPluginConfig {
18
+ const cfg = pluginConfig ?? {}
19
+
20
+ return {
21
+ instanceId: readString(cfg.instanceId),
22
+ otelToken: readString(cfg.otelToken),
23
+ otelDataset: readString(cfg.otelDataset),
24
+ posthogApiKey: readString(cfg.posthogApiKey),
25
+ posthogHost: readString(cfg.posthogHost),
26
+ }
27
+ }
package/index.ts CHANGED
@@ -16,16 +16,22 @@
16
16
  * Agent tools:
17
17
  * - clawly_is_user_online — check if user's device is connected
18
18
  * - clawly_send_app_push — send a push notification to user's device
19
+ * - clawly_send_image — send an image to the user (URL download or local file)
19
20
  * - clawly_send_message — send a message to user via main session agent (supports role: user|assistant)
20
21
  *
21
22
  * Commands:
22
23
  * - /clawly_echo — echo text back without LLM
23
24
  *
25
+ * HTTP routes:
26
+ * - GET /clawly/file/outbound — serve files (hash lookup first, then direct path with allowlist)
27
+ *
24
28
  * Hooks:
25
29
  * - before_message_write — restores original /skill command in user messages (undoes gateway rewrite)
26
30
  * - tool_result_persist — copies TTS audio to persistent outbound directory
27
31
  * - before_tool_call — enforces delivery fields on cron.create
28
32
  * - agent_end — sends push notification when client is offline; injects cron results into main session
33
+ * - after_tool_call — cron telemetry: captures cron job creation/deletion
34
+ * - agent_end (pri 100) — cron telemetry: captures cron execution outcomes with delivery/push flags
29
35
  * - gateway_start — auto-approves device pairing for Clawly mobile clients (clientId: openclaw-ios)
30
36
  * - gateway_start — registers auto-update cron job (0 3 * * *) for clawly-plugins
31
37
  */
@@ -76,6 +82,6 @@ export default {
76
82
  registerCalendar(api, gw)
77
83
  }
78
84
 
79
- api.logger.info(`Loaded ${api.id} plugin.`)
85
+ api.logger.info(`Loaded ${api.id} plugin. (debug-instrumented build)`)
80
86
  },
81
87
  }
@@ -1,12 +1,10 @@
1
1
  /**
2
- * On plugin init, patches openclaw.json to add the `clawly-model-gateway`
3
- * model provider entry. Credentials come from pluginConfig; the model list
4
- * is derived from `agents.defaults.model`
5
- * already present in the config.
2
+ * Reconciles the `clawly-model-gateway` provider in openclaw.json from
3
+ * pluginConfig inputs and the current runtime-facing agent defaults.
6
4
  *
7
- * This runs synchronously during plugin registration (before gateway_start).
8
- * OpenClaw loads the config file once at startup, so writing before the
9
- * gateway fully starts ensures the provider is active on first boot.
5
+ * This is a runtime reconcile path, not a provision/bootstrap contract.
6
+ * The first-boot correctness of gateway startup should not rely on this file
7
+ * write winning a race against OpenClaw's internal startup snapshot timing.
10
8
  */
11
9
 
12
10
  import fs from 'node:fs'
@@ -50,13 +50,18 @@
50
50
  "skillGatewayToken": { "type": "string" },
51
51
  "modelGatewayBaseUrl": { "type": "string" },
52
52
  "modelGatewayToken": { "type": "string" },
53
+ "instanceId": { "type": "string" },
53
54
  "agentId": { "type": "string" },
54
55
  "agentName": { "type": "string" },
55
56
  "workspaceDir": { "type": "string" },
56
57
  "defaultModel": { "type": "string" },
57
58
  "defaultImageModel": { "type": "string" },
58
59
  "elevenlabsApiKey": { "type": "string" },
59
- "elevenlabsVoiceId": { "type": "string" }
60
+ "elevenlabsVoiceId": { "type": "string" },
61
+ "otelToken": { "type": "string" },
62
+ "otelDataset": { "type": "string" },
63
+ "posthogApiKey": { "type": "string" },
64
+ "posthogHost": { "type": "string" }
60
65
  },
61
66
  "required": []
62
67
  }
package/outbound.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  /**
2
- * TTS outbound persistencecopies ephemeral TTS audio to a persistent directory
3
- * so mobile can fetch it after the temp files are cleaned up.
2
+ * Outbound file servingserves files to the mobile client.
3
+ *
4
+ * Resolution order (HTTP route + gateway method):
5
+ * 1. Hash-mapped file: ~/.openclaw/clawly/outbound/<sha256(path)>.<ext>
6
+ * 2. Direct path: if the raw path is under an allowlisted directory
4
7
  *
5
8
  * Hook: tool_result_persist → copies TTS audioPath to ~/.openclaw/clawly/outbound/<hash>.<ext>
6
- * Method: clawly.file.getOutbound → reads outbound file by original-path hash
9
+ * Method: clawly.file.getOutbound → reads outbound file by original-path hash or direct path
7
10
  */
8
11
 
9
12
  import crypto from 'node:crypto'
@@ -74,6 +77,8 @@ export function registerOutboundHook(api: PluginApi) {
74
77
  // ── Gateway method: clawly.file.getOutbound ─────────────────────────
75
78
 
76
79
  export function registerOutboundMethods(api: PluginApi) {
80
+ const stateDir = api.runtime.state.resolveStateDir()
81
+
77
82
  api.registerGatewayMethod('clawly.file.getOutbound', async ({params, respond}) => {
78
83
  const rawPath = typeof params.path === 'string' ? params.path.trim() : ''
79
84
 
@@ -82,30 +87,18 @@ export function registerOutboundMethods(api: PluginApi) {
82
87
  return
83
88
  }
84
89
 
85
- const dest = outboundFilePath(rawPath)
86
-
87
- if (!(await fileExists(dest))) {
88
- // Fallback: TTS writes directly to /tmp/openclaw/ and the tool_result_persist hook
89
- // only fires for agent tool calls, not tts.convert RPC calls. Read the source file
90
- // directly if it lives in the known-safe TTS temp directory.
91
- if (rawPath.startsWith('/tmp/openclaw/') && (await fileExists(rawPath))) {
92
- const buffer = await fsp.readFile(rawPath)
93
- api.logger.info(
94
- `clawly.file.getOutbound: served from source ${rawPath} (${buffer.length} bytes)`,
95
- )
96
- respond(true, {base64: buffer.toString('base64')})
97
- return
98
- }
99
- api.logger.warn(`outbound: file not found: ${rawPath} ${dest}`)
90
+ const resolved = await resolveOutboundFile(rawPath, stateDir)
91
+ if (!resolved) {
92
+ api.logger.warn(`outbound: file not found: ${rawPath}`)
100
93
  respond(false, undefined, {code: 'not_found', message: 'outbound file not found'})
101
94
  return
102
95
  }
103
96
 
104
- const buffer = await fsp.readFile(dest)
105
- const base64 = buffer.toString('base64')
106
-
107
- api.logger.info(`clawly.file.getOutbound: served ${rawPath} (${buffer.length} bytes)`)
108
- respond(true, {base64})
97
+ const buffer = await fsp.readFile(resolved)
98
+ api.logger.info(
99
+ `clawly.file.getOutbound: served ${rawPath} -> ${resolved} (${buffer.length} bytes)`,
100
+ )
101
+ respond(true, {base64: buffer.toString('base64')})
109
102
  })
110
103
  }
111
104
 
@@ -119,6 +112,47 @@ const MIME: Record<string, string> = {
119
112
  '.aac': 'audio/aac',
120
113
  '.flac': 'audio/flac',
121
114
  '.webm': 'audio/webm',
115
+ '.jpg': 'image/jpeg',
116
+ '.jpeg': 'image/jpeg',
117
+ '.png': 'image/png',
118
+ '.gif': 'image/gif',
119
+ '.webp': 'image/webp',
120
+ '.bmp': 'image/bmp',
121
+ '.heic': 'image/heic',
122
+ '.avif': 'image/avif',
123
+ '.ico': 'image/x-icon',
124
+ }
125
+
126
+ /** Directories from which direct-path serving is allowed (no hash required). */
127
+ let allowedRoots: string[] | null = null
128
+
129
+ function getAllowedRoots(stateDir?: string): string[] {
130
+ if (!allowedRoots) {
131
+ const roots = [OUTBOUND_DIR + path.sep, '/tmp/']
132
+ if (stateDir) roots.push(stateDir + path.sep)
133
+ allowedRoots = roots
134
+ }
135
+ return allowedRoots
136
+ }
137
+
138
+ function isDirectPathAllowed(resolved: string, stateDir?: string): boolean {
139
+ return getAllowedRoots(stateDir).some((root) => resolved.startsWith(root))
140
+ }
141
+
142
+ /**
143
+ * Resolve which file to serve: hash-mapped first, then direct path if allowlisted.
144
+ * Returns the resolved file path or null.
145
+ */
146
+ async function resolveOutboundFile(rawPath: string, stateDir?: string): Promise<string | null> {
147
+ // 1. Hash-mapped file
148
+ const hashed = outboundFilePath(rawPath)
149
+ if (await fileExists(hashed)) return hashed
150
+
151
+ // 2. Direct path (allowlisted directories only)
152
+ const resolved = path.resolve(rawPath)
153
+ if (isDirectPathAllowed(resolved, stateDir) && (await fileExists(resolved))) return resolved
154
+
155
+ return null
122
156
  }
123
157
 
124
158
  function sendJson(res: ServerResponse, status: number, body: Record<string, unknown>) {
@@ -127,6 +161,8 @@ function sendJson(res: ServerResponse, status: number, body: Record<string, unkn
127
161
  }
128
162
 
129
163
  export function registerOutboundHttpRoute(api: PluginApi) {
164
+ const stateDir = api.runtime.state.resolveStateDir()
165
+
130
166
  api.registerHttpRoute({
131
167
  path: '/clawly/file/outbound',
132
168
  handler: async (_req: IncomingMessage, res: ServerResponse) => {
@@ -138,17 +174,16 @@ export function registerOutboundHttpRoute(api: PluginApi) {
138
174
  return
139
175
  }
140
176
 
141
- const dest = outboundFilePath(rawPath)
142
-
143
- if (!(await fileExists(dest))) {
177
+ const resolved = await resolveOutboundFile(rawPath, stateDir)
178
+ if (!resolved) {
144
179
  api.logger.warn(`outbound http: file not found: ${rawPath}`)
145
180
  sendJson(res, 404, {error: 'outbound file not found'})
146
181
  return
147
182
  }
148
183
 
149
- const ext = path.extname(dest).toLowerCase()
184
+ const ext = path.extname(resolved).toLowerCase()
150
185
  const contentType = MIME[ext] ?? 'application/octet-stream'
151
- const stat = await fsp.stat(dest)
186
+ const stat = await fsp.stat(resolved)
152
187
  const total = stat.size
153
188
  const rangeHeader = _req.headers.range
154
189
 
@@ -158,7 +193,7 @@ export function registerOutboundHttpRoute(api: PluginApi) {
158
193
  const start = Number(match[1])
159
194
  const end = match[2] ? Number(match[2]) : total - 1
160
195
  const chunkSize = end - start + 1
161
- const stream = fs.createReadStream(dest, {start, end})
196
+ const stream = fs.createReadStream(resolved, {start, end})
162
197
 
163
198
  res.writeHead(206, {
164
199
  'Content-Type': contentType,
@@ -168,13 +203,13 @@ export function registerOutboundHttpRoute(api: PluginApi) {
168
203
  })
169
204
  stream.pipe(res)
170
205
  api.logger.info(
171
- `outbound http: served ${rawPath} -> ${dest} range ${start}-${end}/${total}`,
206
+ `outbound http: served ${rawPath} -> ${resolved} range ${start}-${end}/${total}`,
172
207
  )
173
208
  return
174
209
  }
175
210
  }
176
211
 
177
- const buffer = await fsp.readFile(dest)
212
+ const buffer = await fsp.readFile(resolved)
178
213
  res.writeHead(200, {
179
214
  'Content-Type': contentType,
180
215
  'Content-Length': total,
@@ -182,7 +217,7 @@ export function registerOutboundHttpRoute(api: PluginApi) {
182
217
  })
183
218
  res.end(buffer)
184
219
 
185
- api.logger.info(`outbound http: served ${rawPath} -> ${dest} (${total} bytes)`)
220
+ api.logger.info(`outbound http: served ${rawPath} -> ${resolved} (${total} bytes)`)
186
221
  },
187
222
  })
188
223
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.26.0-beta.0",
3
+ "version": "1.26.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -9,6 +9,12 @@
9
9
  "directory": "plugins/clawly-plugins"
10
10
  },
11
11
  "dependencies": {
12
+ "@opentelemetry/api-logs": "^0.57.0",
13
+ "@opentelemetry/exporter-logs-otlp-http": "^0.57.0",
14
+ "@opentelemetry/resources": "^1.30.0",
15
+ "@opentelemetry/sdk-logs": "^0.57.0",
16
+ "posthog-node": "^5.28.0",
17
+ "file-type": "^21.3.0",
12
18
  "zx": "npm:zx@8.8.5-lite"
13
19
  },
14
20
  "files": [