ethagent 3.0.2 → 3.1.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.
Files changed (69) hide show
  1. package/README.md +6 -1
  2. package/package.json +3 -1
  3. package/src/app/FirstRun.tsx +1 -24
  4. package/src/app/firstRunConfig.ts +26 -0
  5. package/src/auth/openaiOAuth/landingPage.ts +2 -11
  6. package/src/chat/ChatScreen.tsx +15 -116
  7. package/src/chat/MessageList.tsx +18 -260
  8. package/src/chat/chatEnvironment.ts +16 -0
  9. package/src/chat/chatTurnContext.ts +50 -0
  10. package/src/chat/chatTurnOrchestrator.ts +5 -112
  11. package/src/chat/chatTurnRows.ts +64 -0
  12. package/src/chat/commands.ts +3 -178
  13. package/src/chat/continuityEditReview.ts +42 -0
  14. package/src/chat/input/ChatInput.tsx +10 -144
  15. package/src/chat/input/chatInputHelpers.ts +62 -0
  16. package/src/chat/input/inputRendering.tsx +93 -0
  17. package/src/chat/messageMarkdown.ts +220 -0
  18. package/src/chat/messageRows.ts +43 -0
  19. package/src/chat/planImplementation.ts +62 -0
  20. package/src/chat/slashCommandHandlers.ts +165 -0
  21. package/src/chat/slashCommandViews.ts +120 -0
  22. package/src/identity/continuity/challenges.ts +123 -0
  23. package/src/identity/continuity/envelope.ts +49 -1484
  24. package/src/identity/continuity/envelopeCreate.ts +322 -0
  25. package/src/identity/continuity/envelopeCrypto.ts +182 -0
  26. package/src/identity/continuity/envelopeParse.ts +441 -0
  27. package/src/identity/continuity/envelopeTypes.ts +204 -0
  28. package/src/identity/continuity/envelopeVersion.ts +1 -0
  29. package/src/identity/continuity/payloadNormalization.ts +183 -0
  30. package/src/identity/continuity/skills/loadSkills.ts +12 -69
  31. package/src/identity/continuity/skills/skillPaths.ts +76 -0
  32. package/src/identity/continuity/skillsNormalization.ts +119 -0
  33. package/src/identity/continuity/snapshotToken.ts +28 -0
  34. package/src/identity/hub/continuity/completion.ts +67 -0
  35. package/src/identity/hub/continuity/effects.ts +5 -62
  36. package/src/identity/hub/profile/effects.ts +6 -170
  37. package/src/identity/hub/profile/operatorSave.ts +202 -0
  38. package/src/identity/wallet/browserWallet/html.ts +1 -57
  39. package/src/identity/wallet/browserWallet/walletPageSource.ts +85 -0
  40. package/src/identity/wallet/page/controller.ts +1 -1
  41. package/src/identity/wallet/page/errorView.ts +122 -0
  42. package/src/identity/wallet/page/view.ts +3 -114
  43. package/src/mcp/manager.ts +8 -66
  44. package/src/mcp/managerHelpers.ts +70 -0
  45. package/src/models/ModelPicker.tsx +69 -889
  46. package/src/models/huggingface.ts +20 -137
  47. package/src/models/huggingfaceStorage.ts +136 -0
  48. package/src/models/llamacpp.ts +37 -303
  49. package/src/models/llamacppCommands.ts +44 -0
  50. package/src/models/llamacppConfig.ts +34 -0
  51. package/src/models/llamacppDiscovery.ts +176 -0
  52. package/src/models/llamacppOutput.ts +65 -0
  53. package/src/models/modelPickerCatalogFlow.ts +56 -0
  54. package/src/models/modelPickerCredentials.ts +166 -0
  55. package/src/models/modelPickerData.ts +41 -0
  56. package/src/models/modelPickerDisplay.tsx +132 -0
  57. package/src/models/modelPickerHfFlow.ts +192 -0
  58. package/src/models/modelPickerLocalRunnerFlow.ts +115 -0
  59. package/src/models/modelPickerTypes.ts +69 -0
  60. package/src/models/modelPickerUninstallFlow.ts +48 -0
  61. package/src/models/modelPickerViewHelpers.ts +174 -0
  62. package/src/providers/openai-chat.ts +5 -124
  63. package/src/providers/openaiChatWire.ts +124 -0
  64. package/src/runtime/providerTurn.ts +38 -0
  65. package/src/runtime/textToolParser.ts +161 -0
  66. package/src/runtime/toolIntent.ts +1 -1
  67. package/src/runtime/turn.ts +43 -499
  68. package/src/runtime/turnNudges.ts +223 -0
  69. package/src/runtime/turnTypes.ts +86 -0
