@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.
@@ -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
+ }