@casatwy/deyo 0.2.1 → 0.2.5
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 +39 -8
- package/deyo.mjs +295 -115
- package/openclaw-skill-update.mjs +794 -0
- package/package.json +3 -2
- package/skill-manager.mjs +111 -16
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
import { constants as fsConstants } from 'node:fs'
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import {
|
|
5
|
+
chmod,
|
|
6
|
+
lstat,
|
|
7
|
+
mkdir,
|
|
8
|
+
open,
|
|
9
|
+
readFile,
|
|
10
|
+
rename,
|
|
11
|
+
rm,
|
|
12
|
+
} from 'node:fs/promises'
|
|
13
|
+
import os from 'node:os'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
17
|
+
const TIMEOUT_MS = 20_000
|
|
18
|
+
const OWNER = 'casatwy'
|
|
19
|
+
const SLUG = 'deyo'
|
|
20
|
+
const REGISTRY = 'https://clawhub.ai'
|
|
21
|
+
const OWNER_QUALIFIED_SKILL = `@${OWNER}/${SLUG}`
|
|
22
|
+
export const CONFIRM_LATEST_PHRASE = `update ${OWNER_QUALIFIED_SKILL} to latest now`
|
|
23
|
+
const UPDATED_EXIT_CODE = 10
|
|
24
|
+
const CONFIRMATION_EXIT_CODE = 11
|
|
25
|
+
const STABLE_SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/
|
|
26
|
+
const PRIVATE_DIRECTORY_MODE = 0o700
|
|
27
|
+
const PRIVATE_FILE_MODE = 0o600
|
|
28
|
+
|
|
29
|
+
function normalizeOwner(value) {
|
|
30
|
+
return typeof value === 'string' ? value.trim().replace(/^@/, '').toLowerCase() : ''
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeSlug(value) {
|
|
34
|
+
if (typeof value !== 'string') return ''
|
|
35
|
+
return value.trim().replace(/^@casatwy\//i, '').toLowerCase()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function compareSemver(left, right) {
|
|
39
|
+
if (!STABLE_SEMVER.test(left) || !STABLE_SEMVER.test(right)) return null
|
|
40
|
+
const a = left.split('.').map(Number)
|
|
41
|
+
const b = right.split('.').map(Number)
|
|
42
|
+
for (let index = 0; index < 3; index += 1) {
|
|
43
|
+
if (a[index] !== b[index]) return a[index] - b[index]
|
|
44
|
+
}
|
|
45
|
+
return 0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function absoluteConfiguredPath(name, value, fallback) {
|
|
49
|
+
const selected = String(value || fallback || '').trim()
|
|
50
|
+
if (!selected || !path.isAbsolute(selected)) {
|
|
51
|
+
throw new Error(`${name} must be an absolute path`)
|
|
52
|
+
}
|
|
53
|
+
return path.normalize(selected)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function homeDirectory(environment) {
|
|
57
|
+
return absoluteConfiguredPath(
|
|
58
|
+
'HOME',
|
|
59
|
+
environment.HOME || environment.USERPROFILE,
|
|
60
|
+
os.homedir(),
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function openClawStateDirectory(environment) {
|
|
65
|
+
const home = homeDirectory(environment)
|
|
66
|
+
if (environment.OPENCLAW_STATE_DIR) {
|
|
67
|
+
return absoluteConfiguredPath('OPENCLAW_STATE_DIR', environment.OPENCLAW_STATE_DIR)
|
|
68
|
+
}
|
|
69
|
+
const openClawHome = absoluteConfiguredPath('OPENCLAW_HOME', environment.OPENCLAW_HOME, home)
|
|
70
|
+
return path.join(openClawHome, '.openclaw')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function configRoot(environment) {
|
|
74
|
+
return absoluteConfiguredPath(
|
|
75
|
+
'XDG_CONFIG_HOME',
|
|
76
|
+
environment.XDG_CONFIG_HOME,
|
|
77
|
+
path.join(homeDirectory(environment), '.config'),
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function stateRoot(environment) {
|
|
82
|
+
return absoluteConfiguredPath(
|
|
83
|
+
'XDG_STATE_HOME',
|
|
84
|
+
environment.XDG_STATE_HOME,
|
|
85
|
+
path.join(homeDirectory(environment), '.local', 'state'),
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function resolveManagedOriginPath({ scope, cwd = process.cwd(), env = process.env }) {
|
|
90
|
+
if (scope === 'workspace') {
|
|
91
|
+
return path.resolve(cwd, 'skills', SLUG, '.clawhub', 'origin.json')
|
|
92
|
+
}
|
|
93
|
+
if (scope !== 'global') throw new Error('OpenClaw scope must be global or workspace')
|
|
94
|
+
return path.join(openClawStateDirectory(env), 'skills', SLUG, '.clawhub', 'origin.json')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function installIdentity(scope, originPath) {
|
|
98
|
+
return createHash('sha256')
|
|
99
|
+
.update(`${scope}\0${path.normalize(originPath)}`, 'utf8')
|
|
100
|
+
.digest('hex')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function resolveOpenClawUpdatePaths({ scope, cwd = process.cwd(), env = process.env }) {
|
|
104
|
+
const originPath = resolveManagedOriginPath({ scope, cwd, env })
|
|
105
|
+
const identity = installIdentity(scope, originPath)
|
|
106
|
+
const enrollmentDirectory = path.join(configRoot(env), 'deyo', 'openclaw-skill-update')
|
|
107
|
+
const runtimeDirectory = path.join(stateRoot(env), 'deyo', 'openclaw-skill-update')
|
|
108
|
+
return {
|
|
109
|
+
identity,
|
|
110
|
+
originPath,
|
|
111
|
+
enrollmentDirectory,
|
|
112
|
+
enrollmentPath: path.join(enrollmentDirectory, `${identity}.json`),
|
|
113
|
+
runtimeDirectory,
|
|
114
|
+
runtimePath: path.join(runtimeDirectory, `${identity}.json`),
|
|
115
|
+
lockPath: path.join(runtimeDirectory, `${identity}.lock`),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function pathLstat(target) {
|
|
120
|
+
try {
|
|
121
|
+
return await lstat(target)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (error?.code === 'ENOENT') return null
|
|
124
|
+
throw error
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function inspectPrivateJson(target) {
|
|
129
|
+
const directory = path.dirname(target)
|
|
130
|
+
const ownedRoot = path.dirname(directory)
|
|
131
|
+
const [directoryStats, ownedRootStats] = await Promise.all([
|
|
132
|
+
pathLstat(directory),
|
|
133
|
+
pathLstat(ownedRoot),
|
|
134
|
+
])
|
|
135
|
+
if (!directoryStats) return { exists: false, safe: true, value: null }
|
|
136
|
+
if (
|
|
137
|
+
path.basename(ownedRoot) !== 'deyo'
|
|
138
|
+
|| !ownedRootStats
|
|
139
|
+
|| ownedRootStats.isSymbolicLink()
|
|
140
|
+
|| !ownedRootStats.isDirectory()
|
|
141
|
+
|| (ownedRootStats.mode & 0o077) !== 0
|
|
142
|
+
||
|
|
143
|
+
directoryStats.isSymbolicLink()
|
|
144
|
+
|| !directoryStats.isDirectory()
|
|
145
|
+
|| (directoryStats.mode & 0o077) !== 0
|
|
146
|
+
) {
|
|
147
|
+
return { exists: false, safe: false, value: null }
|
|
148
|
+
}
|
|
149
|
+
const stats = await pathLstat(target)
|
|
150
|
+
if (!stats) return { exists: false, safe: true, value: null }
|
|
151
|
+
if (stats.isSymbolicLink() || !stats.isFile() || (stats.mode & 0o077) !== 0) {
|
|
152
|
+
return { exists: true, safe: false, value: null }
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
return { exists: true, safe: true, value: JSON.parse(await readFile(target, 'utf8')) }
|
|
156
|
+
} catch {
|
|
157
|
+
return { exists: true, safe: false, value: null }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function ensurePrivateDirectory(target) {
|
|
162
|
+
const ownedParent = path.dirname(target)
|
|
163
|
+
const parentStats = await pathLstat(ownedParent)
|
|
164
|
+
if (
|
|
165
|
+
path.basename(ownedParent) === 'deyo'
|
|
166
|
+
&& (parentStats?.isSymbolicLink() || (parentStats && !parentStats.isDirectory()))
|
|
167
|
+
) {
|
|
168
|
+
throw new Error(`Unsafe private directory: ${ownedParent}`)
|
|
169
|
+
}
|
|
170
|
+
const existing = await pathLstat(target)
|
|
171
|
+
if (existing?.isSymbolicLink() || (existing && !existing.isDirectory())) {
|
|
172
|
+
throw new Error(`Unsafe private directory: ${target}`)
|
|
173
|
+
}
|
|
174
|
+
await mkdir(target, { recursive: true, mode: PRIVATE_DIRECTORY_MODE })
|
|
175
|
+
const createdParent = await lstat(ownedParent)
|
|
176
|
+
if (
|
|
177
|
+
path.basename(ownedParent) === 'deyo'
|
|
178
|
+
&& (createdParent.isSymbolicLink() || !createdParent.isDirectory())
|
|
179
|
+
) {
|
|
180
|
+
throw new Error(`Unsafe private directory: ${ownedParent}`)
|
|
181
|
+
}
|
|
182
|
+
if (path.basename(ownedParent) === 'deyo') await chmod(ownedParent, PRIVATE_DIRECTORY_MODE)
|
|
183
|
+
const created = await lstat(target)
|
|
184
|
+
if (created.isSymbolicLink() || !created.isDirectory()) {
|
|
185
|
+
throw new Error(`Unsafe private directory: ${target}`)
|
|
186
|
+
}
|
|
187
|
+
await chmod(target, PRIVATE_DIRECTORY_MODE)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function syncDirectory(target) {
|
|
191
|
+
let handle
|
|
192
|
+
try {
|
|
193
|
+
handle = await open(target, 'r')
|
|
194
|
+
await handle.sync()
|
|
195
|
+
} catch {
|
|
196
|
+
// Directory fsync is not available on every supported platform.
|
|
197
|
+
} finally {
|
|
198
|
+
await handle?.close().catch(() => {})
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function writePrivateJson(target, value) {
|
|
203
|
+
const parent = path.dirname(target)
|
|
204
|
+
await ensurePrivateDirectory(parent)
|
|
205
|
+
const existing = await pathLstat(target)
|
|
206
|
+
if (existing?.isSymbolicLink() || (existing && !existing.isFile())) {
|
|
207
|
+
throw new Error(`Unsafe private state file: ${target}`)
|
|
208
|
+
}
|
|
209
|
+
const temporary = path.join(
|
|
210
|
+
parent,
|
|
211
|
+
`.${path.basename(target)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`,
|
|
212
|
+
)
|
|
213
|
+
const handle = await open(temporary, 'wx', PRIVATE_FILE_MODE)
|
|
214
|
+
let temporaryExists = true
|
|
215
|
+
try {
|
|
216
|
+
try {
|
|
217
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
218
|
+
await handle.sync()
|
|
219
|
+
} finally {
|
|
220
|
+
await handle.close()
|
|
221
|
+
}
|
|
222
|
+
await chmod(temporary, PRIVATE_FILE_MODE)
|
|
223
|
+
const rechecked = await pathLstat(target)
|
|
224
|
+
if (rechecked?.isSymbolicLink() || (rechecked && !rechecked.isFile())) {
|
|
225
|
+
throw new Error(`Unsafe private state file: ${target}`)
|
|
226
|
+
}
|
|
227
|
+
await rename(temporary, target)
|
|
228
|
+
temporaryExists = false
|
|
229
|
+
await chmod(target, PRIVATE_FILE_MODE)
|
|
230
|
+
await syncDirectory(parent)
|
|
231
|
+
} finally {
|
|
232
|
+
if (temporaryExists) await rm(temporary, { force: true })
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function acquireLock(lockPath) {
|
|
237
|
+
await ensurePrivateDirectory(path.dirname(lockPath))
|
|
238
|
+
try {
|
|
239
|
+
const handle = await open(
|
|
240
|
+
lockPath,
|
|
241
|
+
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
|
|
242
|
+
PRIVATE_FILE_MODE,
|
|
243
|
+
)
|
|
244
|
+
await handle.writeFile(`${process.pid}\n`)
|
|
245
|
+
await handle.sync()
|
|
246
|
+
await chmod(lockPath, PRIVATE_FILE_MODE)
|
|
247
|
+
return handle
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (error?.code === 'EEXIST') return null
|
|
250
|
+
throw error
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function validOrigin(raw, originPath) {
|
|
255
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
256
|
+
const registry = typeof raw.registry === 'string' ? raw.registry.replace(/\/+$/, '') : ''
|
|
257
|
+
const ownerHandle = normalizeOwner(raw.ownerHandle)
|
|
258
|
+
const slug = normalizeSlug(raw.slug)
|
|
259
|
+
const version = raw.installedVersion
|
|
260
|
+
const fingerprint = raw.fingerprint
|
|
261
|
+
if (
|
|
262
|
+
raw.version !== 1
|
|
263
|
+
|| registry !== REGISTRY
|
|
264
|
+
|| ownerHandle !== OWNER
|
|
265
|
+
|| slug !== SLUG
|
|
266
|
+
|| typeof version !== 'string'
|
|
267
|
+
|| !STABLE_SEMVER.test(version)
|
|
268
|
+
|| !Number.isFinite(raw.installedAt)
|
|
269
|
+
|| !/^[a-f0-9]{64}$/.test(fingerprint || '')
|
|
270
|
+
) return null
|
|
271
|
+
return { originPath, registry, ownerHandle, slug, version, fingerprint }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function readManagedOrigin(originPath) {
|
|
275
|
+
try {
|
|
276
|
+
const skillDirectory = path.dirname(path.dirname(originPath))
|
|
277
|
+
const metadataDirectory = path.dirname(originPath)
|
|
278
|
+
const [skillStats, metadataStats, originStats] = await Promise.all([
|
|
279
|
+
lstat(skillDirectory),
|
|
280
|
+
lstat(metadataDirectory),
|
|
281
|
+
lstat(originPath),
|
|
282
|
+
])
|
|
283
|
+
if (
|
|
284
|
+
skillStats.isSymbolicLink()
|
|
285
|
+
|| !skillStats.isDirectory()
|
|
286
|
+
|| metadataStats.isSymbolicLink()
|
|
287
|
+
|| !metadataStats.isDirectory()
|
|
288
|
+
|| originStats.isSymbolicLink()
|
|
289
|
+
|| !originStats.isFile()
|
|
290
|
+
) return { ok: false, reason: 'unsafe_origin', path: originPath }
|
|
291
|
+
const origin = validOrigin(JSON.parse(await readFile(originPath, 'utf8')), originPath)
|
|
292
|
+
return origin
|
|
293
|
+
? { ok: true, ...origin }
|
|
294
|
+
: { ok: false, reason: 'invalid_origin', path: originPath }
|
|
295
|
+
} catch (error) {
|
|
296
|
+
return {
|
|
297
|
+
ok: false,
|
|
298
|
+
reason: error?.code === 'ENOENT' ? 'missing_origin' : 'unreadable_origin',
|
|
299
|
+
path: originPath,
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function validEnrollment(value, { scope, identity, originPath }) {
|
|
305
|
+
return Boolean(
|
|
306
|
+
value
|
|
307
|
+
&& value.schema === 1
|
|
308
|
+
&& value.enabled === true
|
|
309
|
+
&& value.scope === scope
|
|
310
|
+
&& value.identity === identity
|
|
311
|
+
&& value.originPath === originPath
|
|
312
|
+
&& value.registry === REGISTRY
|
|
313
|
+
&& normalizeOwner(value.ownerHandle) === OWNER
|
|
314
|
+
&& normalizeSlug(value.slug) === SLUG
|
|
315
|
+
&& typeof value.enrolledAt === 'string'
|
|
316
|
+
&& typeof value.enrolledByCliVersion === 'string'
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function validRuntime(value, identity) {
|
|
321
|
+
return Boolean(
|
|
322
|
+
value
|
|
323
|
+
&& value.schema === 3
|
|
324
|
+
&& value.identity === identity
|
|
325
|
+
&& Number.isSafeInteger(value.attempt)
|
|
326
|
+
&& value.attempt >= 0
|
|
327
|
+
&& (value.lastAttemptAt === null || typeof value.lastAttemptAt === 'string')
|
|
328
|
+
&& typeof value.status === 'string'
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function isDue(state, now = Date.now()) {
|
|
333
|
+
const lastAttempt = Date.parse(state?.lastAttemptAt ?? '')
|
|
334
|
+
return !Number.isFinite(lastAttempt) || now - lastAttempt >= DAY_MS
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function isAutoUpdateDisabled(environment = process.env) {
|
|
338
|
+
return environment.DEYO_OPENCLAW_AUTO_UPDATE === '0'
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function isVerificationClean(envelope, expectedInstalledVersion, selector = 'tag') {
|
|
342
|
+
const candidateVersion = envelope?.version
|
|
343
|
+
const installedVersion = envelope?.openclaw?.resolution?.installedVersion
|
|
344
|
+
const selectorMatches = selector === 'tag'
|
|
345
|
+
? envelope?.resolvedFrom === 'tag' && envelope?.tag === 'latest'
|
|
346
|
+
: false
|
|
347
|
+
return envelope?.schema === 'clawhub.skill.verify.v1'
|
|
348
|
+
&& normalizeOwner(envelope?.publisherHandle) === OWNER
|
|
349
|
+
&& normalizeSlug(envelope?.slug) === SLUG
|
|
350
|
+
&& typeof candidateVersion === 'string'
|
|
351
|
+
&& STABLE_SEMVER.test(candidateVersion)
|
|
352
|
+
&& selectorMatches
|
|
353
|
+
&& envelope?.openclaw?.resolution?.source === 'installed'
|
|
354
|
+
&& envelope?.openclaw?.resolution?.selector === selector
|
|
355
|
+
&& installedVersion === expectedInstalledVersion
|
|
356
|
+
&& compareSemver(candidateVersion, expectedInstalledVersion) >= 0
|
|
357
|
+
&& envelope?.ok === true
|
|
358
|
+
&& envelope?.decision === 'pass'
|
|
359
|
+
&& envelope?.security?.passed === true
|
|
360
|
+
&& envelope?.security?.status === 'clean'
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export async function runWithDeadline(command, args, options = {}) {
|
|
364
|
+
const remainingMs = options.deadline - Date.now()
|
|
365
|
+
if (remainingMs <= 0) throw new Error('OpenClaw operation timed out')
|
|
366
|
+
return await new Promise((resolve, reject) => {
|
|
367
|
+
const child = spawn(command, args, {
|
|
368
|
+
cwd: options.cwd,
|
|
369
|
+
env: options.env,
|
|
370
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
371
|
+
})
|
|
372
|
+
const stdout = []
|
|
373
|
+
const stderr = []
|
|
374
|
+
let timedOut = false
|
|
375
|
+
const timer = setTimeout(() => {
|
|
376
|
+
timedOut = true
|
|
377
|
+
child.kill('SIGKILL')
|
|
378
|
+
}, remainingMs)
|
|
379
|
+
child.stdout.on('data', chunk => stdout.push(Buffer.from(chunk)))
|
|
380
|
+
child.stderr.on('data', chunk => stderr.push(Buffer.from(chunk)))
|
|
381
|
+
child.on('error', (error) => {
|
|
382
|
+
clearTimeout(timer)
|
|
383
|
+
reject(error)
|
|
384
|
+
})
|
|
385
|
+
child.on('close', (code, signal) => {
|
|
386
|
+
clearTimeout(timer)
|
|
387
|
+
if (timedOut) {
|
|
388
|
+
reject(new Error('OpenClaw operation timed out'))
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
resolve({
|
|
392
|
+
code: code ?? 1,
|
|
393
|
+
signal: signal || null,
|
|
394
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
395
|
+
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
396
|
+
})
|
|
397
|
+
})
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function baseRuntime(identity, attempt, attemptedAt, origin, status) {
|
|
402
|
+
return {
|
|
403
|
+
schema: 3,
|
|
404
|
+
identity,
|
|
405
|
+
lastAttemptAt: attemptedAt,
|
|
406
|
+
attempt,
|
|
407
|
+
status,
|
|
408
|
+
fromVersion: origin?.version ?? null,
|
|
409
|
+
fromFingerprint: origin?.fingerprint ?? null,
|
|
410
|
+
candidateVersion: null,
|
|
411
|
+
candidateVerifiedAt: null,
|
|
412
|
+
verificationDecision: null,
|
|
413
|
+
securityStatus: null,
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function pendingRuntime(base, candidateVersion, verifiedAt) {
|
|
418
|
+
return {
|
|
419
|
+
...base,
|
|
420
|
+
status: 'pending_confirmation',
|
|
421
|
+
candidateVersion,
|
|
422
|
+
candidateVerifiedAt: verifiedAt,
|
|
423
|
+
verificationDecision: 'pass',
|
|
424
|
+
securityStatus: 'clean',
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function readEnrollmentAndRuntime(paths, scope) {
|
|
429
|
+
const [enrollmentMetadata, runtimeMetadata] = await Promise.all([
|
|
430
|
+
inspectPrivateJson(paths.enrollmentPath),
|
|
431
|
+
inspectPrivateJson(paths.runtimePath),
|
|
432
|
+
])
|
|
433
|
+
const enrollmentState = !enrollmentMetadata.exists
|
|
434
|
+
? 'not_enrolled'
|
|
435
|
+
: enrollmentMetadata.safe && validEnrollment(enrollmentMetadata.value, {
|
|
436
|
+
scope,
|
|
437
|
+
identity: paths.identity,
|
|
438
|
+
originPath: paths.originPath,
|
|
439
|
+
})
|
|
440
|
+
? 'enrolled'
|
|
441
|
+
: 'invalid'
|
|
442
|
+
const runtimeState = !runtimeMetadata.exists
|
|
443
|
+
? 'missing'
|
|
444
|
+
: runtimeMetadata.safe && validRuntime(runtimeMetadata.value, paths.identity)
|
|
445
|
+
? 'valid'
|
|
446
|
+
: 'invalid'
|
|
447
|
+
return {
|
|
448
|
+
enrollmentState,
|
|
449
|
+
enrollment: enrollmentState === 'enrolled' ? enrollmentMetadata.value : null,
|
|
450
|
+
runtimeState,
|
|
451
|
+
runtime: runtimeState === 'valid' ? runtimeMetadata.value : null,
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export async function enrollOpenClawAutoUpdate({
|
|
456
|
+
scope,
|
|
457
|
+
cliVersion,
|
|
458
|
+
cwd = process.cwd(),
|
|
459
|
+
env = process.env,
|
|
460
|
+
now = Date.now(),
|
|
461
|
+
}) {
|
|
462
|
+
process.umask(0o077)
|
|
463
|
+
const paths = resolveOpenClawUpdatePaths({ scope, cwd, env })
|
|
464
|
+
const origin = await readManagedOrigin(paths.originPath)
|
|
465
|
+
if (!origin.ok) throw new Error(`Cannot enroll an unverified OpenClaw origin: ${origin.reason}`)
|
|
466
|
+
await writePrivateJson(paths.runtimePath, {
|
|
467
|
+
...baseRuntime(paths.identity, 0, null, origin, 'enrolled'),
|
|
468
|
+
})
|
|
469
|
+
await writePrivateJson(paths.enrollmentPath, {
|
|
470
|
+
schema: 1,
|
|
471
|
+
enabled: true,
|
|
472
|
+
identity: paths.identity,
|
|
473
|
+
scope,
|
|
474
|
+
originPath: paths.originPath,
|
|
475
|
+
registry: REGISTRY,
|
|
476
|
+
ownerHandle: OWNER,
|
|
477
|
+
slug: SLUG,
|
|
478
|
+
enrolledAt: new Date(now).toISOString(),
|
|
479
|
+
enrolledByCliVersion: cliVersion,
|
|
480
|
+
enrolledVersion: origin.version,
|
|
481
|
+
enrolledFingerprint: origin.fingerprint,
|
|
482
|
+
})
|
|
483
|
+
return {
|
|
484
|
+
state: 'enrolled',
|
|
485
|
+
enrollmentPath: paths.enrollmentPath,
|
|
486
|
+
runtimePath: paths.runtimePath,
|
|
487
|
+
version: origin.version,
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export async function inspectOpenClawAutoUpdate({
|
|
492
|
+
scope,
|
|
493
|
+
cwd = process.cwd(),
|
|
494
|
+
env = process.env,
|
|
495
|
+
}) {
|
|
496
|
+
try {
|
|
497
|
+
const paths = resolveOpenClawUpdatePaths({ scope, cwd, env })
|
|
498
|
+
const local = await readEnrollmentAndRuntime(paths, scope)
|
|
499
|
+
return {
|
|
500
|
+
enrollment: local.enrollmentState,
|
|
501
|
+
runtime: local.runtimeState,
|
|
502
|
+
lastAttemptAt: local.runtime?.lastAttemptAt ?? null,
|
|
503
|
+
status: local.runtime?.status ?? null,
|
|
504
|
+
fromVersion: local.runtime?.fromVersion ?? null,
|
|
505
|
+
fromFingerprint: local.runtime?.fromFingerprint ?? null,
|
|
506
|
+
candidateVersion: local.runtime?.candidateVersion ?? null,
|
|
507
|
+
candidateVerifiedAt: local.runtime?.candidateVerifiedAt ?? null,
|
|
508
|
+
reloadRequiredAt: local.runtime?.reloadRequiredAt ?? null,
|
|
509
|
+
}
|
|
510
|
+
} catch {
|
|
511
|
+
return {
|
|
512
|
+
enrollment: 'invalid',
|
|
513
|
+
runtime: 'invalid',
|
|
514
|
+
lastAttemptAt: null,
|
|
515
|
+
status: null,
|
|
516
|
+
fromVersion: null,
|
|
517
|
+
fromFingerprint: null,
|
|
518
|
+
candidateVersion: null,
|
|
519
|
+
candidateVerifiedAt: null,
|
|
520
|
+
reloadRequiredAt: null,
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function verifyLatest({ before, scope, cwd, env, deadline, run }) {
|
|
526
|
+
const scopeArgs = scope === 'global' ? ['--global'] : []
|
|
527
|
+
const verification = await run('openclaw', [
|
|
528
|
+
'skills', 'verify', OWNER_QUALIFIED_SKILL, '--tag', 'latest', ...scopeArgs,
|
|
529
|
+
], { deadline, cwd, env })
|
|
530
|
+
if (verification.code !== 0) return { ok: false }
|
|
531
|
+
let envelope
|
|
532
|
+
try {
|
|
533
|
+
envelope = JSON.parse(verification.stdout)
|
|
534
|
+
} catch {
|
|
535
|
+
return { ok: false }
|
|
536
|
+
}
|
|
537
|
+
if (!isVerificationClean(envelope, before.version)) return { ok: false }
|
|
538
|
+
return { ok: true, envelope, candidateVersion: envelope.version }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export async function runOpenClawInvocationCheck({
|
|
542
|
+
scope,
|
|
543
|
+
cwd = process.cwd(),
|
|
544
|
+
env = process.env,
|
|
545
|
+
now = Date.now(),
|
|
546
|
+
run = runWithDeadline,
|
|
547
|
+
}) {
|
|
548
|
+
if (isAutoUpdateDisabled(env)) return { exitCode: 0, status: 'disabled' }
|
|
549
|
+
process.umask(0o077)
|
|
550
|
+
let paths
|
|
551
|
+
try {
|
|
552
|
+
paths = resolveOpenClawUpdatePaths({ scope, cwd, env })
|
|
553
|
+
const local = await readEnrollmentAndRuntime(paths, scope)
|
|
554
|
+
if (local.enrollmentState !== 'enrolled') {
|
|
555
|
+
return { exitCode: 0, status: local.enrollmentState }
|
|
556
|
+
}
|
|
557
|
+
if (local.runtimeState === 'invalid') return { exitCode: 0, status: 'invalid_runtime' }
|
|
558
|
+
if (!isDue(local.runtime, now)) return { exitCode: 0, status: 'not_due' }
|
|
559
|
+
} catch {
|
|
560
|
+
return { exitCode: 0, status: 'state_unavailable' }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
let lock
|
|
564
|
+
try {
|
|
565
|
+
lock = await acquireLock(paths.lockPath)
|
|
566
|
+
} catch {
|
|
567
|
+
return { exitCode: 0, status: 'state_unavailable' }
|
|
568
|
+
}
|
|
569
|
+
if (!lock) return { exitCode: 0, status: 'busy' }
|
|
570
|
+
try {
|
|
571
|
+
const local = await readEnrollmentAndRuntime(paths, scope)
|
|
572
|
+
if (local.enrollmentState !== 'enrolled') return { exitCode: 0, status: 'not_enrolled' }
|
|
573
|
+
if (local.runtimeState === 'invalid') return { exitCode: 0, status: 'invalid_runtime' }
|
|
574
|
+
if (!isDue(local.runtime, now)) return { exitCode: 0, status: 'not_due' }
|
|
575
|
+
|
|
576
|
+
const before = await readManagedOrigin(paths.originPath)
|
|
577
|
+
if (!before.ok) return { exitCode: 0, status: 'origin_unavailable' }
|
|
578
|
+
const attemptedAt = new Date(now).toISOString()
|
|
579
|
+
const attempt = (local.runtime?.attempt ?? 0) + 1
|
|
580
|
+
const base = baseRuntime(paths.identity, attempt, attemptedAt, before, 'attempting')
|
|
581
|
+
try {
|
|
582
|
+
await writePrivateJson(paths.runtimePath, base)
|
|
583
|
+
} catch {
|
|
584
|
+
return { exitCode: 0, status: 'state_unavailable' }
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
let verified
|
|
588
|
+
try {
|
|
589
|
+
verified = await verifyLatest({
|
|
590
|
+
before,
|
|
591
|
+
scope,
|
|
592
|
+
cwd,
|
|
593
|
+
env,
|
|
594
|
+
deadline: now + TIMEOUT_MS,
|
|
595
|
+
run,
|
|
596
|
+
})
|
|
597
|
+
} catch {
|
|
598
|
+
await writePrivateJson(paths.runtimePath, { ...base, status: 'verification_failed' }).catch(() => {})
|
|
599
|
+
return { exitCode: 0, status: 'verification_failed' }
|
|
600
|
+
}
|
|
601
|
+
if (!verified.ok) {
|
|
602
|
+
await writePrivateJson(paths.runtimePath, { ...base, status: 'verification_failed' }).catch(() => {})
|
|
603
|
+
return { exitCode: 0, status: 'verification_failed' }
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (compareSemver(verified.candidateVersion, before.version) === 0) {
|
|
607
|
+
await writePrivateJson(paths.runtimePath, {
|
|
608
|
+
...base,
|
|
609
|
+
status: 'up_to_date',
|
|
610
|
+
candidateVersion: verified.candidateVersion,
|
|
611
|
+
candidateVerifiedAt: new Date().toISOString(),
|
|
612
|
+
verificationDecision: 'pass',
|
|
613
|
+
securityStatus: 'clean',
|
|
614
|
+
}).catch(() => {})
|
|
615
|
+
return { exitCode: 0, status: 'up_to_date' }
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const candidateVerifiedAt = new Date().toISOString()
|
|
619
|
+
try {
|
|
620
|
+
await writePrivateJson(
|
|
621
|
+
paths.runtimePath,
|
|
622
|
+
pendingRuntime(base, verified.candidateVersion, candidateVerifiedAt),
|
|
623
|
+
)
|
|
624
|
+
} catch {
|
|
625
|
+
return { exitCode: 0, status: 'state_unavailable' }
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
exitCode: CONFIRMATION_EXIT_CODE,
|
|
629
|
+
status: 'pending_confirmation',
|
|
630
|
+
fromVersion: before.version,
|
|
631
|
+
candidateVersion: verified.candidateVersion,
|
|
632
|
+
message: `Deyo Skill update available: ${OWNER_QUALIFIED_SKILL} ${before.version} -> ${verified.candidateVersion}; source=ClawHub owner=${OWNER}; security=pass/clean. Ask the current user: 是否现在更新Deyo到latest?`,
|
|
633
|
+
}
|
|
634
|
+
} catch {
|
|
635
|
+
return { exitCode: 0, status: 'check_failed' }
|
|
636
|
+
} finally {
|
|
637
|
+
await lock.close().catch(() => {})
|
|
638
|
+
await rm(paths.lockPath, { force: true })
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export async function runOpenClawConfirmedUpdate({
|
|
643
|
+
scope,
|
|
644
|
+
confirmation,
|
|
645
|
+
cwd = process.cwd(),
|
|
646
|
+
env = process.env,
|
|
647
|
+
now = Date.now(),
|
|
648
|
+
run = runWithDeadline,
|
|
649
|
+
}) {
|
|
650
|
+
if (confirmation !== CONFIRM_LATEST_PHRASE) {
|
|
651
|
+
return { exitCode: 1, status: 'confirmation_required' }
|
|
652
|
+
}
|
|
653
|
+
if (isAutoUpdateDisabled(env)) return { exitCode: 0, status: 'disabled' }
|
|
654
|
+
process.umask(0o077)
|
|
655
|
+
let paths
|
|
656
|
+
try {
|
|
657
|
+
paths = resolveOpenClawUpdatePaths({ scope, cwd, env })
|
|
658
|
+
} catch {
|
|
659
|
+
return { exitCode: 0, status: 'state_unavailable' }
|
|
660
|
+
}
|
|
661
|
+
let lock
|
|
662
|
+
try {
|
|
663
|
+
lock = await acquireLock(paths.lockPath)
|
|
664
|
+
} catch {
|
|
665
|
+
return { exitCode: 0, status: 'state_unavailable' }
|
|
666
|
+
}
|
|
667
|
+
if (!lock) return { exitCode: 0, status: 'busy' }
|
|
668
|
+
try {
|
|
669
|
+
const local = await readEnrollmentAndRuntime(paths, scope)
|
|
670
|
+
if (local.enrollmentState !== 'enrolled') return { exitCode: 0, status: 'not_enrolled' }
|
|
671
|
+
if (
|
|
672
|
+
local.runtimeState !== 'valid'
|
|
673
|
+
|| local.runtime?.status !== 'pending_confirmation'
|
|
674
|
+
|| !STABLE_SEMVER.test(local.runtime?.candidateVersion || '')
|
|
675
|
+
|| !STABLE_SEMVER.test(local.runtime?.fromVersion || '')
|
|
676
|
+
|| !/^[a-f0-9]{64}$/.test(local.runtime?.fromFingerprint || '')
|
|
677
|
+
) return { exitCode: 0, status: 'confirmation_missing' }
|
|
678
|
+
|
|
679
|
+
const before = await readManagedOrigin(paths.originPath)
|
|
680
|
+
if (
|
|
681
|
+
!before.ok
|
|
682
|
+
|| before.version !== local.runtime.fromVersion
|
|
683
|
+
|| before.fingerprint !== local.runtime.fromFingerprint
|
|
684
|
+
) {
|
|
685
|
+
return {
|
|
686
|
+
exitCode: UPDATED_EXIT_CODE,
|
|
687
|
+
status: before.ok ? 'origin_changed' : 'origin_indeterminate',
|
|
688
|
+
message: 'Deyo Skill origin changed or became unreadable after confirmation; invoke /deyo again before transcription.',
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const deadline = now + TIMEOUT_MS
|
|
693
|
+
let verified
|
|
694
|
+
try {
|
|
695
|
+
verified = await verifyLatest({ before, scope, cwd, env, deadline, run })
|
|
696
|
+
} catch {
|
|
697
|
+
return { exitCode: 0, status: 'verification_failed' }
|
|
698
|
+
}
|
|
699
|
+
if (!verified.ok) return { exitCode: 0, status: 'verification_failed' }
|
|
700
|
+
if (verified.candidateVersion !== local.runtime.candidateVersion) {
|
|
701
|
+
const refreshed = pendingRuntime(
|
|
702
|
+
baseRuntime(
|
|
703
|
+
paths.identity,
|
|
704
|
+
local.runtime.attempt + 1,
|
|
705
|
+
new Date(now).toISOString(),
|
|
706
|
+
before,
|
|
707
|
+
'pending_confirmation',
|
|
708
|
+
),
|
|
709
|
+
verified.candidateVersion,
|
|
710
|
+
new Date().toISOString(),
|
|
711
|
+
)
|
|
712
|
+
try {
|
|
713
|
+
await writePrivateJson(paths.runtimePath, refreshed)
|
|
714
|
+
} catch {
|
|
715
|
+
return { exitCode: 0, status: 'state_unavailable' }
|
|
716
|
+
}
|
|
717
|
+
return {
|
|
718
|
+
exitCode: CONFIRMATION_EXIT_CODE,
|
|
719
|
+
status: 'confirmation_stale',
|
|
720
|
+
fromVersion: before.version,
|
|
721
|
+
candidateVersion: verified.candidateVersion,
|
|
722
|
+
message: `Deyo Skill latest candidate changed to ${verified.candidateVersion}; source=ClawHub owner=${OWNER}; security=pass/clean. Ask the current user again: 是否现在更新Deyo到latest?`,
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const scopeArgs = scope === 'global' ? ['--global'] : []
|
|
727
|
+
let update
|
|
728
|
+
let indeterminate = false
|
|
729
|
+
try {
|
|
730
|
+
update = await run('openclaw', [
|
|
731
|
+
'skills', 'update', OWNER_QUALIFIED_SKILL, ...scopeArgs,
|
|
732
|
+
], { deadline, cwd, env })
|
|
733
|
+
} catch {
|
|
734
|
+
indeterminate = true
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const after = await readManagedOrigin(paths.originPath)
|
|
738
|
+
const changed = after.ok && (
|
|
739
|
+
after.version !== before.version || after.fingerprint !== before.fingerprint
|
|
740
|
+
)
|
|
741
|
+
const knownFailureUnchanged = !indeterminate && after.ok && !changed && update?.code !== 0
|
|
742
|
+
const reloadRequired = !knownFailureUnchanged
|
|
743
|
+
const status = indeterminate || !after.ok
|
|
744
|
+
? 'update_indeterminate'
|
|
745
|
+
: changed
|
|
746
|
+
? after.version === verified.candidateVersion ? 'updated' : 'updated_other_trusted'
|
|
747
|
+
: update?.code === 0 ? 'update_indeterminate' : 'update_failed'
|
|
748
|
+
const finalState = {
|
|
749
|
+
...baseRuntime(
|
|
750
|
+
paths.identity,
|
|
751
|
+
local.runtime.attempt + 1,
|
|
752
|
+
new Date(now).toISOString(),
|
|
753
|
+
before,
|
|
754
|
+
status,
|
|
755
|
+
),
|
|
756
|
+
status,
|
|
757
|
+
candidateVersion: verified.candidateVersion,
|
|
758
|
+
candidateVerifiedAt: new Date().toISOString(),
|
|
759
|
+
verificationDecision: 'pass',
|
|
760
|
+
securityStatus: 'clean',
|
|
761
|
+
toVersion: after.ok ? after.version : null,
|
|
762
|
+
toFingerprint: after.ok ? after.fingerprint : null,
|
|
763
|
+
...(reloadRequired ? { reloadRequiredAt: new Date().toISOString() } : {}),
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
await writePrivateJson(paths.runtimePath, finalState)
|
|
767
|
+
} catch {
|
|
768
|
+
if (!reloadRequired) return { exitCode: 0, status: 'state_unavailable' }
|
|
769
|
+
}
|
|
770
|
+
if (reloadRequired) {
|
|
771
|
+
return {
|
|
772
|
+
exitCode: UPDATED_EXIT_CODE,
|
|
773
|
+
status,
|
|
774
|
+
fromVersion: before.version,
|
|
775
|
+
toVersion: after.ok ? after.version : null,
|
|
776
|
+
message: 'Deyo Skill may have changed; invoke Deyo again to load or re-check the active version.',
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return { exitCode: 0, status, fromVersion: before.version, toVersion: before.version }
|
|
780
|
+
} catch {
|
|
781
|
+
return { exitCode: 0, status: 'update_failed' }
|
|
782
|
+
} finally {
|
|
783
|
+
await lock.close().catch(() => {})
|
|
784
|
+
await rm(paths.lockPath, { force: true })
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export const __test = {
|
|
789
|
+
CONFIRM_LATEST_PHRASE,
|
|
790
|
+
CONFIRMATION_EXIT_CODE,
|
|
791
|
+
OWNER_QUALIFIED_SKILL,
|
|
792
|
+
UPDATED_EXIT_CODE,
|
|
793
|
+
writePrivateJson,
|
|
794
|
+
}
|