@@ -0,0 +1,183 @@
1
+ import { toChecksumAddress } from '../crypto/eth.js'
2
+ import { normalizeContinuitySkills } from './skillsNormalization.js'
3
+ import type {
4
+ ContinuityAgentSnapshot,
5
+ ContinuityFiles,
6
+ ContinuitySnapshotPayload,
7
+ TransferContinuitySnapshotSlot,
8
+ WalletContinuityRestoreAccessKey,
9
+ WalletContinuitySnapshotSlot,
10
+ } from './envelope.js'
11
+
12
+ type ContinuityTranscriptSummary = {
13
+ sessionId?: string
14
+ createdAt?: string
15
+ summary: string
16
+ }
17
+
18
+ export function continuityPayloadFromArgs(args: {
19
+ ownerAddress: string
20
+ createdAt: string
21
+ payload: Omit<ContinuitySnapshotPayload, 'version' | 'ownerAddress' | 'createdAt'> & { createdAt?: string }
22
+ }): ContinuitySnapshotPayload {
23
+ const skills = normalizeContinuitySkills(args.payload.skills)
24
+ return {
25
+ version: 1,
26
+ ownerAddress: args.ownerAddress,
27
+ createdAt: args.createdAt,
28
+ ...(args.payload.sequence !== undefined ? { sequence: args.payload.sequence } : {}),
29
+ agent: normalizeAgentSnapshot(args.payload.agent),
30
+ files: normalizeContinuityFiles(args.payload.files),
31
+ ...(skills ? { skills } : {}),
32
+ transcript: normalizeTranscript(args.payload.transcript),
33
+ state: normalizeState(args.payload.state),
34
+ }
35
+ }
36
+
37
+ export function normalizeContinuityPayload(input: unknown): ContinuitySnapshotPayload {
38
+ if (!input || typeof input !== 'object') throw new Error('Continuity snapshot payload is invalid')
39
+ const obj = input as Partial<ContinuitySnapshotPayload>
40
+ if (obj.version !== 1) throw new Error('Continuity snapshot payload version is invalid')
41
+ if (typeof obj.ownerAddress !== 'string') throw new Error('Continuity snapshot owner is invalid')
42
+ if (typeof obj.createdAt !== 'string') throw new Error('Continuity snapshot timestamp is invalid')
43
+ const skills = normalizeContinuitySkills(obj.skills)
44
+ return {
45
+ version: 1,
46
+ ownerAddress: toChecksumAddress(obj.ownerAddress),
47
+ createdAt: obj.createdAt,
48
+ ...(typeof obj.sequence === 'number' && Number.isSafeInteger(obj.sequence) ? { sequence: obj.sequence } : {}),
49
+ agent: normalizeAgentSnapshot(obj.agent),
50
+ files: normalizeContinuityFiles(obj.files),
51
+ ...(skills ? { skills } : {}),
52
+ transcript: normalizeTranscript(obj.transcript),
53
+ state: normalizeState(obj.state),
54
+ }
55
+ }
56
+
57
+ export function normalizeAgentSnapshot(input: unknown): ContinuityAgentSnapshot {
58
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
59
+ const obj = input as Record<string, unknown>
60
+ return {
61
+ ...(typeof obj.chainId === 'number' && Number.isSafeInteger(obj.chainId) && obj.chainId > 0 ? { chainId: obj.chainId } : {}),
62
+ ...(typeof obj.identityRegistryAddress === 'string' ? { identityRegistryAddress: obj.identityRegistryAddress } : {}),
63
+ ...(typeof obj.agentId === 'string' ? { agentId: obj.agentId } : {}),
64
+ ...(typeof obj.agentUri === 'string' ? { agentUri: obj.agentUri } : {}),
65
+ ...(typeof obj.metadataCid === 'string' ? { metadataCid: obj.metadataCid } : {}),
66
+ ...(typeof obj.name === 'string' ? { name: obj.name } : {}),
67
+ ...(typeof obj.description === 'string' ? { description: obj.description } : {}),
68
+ }
69
+ }
70
+
71
+ export function normalizeContinuityFiles(input: unknown): ContinuityFiles {
72
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
73
+ throw new Error('Continuity snapshot files are invalid')
74
+ }
75
+ const obj = input as Partial<ContinuityFiles>
76
+ if (typeof obj['SOUL.md'] !== 'string') throw new Error('SOUL.md is missing from continuity snapshot')
77
+ if (typeof obj['MEMORY.md'] !== 'string') throw new Error('MEMORY.md is missing from continuity snapshot')
78
+ return {
79
+ 'SOUL.md': obj['SOUL.md'],
80
+ 'MEMORY.md': obj['MEMORY.md'],
81
+ }
82
+ }
83
+
84
+ export function normalizeTranscript(input: unknown): ContinuityTranscriptSummary[] {
85
+ if (!Array.isArray(input)) return []
86
+ return input.flatMap(item => {
87
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return []
88
+ const obj = item as Partial<ContinuityTranscriptSummary>
89
+ if (typeof obj.summary !== 'string' || !obj.summary.trim()) return []
90
+ return [{
91
+ ...(typeof obj.sessionId === 'string' ? { sessionId: obj.sessionId } : {}),
92
+ ...(typeof obj.createdAt === 'string' ? { createdAt: obj.createdAt } : {}),
93
+ summary: obj.summary,
94
+ }]
95
+ })
96
+ }
97
+
98
+ export function normalizeState(input: unknown): Record<string, unknown> {
99
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
100
+ return input as Record<string, unknown>
101
+ }
102
+
103
+ export function normalizeTransferSlot(input: unknown, expectedAddress: string): TransferContinuitySnapshotSlot {
104
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
105
+ throw new Error('Continuity transfer slot is invalid')
106
+ }
107
+ const obj = input as Partial<TransferContinuitySnapshotSlot>
108
+ if (typeof obj.address !== 'string') throw new Error('Continuity transfer slot address is invalid')
109
+ const address = toChecksumAddress(obj.address)
110
+ if (address.toLowerCase() !== expectedAddress.toLowerCase()) {
111
+ throw new Error('Continuity transfer slot address mismatch')
112
+ }
113
+ if (typeof obj.challenge !== 'string') throw new Error('Continuity transfer slot challenge is invalid')
114
+ if (typeof obj.salt !== 'string') throw new Error('Continuity transfer slot salt is invalid')
115
+ if (typeof obj.nonce !== 'string') throw new Error('Continuity transfer slot nonce is invalid')
116
+ if (typeof obj.encryptedKey !== 'string') throw new Error('Continuity transfer slot key is invalid')
117
+ if (typeof obj.tag !== 'string') throw new Error('Continuity transfer slot tag is invalid')
118
+ return {
119
+ address,
120
+ challenge: obj.challenge,
121
+ salt: obj.salt,
122
+ nonce: obj.nonce,
123
+ encryptedKey: obj.encryptedKey,
124
+ tag: obj.tag,
125
+ }
126
+ }
127
+
128
+ export function normalizeWalletRestoreAccessKeys(input: unknown): WalletContinuityRestoreAccessKey[] {
129
+ if (!Array.isArray(input)) return []
130
+ const out: WalletContinuityRestoreAccessKey[] = []
131
+ const seen = new Set<string>()
132
+ for (const item of input) {
133
+ const key = normalizeWalletRestoreAccessKey(item)
134
+ const dedupe = key.address.toLowerCase()
135
+ if (seen.has(dedupe)) continue
136
+ seen.add(dedupe)
137
+ out.push(key)
138
+ }
139
+ return out.sort((a, b) => a.address.toLowerCase().localeCompare(b.address.toLowerCase()))
140
+ }
141
+
142
+ export function normalizeWalletRestoreAccessKey(input: unknown): WalletContinuityRestoreAccessKey {
143
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
144
+ throw new Error('Wallet restore access key is invalid')
145
+ }
146
+ const obj = input as Partial<WalletContinuityRestoreAccessKey>
147
+ if (typeof obj.address !== 'string') throw new Error('Wallet restore access address is invalid')
148
+ if (typeof obj.challenge !== 'string') throw new Error('Wallet restore access challenge is invalid')
149
+ if (typeof obj.salt !== 'string') throw new Error('Wallet restore access salt is invalid')
150
+ if (typeof obj.kemPublicKey !== 'string') throw new Error('Wallet restore access public key is invalid')
151
+ return {
152
+ address: toChecksumAddress(obj.address),
153
+ challenge: obj.challenge,
154
+ salt: obj.salt,
155
+ kemPublicKey: obj.kemPublicKey,
156
+ ...(typeof obj.createdAt === 'string' ? { createdAt: obj.createdAt } : {}),
157
+ }
158
+ }
159
+
160
+ export function normalizeWalletSlot(input: unknown): WalletContinuitySnapshotSlot {
161
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
162
+ throw new Error('Continuity wallet slot is invalid')
163
+ }
164
+ const obj = input as Partial<WalletContinuitySnapshotSlot>
165
+ if (typeof obj.address !== 'string') throw new Error('Continuity wallet slot address is invalid')
166
+ if (typeof obj.challenge !== 'string') throw new Error('Continuity wallet slot challenge is invalid')
167
+ if (typeof obj.salt !== 'string') throw new Error('Continuity wallet slot salt is invalid')
168
+ if (typeof obj.kemPublicKey !== 'string') throw new Error('Continuity wallet slot public key is invalid')
169
+ if (typeof obj.kemCiphertext !== 'string') throw new Error('Continuity wallet slot ciphertext is invalid')
170
+ if (typeof obj.nonce !== 'string') throw new Error('Continuity wallet slot nonce is invalid')
171
+ if (typeof obj.encryptedKey !== 'string') throw new Error('Continuity wallet slot key is invalid')
172
+ if (typeof obj.tag !== 'string') throw new Error('Continuity wallet slot tag is invalid')
173
+ return {
174
+ address: toChecksumAddress(obj.address),
175
+ challenge: obj.challenge,
176
+ salt: obj.salt,
177
+ kemPublicKey: obj.kemPublicKey,
178
+ kemCiphertext: obj.kemCiphertext,
179
+ nonce: obj.nonce,
180
+ encryptedKey: obj.encryptedKey,
181
+ tag: obj.tag,
182
+ }
183
+ }
@@ -6,6 +6,16 @@ import { ensureContinuityVault } from '../storage/files.js'
6
6
  import { continuityVaultRef } from '../storage/paths.js'
