@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
package/gateway/plugins.test.ts
DELETED
|
@@ -1,472 +0,0 @@
|
|
|
1
|
-
import {afterEach, beforeEach, describe, expect, test} from 'bun:test'
|
|
2
|
-
import fs from 'node:fs'
|
|
3
|
-
import os from 'node:os'
|
|
4
|
-
import path from 'node:path'
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
withPluginBackup,
|
|
8
|
-
recoverOrphanedBackup,
|
|
9
|
-
cleanOrphanedStagingDirs,
|
|
10
|
-
BACKUP_DIR_SUFFIX,
|
|
11
|
-
LEGACY_BACKUP_ZIP_SUFFIX,
|
|
12
|
-
STAGING_DIR_PREFIX,
|
|
13
|
-
} from './plugins'
|
|
14
|
-
import type {PluginApi} from '../types'
|
|
15
|
-
|
|
16
|
-
const PLUGIN_ID = 'test-plugin'
|
|
17
|
-
const SENTINEL = 'openclaw.plugin.json'
|
|
18
|
-
|
|
19
|
-
function makeTmpDir(): string {
|
|
20
|
-
return fs.mkdtempSync(path.join(os.tmpdir(), 'clawly-plugins-test-'))
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function makeApi(stateDir: string): PluginApi {
|
|
24
|
-
const logs: string[] = []
|
|
25
|
-
return {
|
|
26
|
-
id: 'test',
|
|
27
|
-
name: 'test',
|
|
28
|
-
runtime: {state: {resolveStateDir: () => stateDir}},
|
|
29
|
-
logger: {
|
|
30
|
-
info: (msg: string) => logs.push(`info: ${msg}`),
|
|
31
|
-
warn: (msg: string) => logs.push(`warn: ${msg}`),
|
|
32
|
-
error: (msg: string) => logs.push(`error: ${msg}`),
|
|
33
|
-
},
|
|
34
|
-
registerGatewayMethod: () => {},
|
|
35
|
-
registerTool: () => {},
|
|
36
|
-
registerCommand: () => {},
|
|
37
|
-
registerChannel: () => {},
|
|
38
|
-
registerHttpRoute: () => {},
|
|
39
|
-
on: () => {},
|
|
40
|
-
_logs: logs,
|
|
41
|
-
} as unknown as PluginApi & {_logs: string[]}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function setupPluginDir(stateDir: string): string {
|
|
45
|
-
const extensionDir = path.join(stateDir, 'extensions', PLUGIN_ID)
|
|
46
|
-
fs.mkdirSync(extensionDir, {recursive: true})
|
|
47
|
-
fs.writeFileSync(path.join(extensionDir, SENTINEL), '{}')
|
|
48
|
-
fs.writeFileSync(path.join(extensionDir, 'package.json'), '{"version":"1.0.0"}')
|
|
49
|
-
return extensionDir
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Simulate openclaw plugins install writing to a staging dir */
|
|
53
|
-
function simulateStagedInstall(stagingDir: string, pluginId: string, version: string) {
|
|
54
|
-
const stagedPluginDir = path.join(stagingDir, 'extensions', pluginId)
|
|
55
|
-
fs.mkdirSync(stagedPluginDir, {recursive: true})
|
|
56
|
-
fs.writeFileSync(path.join(stagedPluginDir, SENTINEL), '{}')
|
|
57
|
-
fs.writeFileSync(path.join(stagedPluginDir, 'package.json'), JSON.stringify({version}))
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
let tmpDir: string
|
|
61
|
-
|
|
62
|
-
beforeEach(() => {
|
|
63
|
-
tmpDir = makeTmpDir()
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
afterEach(() => {
|
|
67
|
-
fs.rmSync(tmpDir, {recursive: true, force: true})
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
describe('withPluginBackup (staging mode — default)', () => {
|
|
71
|
-
test('successful install: original stays live during operation, atomic swap at end', async () => {
|
|
72
|
-
const api = makeApi(tmpDir)
|
|
73
|
-
const extensionDir = setupPluginDir(tmpDir)
|
|
74
|
-
const backupDir = `${extensionDir}${BACKUP_DIR_SUFFIX}`
|
|
75
|
-
|
|
76
|
-
const result = await withPluginBackup(api, PLUGIN_ID, async ({stagingDir}) => {
|
|
77
|
-
// Original dir should still exist during the operation
|
|
78
|
-
expect(fs.existsSync(extensionDir)).toBe(true)
|
|
79
|
-
expect(fs.existsSync(path.join(extensionDir, SENTINEL))).toBe(true)
|
|
80
|
-
// Staging dir should be provided and exist
|
|
81
|
-
expect(stagingDir).toBeDefined()
|
|
82
|
-
expect(fs.existsSync(stagingDir!)).toBe(true)
|
|
83
|
-
// Staging dir should be under extensions/
|
|
84
|
-
expect(stagingDir!.startsWith(path.join(tmpDir, 'extensions'))).toBe(true)
|
|
85
|
-
|
|
86
|
-
simulateStagedInstall(stagingDir!, PLUGIN_ID, '2.0.0')
|
|
87
|
-
return 'installed'
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
expect(result).toBe('installed')
|
|
91
|
-
expect(fs.existsSync(extensionDir)).toBe(true)
|
|
92
|
-
expect(fs.existsSync(backupDir)).toBe(false)
|
|
93
|
-
expect(
|
|
94
|
-
JSON.parse(fs.readFileSync(path.join(extensionDir, 'package.json'), 'utf-8')).version,
|
|
95
|
-
).toBe('2.0.0')
|
|
96
|
-
// Staging dir should be cleaned up
|
|
97
|
-
const remainingStaging = fs
|
|
98
|
-
.readdirSync(path.join(tmpDir, 'extensions'))
|
|
99
|
-
.filter((f) => f.startsWith(STAGING_DIR_PREFIX))
|
|
100
|
-
expect(remainingStaging).toHaveLength(0)
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
test('failed install: original dir untouched, staging dir cleaned up', async () => {
|
|
104
|
-
const api = makeApi(tmpDir)
|
|
105
|
-
const extensionDir = setupPluginDir(tmpDir)
|
|
106
|
-
|
|
107
|
-
await expect(
|
|
108
|
-
withPluginBackup(api, PLUGIN_ID, async ({stagingDir}) => {
|
|
109
|
-
expect(fs.existsSync(extensionDir)).toBe(true)
|
|
110
|
-
expect(stagingDir).toBeDefined()
|
|
111
|
-
throw new Error('npm install failed')
|
|
112
|
-
}),
|
|
113
|
-
).rejects.toThrow('npm install failed')
|
|
114
|
-
|
|
115
|
-
// Original dir should be untouched
|
|
116
|
-
expect(fs.existsSync(extensionDir)).toBe(true)
|
|
117
|
-
expect(fs.existsSync(path.join(extensionDir, SENTINEL))).toBe(true)
|
|
118
|
-
expect(
|
|
119
|
-
JSON.parse(fs.readFileSync(path.join(extensionDir, 'package.json'), 'utf-8')).version,
|
|
120
|
-
).toBe('1.0.0')
|
|
121
|
-
// No staging dirs left
|
|
122
|
-
const remainingStaging = fs
|
|
123
|
-
.readdirSync(path.join(tmpDir, 'extensions'))
|
|
124
|
-
.filter((f) => f.startsWith(STAGING_DIR_PREFIX))
|
|
125
|
-
expect(remainingStaging).toHaveLength(0)
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
test('missing sentinel in staging: throws, original untouched', async () => {
|
|
129
|
-
const api = makeApi(tmpDir)
|
|
130
|
-
const extensionDir = setupPluginDir(tmpDir)
|
|
131
|
-
|
|
132
|
-
await expect(
|
|
133
|
-
withPluginBackup(api, PLUGIN_ID, async ({stagingDir}) => {
|
|
134
|
-
// Install to staging but forget sentinel
|
|
135
|
-
const stagedPluginDir = path.join(stagingDir!, 'extensions', PLUGIN_ID)
|
|
136
|
-
fs.mkdirSync(stagedPluginDir, {recursive: true})
|
|
137
|
-
fs.writeFileSync(path.join(stagedPluginDir, 'package.json'), '{"version":"2.0.0"}')
|
|
138
|
-
return 'done'
|
|
139
|
-
}),
|
|
140
|
-
).rejects.toThrow('plugin files missing after operation')
|
|
141
|
-
|
|
142
|
-
expect(fs.existsSync(extensionDir)).toBe(true)
|
|
143
|
-
expect(fs.existsSync(path.join(extensionDir, SENTINEL))).toBe(true)
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
test('first-time install (no existing dir): staging works without backup', async () => {
|
|
147
|
-
const api = makeApi(tmpDir)
|
|
148
|
-
const extensionDir = path.join(tmpDir, 'extensions', PLUGIN_ID)
|
|
149
|
-
fs.mkdirSync(path.join(tmpDir, 'extensions'), {recursive: true})
|
|
150
|
-
|
|
151
|
-
const result = await withPluginBackup(api, PLUGIN_ID, async ({stagingDir}) => {
|
|
152
|
-
expect(fs.existsSync(extensionDir)).toBe(false)
|
|
153
|
-
simulateStagedInstall(stagingDir!, PLUGIN_ID, '1.0.0')
|
|
154
|
-
return 'first install'
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
expect(result).toBe('first install')
|
|
158
|
-
expect(fs.existsSync(path.join(extensionDir, SENTINEL))).toBe(true)
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
test('first-time install failure: no leftover dirs', async () => {
|
|
162
|
-
const api = makeApi(tmpDir)
|
|
163
|
-
fs.mkdirSync(path.join(tmpDir, 'extensions'), {recursive: true})
|
|
164
|
-
|
|
165
|
-
await expect(
|
|
166
|
-
withPluginBackup(api, PLUGIN_ID, async () => {
|
|
167
|
-
throw new Error('install failed')
|
|
168
|
-
}),
|
|
169
|
-
).rejects.toThrow('install failed')
|
|
170
|
-
|
|
171
|
-
const extensionDir = path.join(tmpDir, 'extensions', PLUGIN_ID)
|
|
172
|
-
expect(fs.existsSync(extensionDir)).toBe(false)
|
|
173
|
-
const remainingStaging = fs
|
|
174
|
-
.readdirSync(path.join(tmpDir, 'extensions'))
|
|
175
|
-
.filter((f) => f.startsWith(STAGING_DIR_PREFIX))
|
|
176
|
-
expect(remainingStaging).toHaveLength(0)
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
test('staging dir is on same filesystem as extensions/', async () => {
|
|
180
|
-
const api = makeApi(tmpDir)
|
|
181
|
-
setupPluginDir(tmpDir)
|
|
182
|
-
const extensionsBase = path.join(tmpDir, 'extensions')
|
|
183
|
-
|
|
184
|
-
await withPluginBackup(api, PLUGIN_ID, async ({stagingDir}) => {
|
|
185
|
-
// Verify staging dir is a sibling inside extensions/
|
|
186
|
-
expect(path.dirname(stagingDir!)).toBe(extensionsBase)
|
|
187
|
-
simulateStagedInstall(stagingDir!, PLUGIN_ID, '2.0.0')
|
|
188
|
-
return 'ok'
|
|
189
|
-
})
|
|
190
|
-
})
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
describe('withPluginBackup (in-place mode — staging: false)', () => {
|
|
194
|
-
test('preserves original dir for in-place update', async () => {
|
|
195
|
-
const api = makeApi(tmpDir)
|
|
196
|
-
const extensionDir = setupPluginDir(tmpDir)
|
|
197
|
-
const backupDir = `${extensionDir}${BACKUP_DIR_SUFFIX}`
|
|
198
|
-
|
|
199
|
-
const result = await withPluginBackup(
|
|
200
|
-
api,
|
|
201
|
-
PLUGIN_ID,
|
|
202
|
-
async () => {
|
|
203
|
-
// Original dir should still exist (copied, not renamed)
|
|
204
|
-
expect(fs.existsSync(extensionDir)).toBe(true)
|
|
205
|
-
expect(fs.existsSync(backupDir)).toBe(true)
|
|
206
|
-
// Simulate in-place update
|
|
207
|
-
fs.writeFileSync(path.join(extensionDir, 'package.json'), '{"version":"2.0.0"}')
|
|
208
|
-
return 'updated'
|
|
209
|
-
},
|
|
210
|
-
{staging: false},
|
|
211
|
-
)
|
|
212
|
-
|
|
213
|
-
expect(result).toBe('updated')
|
|
214
|
-
expect(fs.existsSync(backupDir)).toBe(false)
|
|
215
|
-
expect(
|
|
216
|
-
JSON.parse(fs.readFileSync(path.join(extensionDir, 'package.json'), 'utf-8')).version,
|
|
217
|
-
).toBe('2.0.0')
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
test('first install failure: cleans up partial extensionDir', async () => {
|
|
221
|
-
const api = makeApi(tmpDir)
|
|
222
|
-
const extensionDir = path.join(tmpDir, 'extensions', PLUGIN_ID)
|
|
223
|
-
fs.mkdirSync(path.join(tmpDir, 'extensions'), {recursive: true})
|
|
224
|
-
|
|
225
|
-
await expect(
|
|
226
|
-
withPluginBackup(
|
|
227
|
-
api,
|
|
228
|
-
PLUGIN_ID,
|
|
229
|
-
async () => {
|
|
230
|
-
// Simulate partial install — dir created but operation fails
|
|
231
|
-
fs.mkdirSync(extensionDir, {recursive: true})
|
|
232
|
-
fs.writeFileSync(path.join(extensionDir, 'package.json'), '{"version":"1.0.0"}')
|
|
233
|
-
throw new Error('install failed')
|
|
234
|
-
},
|
|
235
|
-
{staging: false},
|
|
236
|
-
),
|
|
237
|
-
).rejects.toThrow('install failed')
|
|
238
|
-
|
|
239
|
-
// Partial dir should be cleaned up
|
|
240
|
-
expect(fs.existsSync(extensionDir)).toBe(false)
|
|
241
|
-
})
|
|
242
|
-
|
|
243
|
-
test('rollback on success-without-sentinel', async () => {
|
|
244
|
-
const api = makeApi(tmpDir)
|
|
245
|
-
const extensionDir = setupPluginDir(tmpDir)
|
|
246
|
-
const backupDir = `${extensionDir}${BACKUP_DIR_SUFFIX}`
|
|
247
|
-
|
|
248
|
-
await expect(
|
|
249
|
-
withPluginBackup(
|
|
250
|
-
api,
|
|
251
|
-
PLUGIN_ID,
|
|
252
|
-
async () => {
|
|
253
|
-
// Operation succeeds but removes sentinel
|
|
254
|
-
fs.unlinkSync(path.join(extensionDir, SENTINEL))
|
|
255
|
-
return 'done'
|
|
256
|
-
},
|
|
257
|
-
{staging: false},
|
|
258
|
-
),
|
|
259
|
-
).rejects.toThrow('plugin files missing after operation')
|
|
260
|
-
|
|
261
|
-
// Should be rolled back to original (backup had sentinel)
|
|
262
|
-
expect(fs.existsSync(path.join(extensionDir, SENTINEL))).toBe(true)
|
|
263
|
-
expect(fs.existsSync(backupDir)).toBe(false)
|
|
264
|
-
})
|
|
265
|
-
|
|
266
|
-
test('rollback restores original on failure', async () => {
|
|
267
|
-
const api = makeApi(tmpDir)
|
|
268
|
-
const extensionDir = setupPluginDir(tmpDir)
|
|
269
|
-
|
|
270
|
-
await expect(
|
|
271
|
-
withPluginBackup(
|
|
272
|
-
api,
|
|
273
|
-
PLUGIN_ID,
|
|
274
|
-
async () => {
|
|
275
|
-
// Corrupt the dir
|
|
276
|
-
fs.unlinkSync(path.join(extensionDir, SENTINEL))
|
|
277
|
-
throw new Error('update failed')
|
|
278
|
-
},
|
|
279
|
-
{staging: false},
|
|
280
|
-
),
|
|
281
|
-
).rejects.toThrow('update failed')
|
|
282
|
-
|
|
283
|
-
// Should be rolled back to original
|
|
284
|
-
expect(fs.existsSync(path.join(extensionDir, SENTINEL))).toBe(true)
|
|
285
|
-
expect(
|
|
286
|
-
JSON.parse(fs.readFileSync(path.join(extensionDir, 'package.json'), 'utf-8')).version,
|
|
287
|
-
).toBe('1.0.0')
|
|
288
|
-
})
|
|
289
|
-
})
|
|
290
|
-
|
|
291
|
-
describe('withPluginBackup (no stateDir)', () => {
|
|
292
|
-
test('passes through to operation', async () => {
|
|
293
|
-
const api = makeApi(tmpDir)
|
|
294
|
-
;(api.runtime.state as any).resolveStateDir = () => ''
|
|
295
|
-
|
|
296
|
-
const result = await withPluginBackup(api, PLUGIN_ID, async () => 'passthrough')
|
|
297
|
-
expect(result).toBe('passthrough')
|
|
298
|
-
})
|
|
299
|
-
})
|
|
300
|
-
|
|
301
|
-
describe('recoverOrphanedBackup', () => {
|
|
302
|
-
test('recovers from orphaned backup dir when plugin dir is missing', async () => {
|
|
303
|
-
const api = makeApi(tmpDir)
|
|
304
|
-
const extensionDir = path.join(tmpDir, 'extensions', PLUGIN_ID)
|
|
305
|
-
const backupDir = `${extensionDir}${BACKUP_DIR_SUFFIX}`
|
|
306
|
-
|
|
307
|
-
// Create orphaned backup (as if update was interrupted)
|
|
308
|
-
fs.mkdirSync(backupDir, {recursive: true})
|
|
309
|
-
fs.writeFileSync(path.join(backupDir, SENTINEL), '{}')
|
|
310
|
-
fs.writeFileSync(path.join(backupDir, 'package.json'), '{"version":"1.0.0"}')
|
|
311
|
-
|
|
312
|
-
await recoverOrphanedBackup(api, PLUGIN_ID)
|
|
313
|
-
|
|
314
|
-
expect(fs.existsSync(extensionDir)).toBe(true)
|
|
315
|
-
expect(fs.existsSync(path.join(extensionDir, SENTINEL))).toBe(true)
|
|
316
|
-
expect(fs.existsSync(backupDir)).toBe(false)
|
|
317
|
-
})
|
|
318
|
-
|
|
319
|
-
test('warns when recovered dir is missing sentinel', async () => {
|
|
320
|
-
const api = makeApi(tmpDir) as PluginApi & {_logs: string[]}
|
|
321
|
-
const extensionDir = path.join(tmpDir, 'extensions', PLUGIN_ID)
|
|
322
|
-
const backupDir = `${extensionDir}${BACKUP_DIR_SUFFIX}`
|
|
323
|
-
|
|
324
|
-
// Create orphaned backup without sentinel
|
|
325
|
-
fs.mkdirSync(backupDir, {recursive: true})
|
|
326
|
-
fs.writeFileSync(path.join(backupDir, 'package.json'), '{"version":"1.0.0"}')
|
|
327
|
-
|
|
328
|
-
await recoverOrphanedBackup(api, PLUGIN_ID)
|
|
329
|
-
|
|
330
|
-
expect(fs.existsSync(extensionDir)).toBe(true)
|
|
331
|
-
expect(fs.existsSync(backupDir)).toBe(false)
|
|
332
|
-
expect(api._logs.some((l: string) => l.includes('missing sentinel'))).toBe(true)
|
|
333
|
-
})
|
|
334
|
-
|
|
335
|
-
test('cleans up stale backup when plugin dir is healthy', async () => {
|
|
336
|
-
const api = makeApi(tmpDir)
|
|
337
|
-
const extensionDir = setupPluginDir(tmpDir)
|
|
338
|
-
const backupDir = `${extensionDir}${BACKUP_DIR_SUFFIX}`
|
|
339
|
-
|
|
340
|
-
// Create stale backup alongside healthy plugin
|
|
341
|
-
fs.mkdirSync(backupDir, {recursive: true})
|
|
342
|
-
fs.writeFileSync(path.join(backupDir, SENTINEL), '{}')
|
|
343
|
-
|
|
344
|
-
await recoverOrphanedBackup(api, PLUGIN_ID)
|
|
345
|
-
|
|
346
|
-
expect(fs.existsSync(extensionDir)).toBe(true)
|
|
347
|
-
expect(fs.existsSync(backupDir)).toBe(false)
|
|
348
|
-
})
|
|
349
|
-
|
|
350
|
-
test('cleans up stale legacy zip when plugin dir is healthy', async () => {
|
|
351
|
-
const api = makeApi(tmpDir)
|
|
352
|
-
const extensionDir = setupPluginDir(tmpDir)
|
|
353
|
-
const legacyZip = `${extensionDir}${LEGACY_BACKUP_ZIP_SUFFIX}`
|
|
354
|
-
|
|
355
|
-
fs.writeFileSync(legacyZip, 'fake-zip-data')
|
|
356
|
-
|
|
357
|
-
await recoverOrphanedBackup(api, PLUGIN_ID)
|
|
358
|
-
|
|
359
|
-
expect(fs.existsSync(extensionDir)).toBe(true)
|
|
360
|
-
expect(fs.existsSync(legacyZip)).toBe(false)
|
|
361
|
-
})
|
|
362
|
-
|
|
363
|
-
test('no-op when no backups and plugin is healthy', async () => {
|
|
364
|
-
const api = makeApi(tmpDir) as PluginApi & {_logs: string[]}
|
|
365
|
-
setupPluginDir(tmpDir)
|
|
366
|
-
|
|
367
|
-
await recoverOrphanedBackup(api, PLUGIN_ID)
|
|
368
|
-
|
|
369
|
-
// No recovery logs
|
|
370
|
-
expect((api as any)._logs.filter((l: string) => l.includes('recover'))).toHaveLength(0)
|
|
371
|
-
})
|
|
372
|
-
|
|
373
|
-
test('no-op when plugin dir missing and no backups exist', async () => {
|
|
374
|
-
const api = makeApi(tmpDir)
|
|
375
|
-
fs.mkdirSync(path.join(tmpDir, 'extensions'), {recursive: true})
|
|
376
|
-
|
|
377
|
-
await recoverOrphanedBackup(api, PLUGIN_ID)
|
|
378
|
-
|
|
379
|
-
const extensionDir = path.join(tmpDir, 'extensions', PLUGIN_ID)
|
|
380
|
-
expect(fs.existsSync(extensionDir)).toBe(false)
|
|
381
|
-
})
|
|
382
|
-
|
|
383
|
-
test('cleans orphaned staging dirs at startup', async () => {
|
|
384
|
-
const api = makeApi(tmpDir)
|
|
385
|
-
setupPluginDir(tmpDir)
|
|
386
|
-
const extensionsBase = path.join(tmpDir, 'extensions')
|
|
387
|
-
|
|
388
|
-
// Create orphaned staging dirs
|
|
389
|
-
const staging1 = path.join(extensionsBase, `${STAGING_DIR_PREFIX}${PLUGIN_ID}-aabbccdd`)
|
|
390
|
-
const staging2 = path.join(extensionsBase, `${STAGING_DIR_PREFIX}${PLUGIN_ID}-11223344`)
|
|
391
|
-
fs.mkdirSync(path.join(staging1, 'extensions', PLUGIN_ID), {recursive: true})
|
|
392
|
-
fs.writeFileSync(path.join(staging1, 'openclaw.json'), '{}')
|
|
393
|
-
fs.mkdirSync(staging2, {recursive: true})
|
|
394
|
-
|
|
395
|
-
await recoverOrphanedBackup(api, PLUGIN_ID)
|
|
396
|
-
|
|
397
|
-
expect(fs.existsSync(staging1)).toBe(false)
|
|
398
|
-
expect(fs.existsSync(staging2)).toBe(false)
|
|
399
|
-
})
|
|
400
|
-
|
|
401
|
-
test('no-op when no stateDir', async () => {
|
|
402
|
-
const api = makeApi(tmpDir)
|
|
403
|
-
;(api.runtime.state as any).resolveStateDir = () => ''
|
|
404
|
-
|
|
405
|
-
// Should not throw
|
|
406
|
-
await recoverOrphanedBackup(api, PLUGIN_ID)
|
|
407
|
-
})
|
|
408
|
-
})
|
|
409
|
-
|
|
410
|
-
describe('cleanOrphanedStagingDirs', () => {
|
|
411
|
-
test('removes staging dirs for the specified plugin only', () => {
|
|
412
|
-
const api = makeApi(tmpDir)
|
|
413
|
-
const extensionsBase = path.join(tmpDir, 'extensions')
|
|
414
|
-
fs.mkdirSync(extensionsBase, {recursive: true})
|
|
415
|
-
|
|
416
|
-
// Create staging dirs for our plugin and another plugin
|
|
417
|
-
const ours = path.join(extensionsBase, `${STAGING_DIR_PREFIX}${PLUGIN_ID}-aabb1122`)
|
|
418
|
-
const other = path.join(extensionsBase, `${STAGING_DIR_PREFIX}other-plugin-ccdd3344`)
|
|
419
|
-
fs.mkdirSync(ours, {recursive: true})
|
|
420
|
-
fs.mkdirSync(other, {recursive: true})
|
|
421
|
-
|
|
422
|
-
cleanOrphanedStagingDirs(api, PLUGIN_ID)
|
|
423
|
-
|
|
424
|
-
expect(fs.existsSync(ours)).toBe(false)
|
|
425
|
-
expect(fs.existsSync(other)).toBe(true)
|
|
426
|
-
})
|
|
427
|
-
|
|
428
|
-
test('does not delete staging dirs of plugin whose ID is a superstring', () => {
|
|
429
|
-
const api = makeApi(tmpDir)
|
|
430
|
-
const extensionsBase = path.join(tmpDir, 'extensions')
|
|
431
|
-
fs.mkdirSync(extensionsBase, {recursive: true})
|
|
432
|
-
|
|
433
|
-
// "test-plugin" should NOT match "test-plugin-extra"
|
|
434
|
-
const ours = path.join(extensionsBase, `${STAGING_DIR_PREFIX}${PLUGIN_ID}-aabb1122`)
|
|
435
|
-
const longerPlugin = path.join(
|
|
436
|
-
extensionsBase,
|
|
437
|
-
`${STAGING_DIR_PREFIX}${PLUGIN_ID}-extra-ccdd3344`,
|
|
438
|
-
)
|
|
439
|
-
fs.mkdirSync(ours, {recursive: true})
|
|
440
|
-
fs.mkdirSync(longerPlugin, {recursive: true})
|
|
441
|
-
|
|
442
|
-
cleanOrphanedStagingDirs(api, PLUGIN_ID)
|
|
443
|
-
|
|
444
|
-
expect(fs.existsSync(ours)).toBe(false)
|
|
445
|
-
// longer plugin name must survive — it's a different plugin
|
|
446
|
-
expect(fs.existsSync(longerPlugin)).toBe(true)
|
|
447
|
-
})
|
|
448
|
-
|
|
449
|
-
test('staging install cleans previous orphaned staging dirs', async () => {
|
|
450
|
-
const api = makeApi(tmpDir)
|
|
451
|
-
setupPluginDir(tmpDir)
|
|
452
|
-
const extensionsBase = path.join(tmpDir, 'extensions')
|
|
453
|
-
|
|
454
|
-
// Create an orphaned staging dir from a previous interrupted attempt
|
|
455
|
-
const orphaned = path.join(extensionsBase, `${STAGING_DIR_PREFIX}${PLUGIN_ID}-deadbeef`)
|
|
456
|
-
fs.mkdirSync(path.join(orphaned, 'extensions', PLUGIN_ID), {recursive: true})
|
|
457
|
-
fs.writeFileSync(path.join(orphaned, 'openclaw.json'), '{}')
|
|
458
|
-
|
|
459
|
-
await withPluginBackup(api, PLUGIN_ID, async ({stagingDir}) => {
|
|
460
|
-
// Orphaned dir should already be cleaned up
|
|
461
|
-
expect(fs.existsSync(orphaned)).toBe(false)
|
|
462
|
-
// But our new staging dir should exist
|
|
463
|
-
expect(fs.existsSync(stagingDir!)).toBe(true)
|
|
464
|
-
simulateStagedInstall(stagingDir!, PLUGIN_ID, '2.0.0')
|
|
465
|
-
return 'ok'
|
|
466
|
-
})
|
|
467
|
-
|
|
468
|
-
// No staging dirs left
|
|
469
|
-
const remaining = fs.readdirSync(extensionsBase).filter((f) => f.startsWith(STAGING_DIR_PREFIX))
|
|
470
|
-
expect(remaining).toHaveLength(0)
|
|
471
|
-
})
|
|
472
|
-
})
|