7
7
  import { parseSkillFile } from './frontmatter.js'
8
8
  import { defaultSkillScaffold } from './scaffold.js'
9
+ import {
10
+ isReservedWindowsSegment,
11
+ isValidFilenameSegment,
12
+ isValidSegment,
13
+ isValidSkillEntryKey,
14
+ isValidSkillFilePath,
15
+ isWithin,
16
+ MAX_FOLDER_DEPTH,
17
+ SKILL_FILE_NAME,
18
+ } from './skillPaths.js'
9
19
  import type {
10
20
  ContinuitySkillsTree,
11
21
  Skill,
@@ -13,18 +23,9 @@ import type {
13
23
  SkillVisibility,
14
24
  } from './types.js'
15
25
 
16
- const SKILL_FILE_NAME = 'SKILL.md'
17
- const SEGMENT_RE = /^[A-Za-z0-9._-]+$/
18
- const FILE_EXT_RE = /\.[A-Za-z0-9]+$/
19
- const RESERVED_WINDOWS_SEGMENTS = new Set([
20
- 'con', 'prn', 'aux', 'nul',
21
- 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
22
- 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
23
- ])
24
26
  const MAX_SKILL_ENTRIES = 200
25
27
  const MAX_SKILL_FILE_BYTES = 256 * 1024
26
28
  const MAX_TREE_FILES = 500
27
- const MAX_FOLDER_DEPTH = 4
28
29
 
29
30
  type IdentityKey = Pick<EthagentIdentity, 'chainId' | 'identityRegistryAddress' | 'agentId' | 'address'>
30
31
 
@@ -135,13 +136,6 @@ async function walkSkillFileStats(root: string): Promise<SkillFileStat[]> {
135
136
  return out
136
137
  }
137
138
 
138
- function isValidSegment(name: string): boolean {
139
- if (!name) return false
140
- if (name.startsWith('.')) return false
141
- if (RESERVED_WINDOWS_SEGMENTS.has(name.toLowerCase())) return false
142
- return SEGMENT_RE.test(name)
143
- }
144
-
145
139
  export async function readSkill(identity: EthagentIdentity, name: string): Promise<Skill> {
146
140
  const entries = await listSkills(identity)
147
141
  const lookup = name.replace(/^.*:/, '').replace(/:SKILL$/i, '')
@@ -444,7 +438,7 @@ async function walkFolderFiles(
444
438
  for (const ent of dirents) {
445
439
  if (ent.isSymbolicLink()) continue
446
440
  if (ent.name.startsWith('.')) continue
447
- if (RESERVED_WINDOWS_SEGMENTS.has(ent.name.toLowerCase())) continue
441
+ if (isReservedWindowsSegment(ent.name)) continue
448
442
  if (ent.isDirectory()) {
449
443
  if (!isValidSegment(ent.name)) continue
450
444
  const nextPrefix = relativePrefix ? `${relativePrefix}/${ent.name}` : ent.name
@@ -465,14 +459,6 @@ async function walkFolderFiles(
465
459
  }
466
460
  }
467
461
 
468
- function isValidFilenameSegment(name: string): boolean {
469
- if (!name) return false
470
- if (name.startsWith('.')) return false
471
- if (RESERVED_WINDOWS_SEGMENTS.has(name.toLowerCase())) return false
472
- if (!SEGMENT_RE.test(name)) return false
473
- return FILE_EXT_RE.test(name)
474
- }
475
-
476
462
  async function statOrNull(file: string): Promise<import('node:fs').Stats | null> {
477
463
  try {
478
464
  return await fs.stat(file)
@@ -563,47 +549,4 @@ async function loadSkillBody(entry: SkillIndexEntry): Promise<Skill> {
563
549
  return { ...entry, body: parsed.body }
564
550
  }
565
551
 
566
- export function isValidSkillEntryKey(rel: string): boolean {
567
- if (!rel || rel.length > 256) return false
568
- if (rel.includes('\0')) return false
569
- if (rel.startsWith('/') || rel.startsWith('\\')) return false
570
- if (/^[a-zA-Z]:/.test(rel)) return false
571
- const segments = rel.split('/')
572
- if (segments.length !== 2) return false
573
- const [name, filename] = segments
574
- if (!name || !filename) return false
575
- if (filename !== SKILL_FILE_NAME) return false
576
- if (!isValidSegment(name)) return false
577
- return true
578
- }
579
-
580
- export function isValidSkillFilePath(rel: string): boolean {
581
- if (!rel || rel.length > 256) return false
582
- if (rel.includes('\0')) return false
583
- if (rel.startsWith('/') || rel.startsWith('\\')) return false
584
- if (/^[a-zA-Z]:/.test(rel)) return false
585
- const segments = rel.split('/')
586
- if (segments.length < 2) return false
587
- if (segments.length > MAX_FOLDER_DEPTH + 2) return false
588
- const [first, ...rest] = segments
589
- if (!first || !isValidSegment(first)) return false
590
- for (let i = 0; i < rest.length; i++) {
591
- const seg = rest[i]
592
- if (!seg) return false
593
- if (i === rest.length - 1) {
594
- if (seg === SKILL_FILE_NAME) continue
595
- if (!isValidFilenameSegment(seg)) return false
596
- } else {
597
- if (!isValidSegment(seg)) return false
598
- }
599
- }
600
- return true
601
- }
602
-
603
- function isWithin(root: string, target: string): boolean {
604
- const rootResolved = path.resolve(root)
605
- const targetResolved = path.resolve(target)
606
- if (targetResolved === rootResolved) return true
607
- const prefix = rootResolved.endsWith(path.sep) ? rootResolved : rootResolved + path.sep
608
- return targetResolved.startsWith(prefix)
609
- }
552
+ export { isValidSkillEntryKey, isValidSkillFilePath } from './skillPaths.js'
@@ -0,0 +1,76 @@
1
+ import path from 'node:path'
2
+
3
+ export const SKILL_FILE_NAME = 'SKILL.md'
4
+ export const MAX_FOLDER_DEPTH = 4
5
+
6
+ const SEGMENT_RE = /^[A-Za-z0-9._-]+$/
7
+ const FILE_EXT_RE = /\.[A-Za-z0-9]+$/
8
+ const RESERVED_WINDOWS_SEGMENTS = new Set([
9
+ 'con', 'prn', 'aux', 'nul',
10
+ 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
11
+ 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
12
+ ])
13
+
14
+ export function isReservedWindowsSegment(name: string): boolean {
15
+ return RESERVED_WINDOWS_SEGMENTS.has(name.toLowerCase())
16
+ }
17
+
18
+ export function isValidSegment(name: string): boolean {
19
+ if (!name) return false
20
+ if (name.startsWith('.')) return false
21
+ if (isReservedWindowsSegment(name)) return false
22
+ return SEGMENT_RE.test(name)
23
+ }
24
+
25
+ export function isValidFilenameSegment(name: string): boolean {
26
+ if (!name) return false
27
+ if (name.startsWith('.')) return false
28
+ if (isReservedWindowsSegment(name)) return false
29
+ if (!SEGMENT_RE.test(name)) return false
30
+ return FILE_EXT_RE.test(name)
31
+ }
32
+
33
+ export function isValidSkillEntryKey(rel: string): boolean {
34
+ if (!rel || rel.length > 256) return false
35
+ if (rel.includes('\0')) return false
36
+ if (rel.startsWith('/') || rel.startsWith('\\')) return false
37
+ if (/^[a-zA-Z]:/.test(rel)) return false
38
+ const segments = rel.split('/')
39
+ if (segments.length !== 2) return false
40
+ const [name, filename] = segments
41
+ if (!name || !filename) return false
42
+ if (filename !== SKILL_FILE_NAME) return false
43
+ if (!isValidSegment(name)) return false
44
+ return true
45
+ }
46
+
47
+ export function isValidSkillFilePath(rel: string): boolean {
48
+ if (!rel || rel.length > 256) return false
49
+ if (rel.includes('\0')) return false
50
+ if (rel.startsWith('/') || rel.startsWith('\\')) return false
51
+ if (/^[a-zA-Z]:/.test(rel)) return false
52
+ const segments = rel.split('/')
53
+ if (segments.length < 2) return false
54
+ if (segments.length > MAX_FOLDER_DEPTH + 2) return false
55
+ const [first, ...rest] = segments
56
+ if (!first || !isValidSegment(first)) return false
57
+ for (let i = 0; i < rest.length; i++) {
58
+ const seg = rest[i]
59
+ if (!seg) return false
60
+ if (i === rest.length - 1) {
61
+ if (seg === SKILL_FILE_NAME) continue
62
+ if (!isValidFilenameSegment(seg)) return false
63
+ } else {
64
+ if (!isValidSegment(seg)) return false
65
+ }
66
+ }
67
+ return true
68
+ }
69
+
70
+ export function isWithin(root: string, target: string): boolean {
71
+ const rootResolved = path.resolve(root)
72
+ const targetResolved = path.resolve(target)
73
+ if (targetResolved === rootResolved) return true
74
+ const prefix = rootResolved.endsWith(path.sep) ? rootResolved : rootResolved + path.sep
75
+ return targetResolved.startsWith(prefix)
76
+ }
@@ -0,0 +1,119 @@
1
+ import type { ContinuitySkillsTree } from './envelope.js'
2
+
3
+ const PRIVATE_SKILL_FILE_RE = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+$/
4
+ const PRIVATE_SKILL_LAST_SEG_FILE_RE = /^[A-Za-z0-9._-]+\.[A-Za-z0-9]+$/
5
+ const LEGACY_NESTED_SKILL_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\/.+$/
6
+ const LEGACY_FLAT_NAME_MD_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\.md$/i
7
+ const MAX_PRIVATE_SKILL_ENTRIES = 500
8
+ const MAX_PRIVATE_SKILL_BODY_BYTES = 256 * 1024
9
+ const MAX_PRIVATE_SKILL_PATH_LEN = 256
10
+
11
+ export function normalizeContinuitySkills(input: unknown): ContinuitySkillsTree | undefined {
12
+ if (input === undefined || input === null) return undefined
13
+ if (typeof input !== 'object' || Array.isArray(input)) return undefined
14
+ const obj = input as Record<string, unknown>
15
+ const out: ContinuitySkillsTree = {}
16
+ let count = 0
17
+ const tryInsert = (key: string, rawValue: unknown): void => {
18
+ if (count >= MAX_PRIVATE_SKILL_ENTRIES) return
19
+ if (typeof rawValue !== 'string') return
20
+ if (key.length === 0 || key.length > MAX_PRIVATE_SKILL_PATH_LEN) return
21
+ if (key.includes('\0')) return
22
+ if (key.includes('..')) return
23
+ if (key.startsWith('/')) return
24
+ if (/^[A-Za-z]:/.test(key)) return
25
+ if (!isAcceptableSkillKey(key)) return
26
+ if (Buffer.byteLength(rawValue, 'utf8') > MAX_PRIVATE_SKILL_BODY_BYTES) return
27
+ if (out[key] !== undefined) return
28
+ out[key] = rawValue
29
+ count++
30
+ }
31
+ const legacyRoots = new Set<string>()
32
+ const realSkillFolders = new Set<string>()
33
+ for (const rawKey of Object.keys(obj)) {
34
+ const key = rawKey.replace(/\\/g, '/')
35
+ const segments = key.split('/')
36
+ if (segments.length === 3 && segments[2] === 'SKILL.md' && segments[0] && segments[1]) {
37
+ legacyRoots.add(`${segments[0]}/${segments[1]}`)
38
+ }
39
+ if (segments.length === 2 && segments[1] === 'SKILL.md' && segments[0]) {
40
+ realSkillFolders.add(segments[0])
41
+ }
42
+ }
43
+ for (const [rawKey, rawValue] of Object.entries(obj)) {
44
+ const key = rawKey.replace(/\\/g, '/')
45
+ if (!isCanonicalFlatKey(key)) continue
46
+ if (isUnderLegacyRoot(key, legacyRoots)) continue
47
+ if (!keyHasRealSkillFolder(key, realSkillFolders)) continue
48
+ tryInsert(key, rawValue)
49
+ }
50
+ for (const [rawKey, rawValue] of Object.entries(obj)) {
51
+ const key = rawKey.replace(/\\/g, '/')
52
+ if (
53
+ isCanonicalFlatKey(key)
54
+ && !isUnderLegacyRoot(key, legacyRoots)
55
+ && keyHasRealSkillFolder(key, realSkillFolders)
56
+ ) continue
57
+ const upgraded = upgradeLegacySkillKey(key, legacyRoots)
58
+ if (!upgraded) continue
59
+ tryInsert(upgraded, rawValue)
60
+ }
61
+ return count > 0 ? out : undefined
62
+ }
63
+
64
+ function isUnderLegacyRoot(key: string, legacyRoots: Set<string>): boolean {
65
+ for (const root of legacyRoots) {
66
+ if (key === `${root}/SKILL.md`) return true
67
+ if (key.startsWith(`${root}/`)) return true
68
+ }
69
+ return false
70
+ }
71
+
72
+ function keyHasRealSkillFolder(key: string, realSkillFolders: Set<string>): boolean {
73
+ const first = key.split('/')[0]
74
+ if (!first) return false
75
+ return realSkillFolders.has(first)
76
+ }
77
+
78
+ function isCanonicalFlatKey(key: string): boolean {
79
+ if (!PRIVATE_SKILL_FILE_RE.test(key)) return false
80
+ const segments = key.split('/')
81
+ if (segments.length < 2) return false
82
+ const last = segments[segments.length - 1]!
83
+ if (last === 'SKILL.md') return segments.length === 2
84
+ return PRIVATE_SKILL_LAST_SEG_FILE_RE.test(last)
85
+ }
86
+
87
+ function isAcceptableSkillKey(key: string): boolean {
88
+ return isCanonicalFlatKey(key)
89
+ }
90
+
91
+ function upgradeLegacySkillKey(key: string, legacyRoots: Set<string>): string | null {
92
+ for (const root of legacyRoots) {
93
+ if (key === `${root}/SKILL.md` || key.startsWith(`${root}/`)) {
94
+ const [first, second] = root.split('/')
95
+ if (!first || !second) continue
96
+ const rest = key.slice(root.length + 1)
97
+ const flattened = `${first}-${second}/${rest}`
98
+ return isCanonicalFlatKey(flattened) ? flattened : null
99
+ }
100
+ }
101
+ if (LEGACY_FLAT_NAME_MD_RE.test(key)) {
102
+ const [category, file] = key.split('/')
103
+ if (!category || !file) return null
104
+ const slug = file.replace(/\.md$/i, '')
105
+ if (!slug) return null
106
+ const flattened = `${category}-${slug}/SKILL.md`
107
+ return isCanonicalFlatKey(flattened) ? flattened : null
108
+ }
109
+ if (LEGACY_NESTED_SKILL_RE.test(key)) {
110
+ const segments = key.split('/')
111
+ if (segments.length < 3) return null
112
+ const [first, second, ...rest] = segments
113
+ if (!first || !second || rest.length === 0) return null
114
+ const flattened = `${first}-${second}/${rest.join('/')}`
115
+ return isCanonicalFlatKey(flattened) ? flattened : null
116
+ }
117
+ if (isCanonicalFlatKey(key)) return key
118
+ return null
119
+ }
@@ -0,0 +1,28 @@
1
+ import { toChecksumAddress } from '../crypto/eth.js'
2
+
3
+ export type ContinuitySnapshotToken = {
4
+ chainId: number
5
+ identityRegistryAddress: string
6
+ agentId: string
7
+ }
8
+
9
+ export function normalizeContinuitySnapshotToken(input: unknown): ContinuitySnapshotToken {
10
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
11
+ throw new Error('Continuity snapshot token is invalid')
12
+ }
13
+ const obj = input as Partial<ContinuitySnapshotToken>
14
+ if (typeof obj.chainId !== 'number' || !Number.isSafeInteger(obj.chainId) || obj.chainId <= 0) {
15
+ throw new Error('Continuity snapshot token chain is invalid')
16
+ }
17
+ if (typeof obj.identityRegistryAddress !== 'string') {
18
+ throw new Error('Continuity snapshot token registry is invalid')
19
+ }
20
+ if (typeof obj.agentId !== 'string' || !/^\d+$/.test(obj.agentId)) {
21
+ throw new Error('Continuity snapshot token id is invalid')
22
+ }
23
+ return {
24
+ chainId: obj.chainId,
25
+ identityRegistryAddress: toChecksumAddress(obj.identityRegistryAddress),
26
+ agentId: obj.agentId,
27
+ }
28
+ }
@@ -0,0 +1,67 @@
1
+ import type { EthagentIdentity } from '../../../storage/config.js'
2
+ import type { WalletPurpose } from '../../wallet/browserWallet.js'
3
+ import type { ProfileUpdates } from '../identityHubReducer.js'
4
+ import { snapshotSaveWalletRole } from './snapshot.js'
5
+
6
+ export function rebackupWalletPurpose(
7
+ identity: EthagentIdentity,
8
+ profileUpdates: ProfileUpdates | undefined,
9
+ ): WalletPurpose {
10
+ const role = snapshotSaveWalletRole(identity, profileUpdates)
11
+ const snapshotPurpose = role === 'operator'
12
+ ? 'update-snapshot-operator' as const
13
+ : role === 'owner'
14
+ ? 'update-snapshot-owner' as const
15
+ : 'update-snapshot-connected' as const
16
+ const profilePurpose = role === 'operator'
17
+ ? 'update-profile-operator' as const
18
+ : role === 'owner'
19
+ ? 'update-profile-owner' as const
20
+ : 'update-profile-connected' as const
21
+ if (!profileUpdates) return snapshotPurpose
22
+ const baseState = (identity.state ?? {}) as Record<string, unknown>
23
+ const currentEns = typeof baseState.ensName === 'string' ? baseState.ensName.trim() : ''
24
+ const ensTouched = typeof profileUpdates.ensName === 'string'
25
+ const profileFieldsTouched = profileUpdates.name !== undefined
26
+ || profileUpdates.description !== undefined
27
+ || profileUpdates.imagePath !== undefined
28
+ const operatorFieldsTouched = profileUpdates.ownerAddress !== undefined
29
+ || profileUpdates.approvedOperatorWallets !== undefined
30
+ || profileUpdates.activeOperatorAddress !== undefined
31
+ || profileUpdates.restoreAccessEpoch !== undefined
32
+ if (operatorFieldsTouched && !ensTouched && !profileFieldsTouched) return 'update-operators'
33
+ if (ensTouched && !profileFieldsTouched) {
34
+ const next = (profileUpdates.ensName ?? '').trim()
35
+ if (!next && currentEns) return 'clear-ens'
36
+ return 'update-ens'
37
+ }
38
+ if (profileFieldsTouched) return profilePurpose
39
+ return snapshotPurpose
40
+ }
41
+
42
+ export function rebackupCompletionMessage(
43
+ profileUpdates: ProfileUpdates | undefined,
44
+ identity: EthagentIdentity,
45
+ ensOk?: boolean,
46
+ ): string {
47
+ if (!profileUpdates) return 'Backup Saved'
48
+ const baseState = (identity.state ?? {}) as Record<string, unknown>
49
+ const currentEns = typeof baseState.ensName === 'string' ? baseState.ensName.trim() : ''
50
+ const ensTouched = typeof profileUpdates.ensName === 'string'
51
+ const profileFieldsTouched = profileUpdates.name !== undefined
52
+ || profileUpdates.description !== undefined
53
+ || profileUpdates.imagePath !== undefined
54
+ const operatorFieldsTouched = profileUpdates.ownerAddress !== undefined
55
+ || profileUpdates.approvedOperatorWallets !== undefined
56
+ || profileUpdates.activeOperatorAddress !== undefined
57
+ || profileUpdates.restoreAccessEpoch !== undefined
58
+ if (operatorFieldsTouched && !ensTouched && !profileFieldsTouched) return 'Operator Wallets Updated'
59
+ if (ensTouched && !profileFieldsTouched) {
60
+ const next = (profileUpdates.ensName ?? '').trim()
61
+ if (!next && currentEns) return 'ENS Unlinked'
62
+ if (next) return ensOk === false ? 'ENS Issue' : 'ENS Linked'
63
+ return 'ENS Updated'
64
+ }
65
+ if (profileFieldsTouched) return 'Profile Updated'
66
+ return 'Backup Saved'
67
+ }
@@ -77,6 +77,10 @@ import {
77
77
  syncVaultOperatorsAfterOwnerSave,
78
78
  } from '../shared/effects/sync.js'
79
79
  import { runOperatorWalletRebackup } from './vault.js'
80
+ import {
81
+ rebackupCompletionMessage,
82
+ rebackupWalletPurpose,
83
+ } from './completion.js'
80
84
 
81
85
  type BackupMetadata = NonNullable<EthagentIdentity['backup']>
82
86
  type PublicSkillsMetadata = NonNullable<EthagentIdentity['publicSkills']>
@@ -418,65 +422,4 @@ export async function runRebackupStorageSubmit(
418
422
  callbacks.onStep({ kind: 'rebackup-signing', identity: step.identity, registry: step.registry, pinataJwt, profileUpdates: step.profileUpdates, returnTo: step.returnTo, walletPurpose: step.walletPurpose, vaultAddress: step.vaultAddress })
419
423
  }
420
424
 
421
- function rebackupWalletPurpose(
422
- identity: EthagentIdentity,
423
- profileUpdates: ProfileUpdates | undefined,
424
- ): WalletPurpose {
425
- const role = snapshotSaveWalletRole(identity, profileUpdates)
426
- const snapshotPurpose = role === 'operator'
427
- ? 'update-snapshot-operator' as const
428
- : role === 'owner'
429
- ? 'update-snapshot-owner' as const
430
- : 'update-snapshot-connected' as const
431
- const profilePurpose = role === 'operator'
432
- ? 'update-profile-operator' as const
433
- : role === 'owner'
434
- ? 'update-profile-owner' as const
435
- : 'update-profile-connected' as const
436
- if (!profileUpdates) return snapshotPurpose
437
- const baseState = (identity.state ?? {}) as Record<string, unknown>
438
- const currentEns = typeof baseState.ensName === 'string' ? baseState.ensName.trim() : ''
439
- const ensTouched = typeof profileUpdates.ensName === 'string'
440
- const profileFieldsTouched = profileUpdates.name !== undefined
441
- || profileUpdates.description !== undefined
442
- || profileUpdates.imagePath !== undefined
443
- const operatorFieldsTouched = profileUpdates.ownerAddress !== undefined
444
- || profileUpdates.approvedOperatorWallets !== undefined
445
- || profileUpdates.activeOperatorAddress !== undefined
446
- || profileUpdates.restoreAccessEpoch !== undefined
447
- if (operatorFieldsTouched && !ensTouched && !profileFieldsTouched) return 'update-operators'
448
- if (ensTouched && !profileFieldsTouched) {
449
- const next = (profileUpdates.ensName ?? '').trim()
450
- if (!next && currentEns) return 'clear-ens'
451
- return 'update-ens'
452
- }
453
- if (profileFieldsTouched) return profilePurpose
454
- return snapshotPurpose
455
- }
456
-
457
- export function rebackupCompletionMessage(
458
- profileUpdates: ProfileUpdates | undefined,
459
- identity: EthagentIdentity,
460
- ensOk?: boolean,
461
- ): string {
462
- if (!profileUpdates) return 'Backup Saved'
463
- const baseState = (identity.state ?? {}) as Record<string, unknown>
464
- const currentEns = typeof baseState.ensName === 'string' ? baseState.ensName.trim() : ''
465
- const ensTouched = typeof profileUpdates.ensName === 'string'
466
- const profileFieldsTouched = profileUpdates.name !== undefined
467
- || profileUpdates.description !== undefined
468
- || profileUpdates.imagePath !== undefined
469
- const operatorFieldsTouched = profileUpdates.ownerAddress !== undefined
470
- || profileUpdates.approvedOperatorWallets !== undefined
471
- || profileUpdates.activeOperatorAddress !== undefined
472
- || profileUpdates.restoreAccessEpoch !== undefined
473
- if (operatorFieldsTouched && !ensTouched && !profileFieldsTouched) return 'Operator Wallets Updated'
474
- if (ensTouched && !profileFieldsTouched) {
475
- const next = (profileUpdates.ensName ?? '').trim()
476
- if (!next && currentEns) return 'ENS Unlinked'
477
- if (next) return ensOk === false ? 'ENS Issue' : 'ENS Linked'
478
- return 'ENS Updated'
479
- }
480
- if (profileFieldsTouched) return 'Profile Updated'
481
- return 'Backup Saved'
482
- }
425
+ export { rebackupCompletionMessage } from './completion.js'