diff --git a/package.json b/package.json index 4c90837..fb45922 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "build": "tsc --noEmit", "typecheck": "tsc --noEmit", "test": "node test/run-tests.mjs", + "test:coverage": "node test/run-tests.mjs --coverage", "previews": "tsx scripts/render-previews.ts", "prepack": "npm run build", "contracts:build": "cd contracts && forge build", diff --git a/src/app/keybindings/KeybindingProvider.tsx b/src/app/keybindings/KeybindingProvider.tsx index b400058..c1b65bb 100644 --- a/src/app/keybindings/KeybindingProvider.tsx +++ b/src/app/keybindings/KeybindingProvider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react' import type { Key } from 'ink' import { useAppInput } from '../input/AppInputProvider.js' import type { Action, Binding, KeybindingContextName } from './types.js' @@ -88,42 +88,3 @@ export function useKeybindingContext(): KeybindingContextValue { if (!ctx) throw new Error('Hook useKeybindingContext requires KeybindingProvider') return ctx } - -export function useOptionalKeybindingContext(): KeybindingContextValue | null { - return useContext(Ctx) -} - -type UseKeybindingOptions = { - context?: KeybindingContextName - isActive?: boolean -} - -export function useKeybinding( - action: Action, - handler: () => void, - options: UseKeybindingOptions = {}, -): void { - const ctx = useOptionalKeybindingContext() - const context = options.context ?? 'Global' - const isActive = options.isActive ?? true - const handlerRef = useRef(handler) - useEffect(() => { handlerRef.current = handler }, [handler]) - - useEffect(() => { - if (!ctx) return - const entry: HandlerEntry = { - handler: () => handlerRef.current(), - context, - isActive, - } - return ctx.register(action, entry) - }, [ctx, action, context, isActive]) -} - -export function useRegisterKeybindingContext(context: KeybindingContextName, isActive = true): void { - const ctx = useOptionalKeybindingContext() - useEffect(() => { - if (!ctx || !isActive) return - return ctx.activateContext(context) - }, [ctx, context, isActive]) -} diff --git a/src/identity/continuity/envelopeTypes.ts b/src/identity/continuity/envelopeTypes.ts index f76e4c0..3dfe399 100644 --- a/src/identity/continuity/envelopeTypes.ts +++ b/src/identity/continuity/envelopeTypes.ts @@ -169,12 +169,6 @@ export type CreateWalletContinuitySnapshotEnvelopeArgs = { } } -export type RestoreContinuitySnapshotEnvelopeArgs = { - envelope: ContinuitySnapshotEnvelope - walletSignature: string - currentOwnerAddress?: string -} - export class ContinuitySnapshotOwnerMismatchError extends Error { constructor( readonly snapshotOwner: string, diff --git a/src/identity/continuity/localKeyDecrypt.ts b/src/identity/continuity/localKeyDecrypt.ts index ee396df..0a388a5 100644 --- a/src/identity/continuity/localKeyDecrypt.ts +++ b/src/identity/continuity/localKeyDecrypt.ts @@ -92,14 +92,3 @@ export async function decryptWithSigner(envelope: ContinuitySnapshotEnvelope, si } return { ok: false, reason: 'signature-mismatch' } } - -export function envelopeChallengeFor(envelope: ContinuitySnapshotEnvelope, address: string): string | null { - const me = address.toLowerCase() - if (isWalletContinuitySnapshotEnvelope(envelope)) { - return envelope.slots.find(slot => slot.address.toLowerCase() === me)?.challenge ?? null - } - if (isTransferContinuitySnapshotEnvelope(envelope)) { - return [envelope.slots.owner, envelope.slots.target].find(slot => slot.address.toLowerCase() === me)?.challenge ?? null - } - return envelope.ownerAddress.toLowerCase() === me ? envelope.challenge : null -} diff --git a/src/identity/continuity/skills/frontmatter.ts b/src/identity/continuity/skills/frontmatter.ts index ffcc64b..f9d3398 100644 --- a/src/identity/continuity/skills/frontmatter.ts +++ b/src/identity/continuity/skills/frontmatter.ts @@ -42,22 +42,6 @@ function yamlQuote(value: string): string { return out + '"' } -export function serializeSkillFile(frontmatter: SkillFrontmatter, body: string): string { - const lines: string[] = ['---'] - if (frontmatter.name) lines.push(`name: ${yamlQuote(frontmatter.name)}`) - if (frontmatter.description) lines.push(`description: ${yamlQuote(frontmatter.description)}`) - if (frontmatter.whenToUse) lines.push(`when_to_use: ${yamlQuote(frontmatter.whenToUse)}`) - if (frontmatter.version) lines.push(`version: ${yamlQuote(frontmatter.version)}`) - if (frontmatter.argumentHint) lines.push(`argument-hint: ${yamlQuote(frontmatter.argumentHint)}`) - if (frontmatter.tags && frontmatter.tags.length > 0) { - lines.push(`tags: [${frontmatter.tags.map(yamlQuote).join(', ')}]`) - } - if (frontmatter.visibility) lines.push(`visibility: ${yamlQuote(frontmatter.visibility)}`) - lines.push('---', '') - const trimmedBody = body.trim() - return lines.join('\n') + (trimmedBody ? trimmedBody + '\n' : '') -} - export function sanitizeSkillFileForStrictYaml(content: string): string { const normalized = content.replace(/^/, '').replace(/\r\n?/g, '\n') if (!normalized.startsWith('---\n')) return content diff --git a/src/identity/continuity/skills/loadSkills.ts b/src/identity/continuity/skills/loadSkills.ts index 2c8115a..3ceef89 100644 --- a/src/identity/continuity/skills/loadSkills.ts +++ b/src/identity/continuity/skills/loadSkills.ts @@ -136,19 +136,6 @@ async function walkSkillFileStats(root: string): Promise { return out } -export async function readSkill(identity: EthagentIdentity, name: string): Promise { - const entries = await listSkills(identity) - const lookup = name.replace(/^.*:/, '').replace(/:SKILL$/i, '') - const match = entries.find(entry => - entry.name === name - || entry.name === lookup - || entry.displayName === name - || entry.displayName === lookup, - ) - if (!match) throw new Error(`unknown private skill: ${name}`) - return loadSkillBody(match) -} - export async function readSkillByRelativePath( identity: EthagentIdentity, relativePath: string, @@ -170,26 +157,6 @@ export async function readSkillByRelativePath( return { ...entry, body: parsed.body } } -export async function readSkillFile( - identity: EthagentIdentity, - skillName: string, - filePath: string, -): Promise<{ relativePath: string; absolutePath: string; content: string }> { - if (!isValidSegment(skillName)) throw new Error('skill name is invalid') - const ref = await ensureContinuityVault(identity) - const skillDir = path.join(ref.skillsDir, skillName) - const normalizedFile = filePath.replace(/\\/g, '/').replace(/^\/+/, '') - const rel = `${skillName}/${normalizedFile}` - if (!isValidSkillFilePath(rel)) throw new Error('skill file path is not allowed') - const absolute = path.resolve(skillDir, normalizedFile) - if (!isWithin(ref.skillsDir, absolute)) throw new Error('skill file path escapes the vault') - const stat = await statOrNull(absolute) - if (!stat || !stat.isFile()) throw new Error(`skill file not found: ${rel}`) - if (stat.size > MAX_SKILL_FILE_BYTES) throw new Error(`skill file too large: ${rel}`) - const content = await fs.readFile(absolute, 'utf8') - return { relativePath: rel, absolutePath: absolute, content } -} - export async function loadSkillsTree(identity: EthagentIdentity): Promise { const ref = await ensureContinuityVault(identity) await migrateLegacySkillFiles(ref.skillsDir) @@ -726,10 +693,4 @@ function pickDescription(fromFrontmatter: string | undefined, body: string): str return '' } -async function loadSkillBody(entry: SkillIndexEntry): Promise { - const raw = await fs.readFile(entry.absolutePath, 'utf8') - const parsed = parseSkillFile(raw) - return { ...entry, body: parsed.body } -} - export { isValidSkillEntryKey, isValidSkillFilePath } from './skillPaths.js' diff --git a/src/identity/continuity/storage/packed.ts b/src/identity/continuity/storage/packed.ts index 0e9d41b..8a4c34e 100644 --- a/src/identity/continuity/storage/packed.ts +++ b/src/identity/continuity/storage/packed.ts @@ -28,7 +28,6 @@ export type PackedWorkingView = { warnings: PackedWarning[] } -export const PRIVATE_SNAPSHOT_PATHS = ['SOUL.md', 'MEMORY.md'] as const export const AGENT_CARD_PATH = 'agent-card.json' export const SKILLS_PREFIX = 'skills/' @@ -45,18 +44,6 @@ export function packedFileMap(sources: PackedSources): Record { return files } -export function sourcesFromFileMap(files: Record): PackedSources { - const skills: ContinuitySkillsTree = {} - for (const [path, value] of Object.entries(files)) { - if (path.startsWith(SKILLS_PREFIX)) skills[path.slice(SKILLS_PREFIX.length)] = value - } - return { - privateFiles: { 'SOUL.md': files['SOUL.md'] ?? '', 'MEMORY.md': files['MEMORY.md'] ?? '' }, - ...(files[AGENT_CARD_PATH] !== undefined ? { agentCard: files[AGENT_CARD_PATH] } : {}), - ...(Object.keys(skills).length > 0 ? { skills } : {}), - } -} - export async function readPackedWorkingView( identity: EthagentIdentity, soulMemory?: { files: ContinuityFiles; pulled: string[] }, diff --git a/src/identity/ens/resolverDelegation.ts b/src/identity/ens/resolverDelegation.ts index 6af84d9..4b499c8 100644 --- a/src/identity/ens/resolverDelegation.ts +++ b/src/identity/ens/resolverDelegation.ts @@ -1,5 +1,4 @@ import { - encodeFunctionData, getAddress, type Address, type Hex, @@ -18,22 +17,6 @@ export type ResolverDelegationReadArgs = { delegateAddress: Address } -export function encodeApprove(node: Hex, delegateAddress: Address): Hex { - return encodeFunctionData({ - abi: ENS_AUTOMATION_RESOLVER_ABI, - functionName: 'approve', - args: [node, getAddress(delegateAddress), true], - }) -} - -export function encodeApprovalRevoke(node: Hex, delegateAddress: Address): Hex { - return encodeFunctionData({ - abi: ENS_AUTOMATION_RESOLVER_ABI, - functionName: 'approve', - args: [node, getAddress(delegateAddress), false], - }) -} - export async function readDelegation(args: ResolverDelegationReadArgs): Promise { try { const approved = await args.client.readContract({ diff --git a/src/identity/manager/custody/helpers.ts b/src/identity/manager/custody/helpers.ts index 7a240b5..d68ee3f 100644 --- a/src/identity/manager/custody/helpers.ts +++ b/src/identity/manager/custody/helpers.ts @@ -1,7 +1,6 @@ -import type { EthagentConfig, EthagentIdentity } from '../../../storage/config.js' -import { buildSeedConfigForIdentity } from '../../../storage/config.js' +import type { EthagentIdentity } from '../../../storage/config.js' import { readOwnerAddressField } from '../../identityCompat.js' -import { supportedErc8004ChainForId, type Erc8004RegistryConfig } from '../../registry/erc8004.js' +import { supportedErc8004ChainForId } from '../../registry/erc8004.js' export function chainLabel(chainId: number): string { return supportedErc8004ChainForId(chainId)?.name ?? `chain ${chainId}` @@ -14,12 +13,3 @@ export function humanOwnerAddress(identity: EthagentIdentity): `0x${string}` { } return (identity.ownerAddress ?? identity.address) as `0x${string}` } - -export function buildSeedConfigFromStep(identity: EthagentIdentity, registry: Erc8004RegistryConfig): EthagentConfig { - return buildSeedConfigForIdentity({ - identity, - chainId: registry.chainId, - rpcUrl: registry.rpcUrl, - identityRegistryAddress: registry.identityRegistryAddress, - }) -} diff --git a/src/identity/manager/ens/editCopy.ts b/src/identity/manager/ens/editCopy.ts index 8f1252b..ec8b235 100644 --- a/src/identity/manager/ens/editCopy.ts +++ b/src/identity/manager/ens/editCopy.ts @@ -116,11 +116,3 @@ export function manualReasonTitle(reason: EnsSetupBlockedPlan['reason']): string return 'The name could not be checked.' } } - -export function readValidationFromState(state: Record | undefined): { ok: boolean; reason?: string } | null { - const raw = state?.ensValidation - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null - const obj = raw as Record - if (typeof obj.ok !== 'boolean') return null - return { ok: obj.ok, ...(typeof obj.reason === 'string' ? { reason: obj.reason } : {}) } -} diff --git a/src/identity/manager/ens/types.ts b/src/identity/manager/ens/types.ts index a1a9be4..94056ba 100644 --- a/src/identity/manager/ens/types.ts +++ b/src/identity/manager/ens/types.ts @@ -15,8 +15,6 @@ import type { Erc8004RegistryConfig } from '../../registry/erc8004.js' import type { AgentReconciliation } from '../shared/reconciliation/index.js' import type { EnsLinkOptions } from './editCopy.js' -export type EnsIssueValidation = Extract - export type SimpleEnsPhase = | { kind: 'discovering'; mode?: 'simple' | 'advanced' } | { kind: 'pick-parent'; mode?: 'simple' | 'advanced'; error?: string } diff --git a/src/identity/manager/profile/effects.ts b/src/identity/manager/profile/effects.ts index f04271e..f0b0bd9 100644 --- a/src/identity/manager/profile/effects.ts +++ b/src/identity/manager/profile/effects.ts @@ -9,23 +9,22 @@ import { } from '../../continuity/storage.js' import { syncAgentCardManifest } from '../../continuity/skills/publicSkillsSync.js' import { recordPublishedContinuitySnapshot } from '../../continuity/snapshots.js' -import { addToIpfs, DEFAULT_IPFS_API_URL, isPinataUploadUrl } from '../../storage/ipfs.js' +import { addToIpfs, DEFAULT_IPFS_API_URL } from '../../storage/ipfs.js' import { createErc8004PublicClient, encodeSetAgentUri, preflightSetAgentUri, withEthagentPointers, - type Erc8004RegistryConfig, } from '../../registry/erc8004.js' import { encodeRotateAgentURI } from '../../registry/vault.js' -import { resolveValidatedPinataJwt, savePinataJwt } from '../../storage/pinataJwt.js' +import { savePinataJwt } from '../../storage/pinataJwt.js' import { openBrowserWalletSession, prepareTransactionGasFee, requestBrowserWalletSignatureAndTransaction, type WalletPurpose, } from '../../wallet/browserWallet.js' -import type { ProfileUpdates, Step } from '../reducer.js' +import type { Step } from '../reducer.js' import { acquireTxGuard, releaseTxGuard } from '../shared/txGuard.js' import type { EffectCallbacks } from '../shared/effects/types.js' import { awaitConfirmedReceipt } from '../shared/effects/receipts.js' @@ -66,29 +65,6 @@ type PublicProfilePreparedTransaction = { agentCardJson: string } -export async function runPublicProfilePreflight( - identity: EthagentIdentity, - registry: Erc8004RegistryConfig, - callbacks: EffectCallbacks, - profileUpdates?: ProfileUpdates, - returnTo: Step = { kind: 'continuity-public' }, - vaultAddress?: Address, -): Promise { - const apiUrl = DEFAULT_IPFS_API_URL - let jwt: string | undefined - try { - jwt = isPinataUploadUrl(apiUrl) ? await resolveValidatedPinataJwt() : undefined - } catch (err: unknown) { - callbacks.onStep({ kind: 'public-profile-storage', identity, registry, error: (err as Error).message, profileUpdates, returnTo, ...(vaultAddress ? { vaultAddress } : {}) }) - return - } - if (isPinataUploadUrl(apiUrl) && !jwt) { - callbacks.onStep({ kind: 'public-profile-storage', identity, registry, profileUpdates, returnTo, ...(vaultAddress ? { vaultAddress } : {}) }) - return - } - callbacks.onStep({ kind: 'public-profile-signing', identity, registry, pinataJwt: jwt, profileUpdates, returnTo, ...(vaultAddress ? { vaultAddress } : {}) }) -} - export async function runPublicProfileSigning( step: Extract, callbacks: EffectCallbacks, diff --git a/src/identity/manager/profile/identity.ts b/src/identity/manager/profile/identity.ts index e7d61b5..855e956 100644 --- a/src/identity/manager/profile/identity.ts +++ b/src/identity/manager/profile/identity.ts @@ -1,9 +1,8 @@ -import type { EthagentConfig, EthagentIdentity } from '../../../storage/config.js' -import { supportedErc8004ChainForId, type Erc8004AgentCandidate } from '../../registry/erc8004.js' +import type { EthagentIdentity } from '../../../storage/config.js' +import type { Erc8004AgentCandidate } from '../../registry/erc8004.js' import type { CustodyMode } from '../custody/state.js' -import { formatDate, shortAddress, shortCid } from '../shared/model/format.js' +import { formatDate } from '../shared/model/format.js' import { truncateEnd } from '../../../ui/text.js' -import { chainSummaryRow } from '../shared/model/network.js' export const PREFLIGHT_AGENT_URI = 'ipfs://bafybeigdyrztma2dbfczw7q6ooozbxlqzyw5r7w4f3qw2axvvxqg3w6y7q' @@ -33,16 +32,6 @@ export function tokenCandidateSelectLabel( return `${truncateEnd(tokenCandidateLabel(candidate), maxLength - suffix.length)}${suffix}` } -export function tokenCandidateHint(candidate: Erc8004AgentCandidate): string { - const network = supportedErc8004ChainForId(candidate.chainId)?.name ?? `Chain ${candidate.chainId}` - const parts = [ - candidate.name?.trim() ? `#${candidate.agentId.toString()}` : null, - network, - candidate.backup?.createdAt ? `saved ${formatDate(candidate.backup.createdAt)}` : null, - ].filter((part): part is string => Boolean(part)) - return parts.join(' · ') -} - export function isCurrentAgentCandidate( identity: EthagentIdentity | undefined, candidate: Erc8004AgentCandidate, @@ -62,32 +51,6 @@ export function isCurrentAgentCandidate( return true } -export function identitySummaryRows( - identity: EthagentIdentity | undefined, - config?: EthagentConfig, -): Array<{ - label: string - value: string - tone: 'ok' | 'dim' -}> { - const backup = identity?.backup - const owner = identity?.ownerAddress ?? identity?.address - const ownerValue = owner ? shortAddress(owner) : 'not connected' - const tokenValue = identity?.agentId ? `#${identity.agentId}` : 'not created' - const chain = chainSummaryRow(config, identity) - const stateValue = backup?.cid ? shortCid(backup.cid) : 'not saved yet' - const cardValue = identity?.agentCard?.cid ? shortCid(identity.agentCard.cid) : 'not saved' - const iconValue = typeof identity?.state?.imageUrl === 'string' && identity.state.imageUrl.trim() ? 'attached' : 'not attached' - return [ - { label: 'owner wallet', value: ownerValue, tone: identity ? 'ok' : 'dim' }, - { label: 'token', value: tokenValue, tone: identity?.agentId ? 'ok' : 'dim' }, - { label: 'network', value: chain.value, tone: chain.tone }, - { label: 'state', value: stateValue, tone: backup ? 'ok' : 'dim' }, - { label: 'card', value: cardValue, tone: identity?.agentCard?.cid ? 'ok' : 'dim' }, - { label: 'icon', value: iconValue, tone: iconValue === 'attached' ? 'ok' : 'dim' }, - ] -} - export function lastBackupLabel(identity?: EthagentIdentity): string { const created = identity?.backup?.createdAt return created ? formatDate(created) : 'never' diff --git a/src/identity/manager/shared/effects/receipts.ts b/src/identity/manager/shared/effects/receipts.ts index 595cbd8..4013e9f 100644 --- a/src/identity/manager/shared/effects/receipts.ts +++ b/src/identity/manager/shared/effects/receipts.ts @@ -30,19 +30,3 @@ export async function awaitConfirmedReceipt( } } } - -export async function awaitOptionalReceipt( - client: Pick, - hash: Hex, - action: string, -): Promise { - let receipt - try { - receipt = await client.waitForTransactionReceipt({ hash, timeout: 0 }) - } catch { - return - } - if (receipt.status !== 'success') { - throw new Error(`${action} reverted onchain (tx ${hash}).`) - } -} diff --git a/src/identity/manager/shared/model/network.ts b/src/identity/manager/shared/model/network.ts index 12d394c..8c0e804 100644 --- a/src/identity/manager/shared/model/network.ts +++ b/src/identity/manager/shared/model/network.ts @@ -1,15 +1,5 @@ import type { EthagentConfig, EthagentIdentity, SelectableNetwork } from '../../../../storage/config.js' import { chainIdForNetwork, supportedErc8004ChainForId } from '../../../registry/erc8004.js' -import { resolveSelectedNetwork } from '../../../registry/registryConfig.js' - -const NETWORK_LABELS: Record = { - mainnet: 'ethereum mainnet', - base: 'base', -} - -export function networkLabel(network: SelectableNetwork): string { - return NETWORK_LABELS[network] -} const NETWORK_SUBTITLES: Record = { mainnet: 'Most secure', @@ -29,14 +19,3 @@ export function identityNetworkName(identity?: EthagentIdentity, config?: Ethage if (chainId) return networkName(chainId) return config?.selectedNetwork ? networkName(chainIdForNetwork(config.selectedNetwork)) : null } - -export function chainSummaryRow(config?: EthagentConfig, identity?: EthagentIdentity): { - label: string - value: string - tone: 'ok' | 'dim' -} { - const network = resolveSelectedNetwork(config) - const fromIdentity = identity?.chainId ? supportedErc8004ChainForId(identity.chainId)?.name.toLowerCase() : undefined - const value = fromIdentity ?? networkLabel(network) - return { label: 'chain', value, tone: identity?.chainId ? 'ok' : 'dim' } -} diff --git a/src/identity/registry/fieldParsers.ts b/src/identity/registry/fieldParsers.ts index e812f7d..13bd790 100644 --- a/src/identity/registry/fieldParsers.ts +++ b/src/identity/registry/fieldParsers.ts @@ -21,8 +21,3 @@ export function numberField(input: Record | null | undefined, k const value = input?.[key] return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined } - -export function booleanField(input: Record | null | undefined, key: string): boolean | undefined { - const value = input?.[key] - return typeof value === 'boolean' ? value : undefined -} diff --git a/src/identity/wallet/browserWallet/html.ts b/src/identity/wallet/browserWallet/html.ts index a095e05..fc3b634 100644 --- a/src/identity/wallet/browserWallet/html.ts +++ b/src/identity/wallet/browserWallet/html.ts @@ -16,10 +16,6 @@ export function walletPage(title: string, sessionToken: string, payload: Record< return injectWalletConfig(WALLET_HTML, title, sessionToken, payload) } -export function walletPageFresh(title: string, sessionToken: string, payload: Record): string { - return injectWalletConfig(loadWalletHtml(), title, sessionToken, payload) -} - export function __testWalletPage(title: string, sessionToken: string, payload: Record): string { return walletPage(title, sessionToken, payload) } diff --git a/src/storage/config.ts b/src/storage/config.ts index 5d23c64..d5f51ac 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -206,24 +206,6 @@ export function getConfiguredVaultAddress( return config?.erc8004?.operatorVaults?.[String(chainId)] } -export function buildSeedConfigForIdentity(args: { - identity: EthagentIdentity - chainId: number - rpcUrl: string - identityRegistryAddress: string -}): EthagentConfig { - return { - version: 2, - firstSeenAt: new Date().toISOString(), - identity: { ...args.identity, source: 'erc8004' }, - erc8004: { - chainId: args.chainId, - rpcUrl: args.rpcUrl, - identityRegistryAddress: args.identityRegistryAddress, - }, - } -} - export function setConfiguredVaultAddress( config: EthagentConfig, chainId: number, @@ -281,14 +263,6 @@ async function savePendingTxMutation( return null } -export async function deleteConfig(): Promise { - try { - await fs.unlink(getConfigPath()) - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err - } -} - export function normalizeConfig(config: EthagentConfig): EthagentConfig { let next = config if (!next.erc8004 && next.identity?.chainId && next.identity.identityRegistryAddress && next.identity.rpcUrl) { diff --git a/src/storage/identity.ts b/src/storage/identity.ts index 52f073b..62111c1 100644 --- a/src/storage/identity.ts +++ b/src/storage/identity.ts @@ -5,7 +5,6 @@ import { type EthagentIdentity, } from './config.js' import { - rmSecret, hasSecret, whichBackend, type KeyBackend, @@ -77,29 +76,3 @@ export async function setTokenIdentity( await saveConfig(next) return next } - -export async function updateIdentityBackup( - config: EthagentConfig, - backup: NonNullable, -): Promise { - if (!config.identity) throw new Error('No identity set') - const next: EthagentConfig = { - ...config, - identity: { - ...config.identity, - backup, - }, - } - await saveConfig(next) - return next -} - -export async function clearIdentity(config: EthagentConfig): Promise { - await rmSecret(IDENTITY_ACCOUNT) - if (!config.identity) return config - const { identity: _drop, ...rest } = config - void _drop - const next = { ...rest } as EthagentConfig - await saveConfig(next) - return next -} diff --git a/src/ui/text.ts b/src/ui/text.ts index 38d2227..1a6755d 100644 --- a/src/ui/text.ts +++ b/src/ui/text.ts @@ -120,16 +120,6 @@ export function firstThatFits(candidates: readonly string[], budget: number): st return null } -export function joinWithMore(items: readonly string[], budget: number, separator = ', '): string | null { - const all = items.join(separator) - if (all.length <= budget) return all - for (let shown = items.length - 1; shown >= 1; shown -= 1) { - const candidate = `${items.slice(0, shown).join(separator)} +${items.length - shown} more` - if (candidate.length <= budget) return candidate - } - return null -} - export function middleEllipsis(value: string, head: number, tail: number): string { if (value.length <= head + tail + 1) return value return `${value.slice(0, head)}…${value.slice(-tail)}` diff --git a/test/identity/continuity/skillsFrontmatter.test.ts b/test/identity/continuity/skillsFrontmatter.test.ts index 5343764..bf1616d 100644 --- a/test/identity/continuity/skillsFrontmatter.test.ts +++ b/test/identity/continuity/skillsFrontmatter.test.ts @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { parseSkillFile, serializeSkillFile, sanitizeSkillFileForStrictYaml } from '../../../src/identity/continuity/skills/frontmatter.js' +import { parseSkillFile, sanitizeSkillFileForStrictYaml } from '../../../src/identity/continuity/skills/frontmatter.js' test('sanitizeSkillFileForStrictYaml quotes colon-y values, preserves unknown keys, leaves the body', () => { const src = [ @@ -59,32 +59,6 @@ test('sanitizeSkillFileForStrictYaml leaves block-scalar content lines verbatim assert.match(out, /visibility: "public"/) }) -test('serializeSkillFile emits strict-valid quoted YAML and round-trips through parseSkillFile', () => { - const frontmatter = { - name: 'vscode-setup', - description: 'Set up VS Code in one shot: apply my "Preferences Dark" theme', - visibility: 'public' as const, - tags: ['vscode', 'setup'], - } - const out = serializeSkillFile(frontmatter, 'body text\n') - assert.match(out, /description: "Set up VS Code in one shot: apply my \\"Preferences Dark\\" theme"/) - const parsed = parseSkillFile(out) - assert.equal(parsed.frontmatter.name, frontmatter.name) - assert.equal(parsed.frontmatter.description, frontmatter.description) - assert.equal(parsed.frontmatter.visibility, 'public') - assert.deepEqual(parsed.frontmatter.tags, frontmatter.tags) - assert.equal(parsed.body, 'body text') -}) - -test('serializeSkillFile escapes control characters so a strict YAML parser stays happy', () => { - const out = serializeSkillFile({ name: 'x', description: 'a\fb\x07c\x1bd\x7fe' }, '') - assert.doesNotMatch(out, /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/) - assert.match(out, /\\x0C/) - assert.match(out, /\\x07/) - assert.match(out, /\\x1B/) - assert.match(out, /\\x7F/) -}) - test('parses supported frontmatter keys with quoted and bare scalars', () => { const content = [ '---', diff --git a/test/identity/ens/resolverDelegation.test.ts b/test/identity/ens/resolverDelegation.test.ts deleted file mode 100644 index 87825ed..0000000 --- a/test/identity/ens/resolverDelegation.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -import { decodeFunctionData, namehash, parseAbi } from 'viem' -import { - encodeApprove, - encodeApprovalRevoke, -} from '../../../src/identity/ens/resolverDelegation.js' - -const RESOLVER_ABI = parseAbi([ - 'function approve(bytes32 node, address delegate, bool approved)', -]) - -const SUBDOMAIN = 'agent1.alice.eth' -const NODE = namehash(SUBDOMAIN) -const OPERATOR_WALLET = '0x000000000000000000000000000000000000bEEF' as `0x${string}` - -test('encodeApprove builds calldata for resolver.approve(node, operatorWallet, true)', () => { - const data = encodeApprove(NODE, OPERATOR_WALLET) - const decoded = decodeFunctionData({ abi: RESOLVER_ABI, data }) - assert.equal(decoded.functionName, 'approve') - const args = decoded.args as readonly [`0x${string}`, `0x${string}`, boolean] - assert.equal(args[0], NODE) - assert.equal(args[1].toLowerCase(), OPERATOR_WALLET.toLowerCase()) - assert.equal(args[2], true) -}) - -test('encodeApprovalRevoke builds calldata for resolver.approve(node, operatorWallet, false)', () => { - const data = encodeApprovalRevoke(NODE, OPERATOR_WALLET) - const decoded = decodeFunctionData({ abi: RESOLVER_ABI, data }) - assert.equal(decoded.functionName, 'approve') - const args = decoded.args as readonly [`0x${string}`, `0x${string}`, boolean] - assert.equal(args[0], NODE) - assert.equal(args[1].toLowerCase(), OPERATOR_WALLET.toLowerCase()) - assert.equal(args[2], false) -}) diff --git a/test/identity/manager/lazyMenuTruncation.test.tsx b/test/identity/manager/lazyMenuTruncation.test.tsx index 0bffbb3..735ba77 100644 --- a/test/identity/manager/lazyMenuTruncation.test.tsx +++ b/test/identity/manager/lazyMenuTruncation.test.tsx @@ -4,7 +4,7 @@ import React from 'react' import { render } from 'ink-testing-library' import { LazyMenu } from '../../../src/identity/manager/shared/components/LazyMenu.js' import { changeSummaryCandidates, type LocalChangeItem } from '../../../src/identity/manager/continuity/state.js' -import { joinWithMore, wrapWords } from '../../../src/ui/text.js' +import { wrapWords } from '../../../src/ui/text.js' const WIDTH = 42 @@ -82,12 +82,6 @@ test('change summaries go from full names to a skill count to a total', () => { assert.deepEqual(changeSummaryCandidates([items[1]!]), ['MEMORY.md']) }) -test('whole-item shortening never splits an item', () => { - assert.equal(joinWithMore(['SOUL.md', 'MEMORY.md', 'canvas'], 40), 'SOUL.md, MEMORY.md, canvas') - assert.equal(joinWithMore(['SOUL.md', 'MEMORY.md', 'canvas'], 20), 'SOUL.md +2 more') - assert.equal(joinWithMore(['SOUL.md', 'MEMORY.md'], 5), null) -}) - test('word wrapping trims at the break instead of starting a line with a space', () => { const lines = wrapWords('Choose a root .eth name to create an agent subdomain under.', 42) assert.deepEqual(lines, ['Choose a root .eth name to create an agent', 'subdomain under.']) diff --git a/test/identity/manager/model/model.test.ts b/test/identity/manager/model/model.test.ts index 0993c2f..40e70b9 100644 --- a/test/identity/manager/model/model.test.ts +++ b/test/identity/manager/model/model.test.ts @@ -16,16 +16,11 @@ import { import { RecoveryConfirmScreen } from '../../../../src/identity/manager/continuity/RecoveryConfirmScreen.js' import { ensValidationReasonText } from '../../../../src/identity/manager/ens/state.js' import { - chainSummaryRow, - networkLabel, networkSubtitle, } from '../../../../src/identity/manager/shared/model/network.js' import { - identitySummaryRows, isCurrentAgentCandidate, lastBackupLabel, - tokenCandidateHint, - tokenCandidateLabel, tokenCandidateSelectLabel, } from '../../../../src/identity/manager/profile/identity.js' import { @@ -91,40 +86,6 @@ test('identity manager explains missing restore access without generic identity assert.equal(view.hint, 'Use the owner wallet once to save a fresh snapshot, then restore with this wallet.') }) -test('identity manager summary always shows the short state CID for the menu card', () => { - const rows = identitySummaryRows({ - source: 'erc8004', - address: '0x000000000000000000000000000000000000dEaD', - ownerAddress: '0x000000000000000000000000000000000000dEaD', - createdAt: new Date(0).toISOString(), - agentId: '42', - backup: { - cid: 'bafybeigdyrztma2dbfczw7q6ooozbxlqzyw5r7w4f3qw2axvvxqg3w6y7q', - createdAt: new Date(0).toISOString(), - envelopeVersion: 'ethagent-state-backup-v1', - ipfsApiUrl: 'https://uploads.pinata.cloud/v3/files', - status: 'pinned', - }, - }) - - assert.deepEqual(rows.map(row => row.label), ['owner wallet', 'token', 'network', 'state', 'card', 'icon']) - assert.equal(rows[3]?.value, 'bafybeigdy…3w6y7q') - assert.equal(rows[0]?.value, '0x0000…dEaD') - assert.equal(rows[4]?.value, 'not saved') - assert.equal(rows[5]?.value, 'not attached') -}) - -test('identity manager summary collapses gracefully when no identity is loaded', () => { - const rows = identitySummaryRows(undefined) - assert.deepEqual(rows.map(row => row.label), ['owner wallet', 'token', 'network', 'state', 'card', 'icon']) - assert.equal(rows[0]?.value, 'not connected') - assert.equal(rows[1]?.value, 'not created') - assert.equal(rows[2]?.value, 'ethereum mainnet') - assert.equal(rows[3]?.value, 'not saved yet') - assert.equal(rows[4]?.value, 'not saved') - assert.equal(rows[5]?.value, 'not attached') -}) - test('ensValidationReasonText covers advanced ENS ownership codes', () => { assert.equal(ensValidationReasonText('token-owner-mismatch'), 'Owner wallet does not hold the agent token') assert.equal(ensValidationReasonText('token-owner-lookup-failed'), 'Could not confirm the agent token owner') @@ -263,34 +224,12 @@ test('storage credential confirmation says pinned snapshots survive and defaults assert.match(output, /❯ Keep JWT/) }) -test('networkLabel and networkSubtitle return human-readable strings for every curated network', () => { - assert.equal(networkLabel('mainnet'), 'ethereum mainnet') - assert.equal(networkLabel('base'), 'base') +test('networkSubtitle returns a human-readable string for every curated network', () => { for (const n of ['mainnet', 'base'] as const) { assert.ok(networkSubtitle(n).length > 0) } }) -test('chainSummaryRow prefers identity.chainId, falls back to selectedNetwork', () => { - const onMainnet = chainSummaryRow( - { version: 2, firstSeenAt: new Date(0).toISOString(), selectedNetwork: 'base' }, - undefined, - ) - assert.equal(onMainnet.value, 'base') - assert.equal(onMainnet.tone, 'dim') - - const fromIdentity = chainSummaryRow( - { version: 2, firstSeenAt: new Date(0).toISOString(), selectedNetwork: 'base' }, - { - address: '0x000000000000000000000000000000000000dEaD', - createdAt: new Date(0).toISOString(), - chainId: 1, - }, - ) - assert.equal(fromIdentity.value, 'ethereum mainnet') - assert.equal(fromIdentity.tone, 'ok') -}) - test('networkSubtitle frames each network as a value-prop, not a jargon dump', () => { for (const n of ['mainnet', 'base'] as const) { assertNoNetworkJargon(networkSubtitle(n)) @@ -364,43 +303,6 @@ test('copyableIdentityFields exposes advanced ENS operator wallet state', () => assert.equal(fields.find(field => field.label === 'Approved Operator Wallets'), undefined) }) -test('token candidate hint stays terse so wallets with many agents stay scannable', () => { - const candidate = { - ownerAddress: '0x8DDe0C47EdC7C0a0745f56d4BB92a959CD0c5394', - chainId: 8453, - rpcUrl: 'https://base.publicnode.com', - identityRegistryAddress: '0x8004A169FB4a3325136EB29fA0ceB6D2e539a432', - agentId: 45744n, - agentUri: 'ipfs://bafy-metadata', - name: 'research agent', - backup: { - cid: 'bafkreib2abcdefghijklmnopqrstuvwxyzjsyzdy', - createdAt: '2026-04-25T00:00:00.000Z', - }, - registration: null, - } as const - - assert.equal(tokenCandidateLabel(candidate), 'research agent') - assert.equal(tokenCandidateHint(candidate), '#45744 · Base · saved 2026-04-25') - assert.doesNotMatch(tokenCandidateHint(candidate), /owner/) - assert.doesNotMatch(tokenCandidateHint(candidate), /state/) -}) - -test('token candidate hint falls back to network only when no backup is pinned yet', () => { - const candidate = { - ownerAddress: '0x8DDe0C47EdC7C0a0745f56d4BB92a959CD0c5394', - chainId: 1, - rpcUrl: 'https://eth.publicnode.com', - identityRegistryAddress: '0x8004A169FB4a3325136EB29fA0ceB6D2e539a432', - agentId: 1n, - agentUri: 'ipfs://bafy', - registration: null, - } as const - - assert.equal(tokenCandidateLabel(candidate), 'Agent Token #1') - assert.equal(tokenCandidateHint(candidate), 'Ethereum Mainnet') -}) - test('current agent candidate marker requires the selected token identity', () => { const identity = { address: '0x8DDe0C47EdC7C0a0745f56d4BB92a959CD0c5394', diff --git a/test/identity/manager/networkFailures.test.tsx b/test/identity/manager/networkFailures.test.tsx index ea941d9..62500e1 100644 --- a/test/identity/manager/networkFailures.test.tsx +++ b/test/identity/manager/networkFailures.test.tsx @@ -9,7 +9,7 @@ import { RestoreFlow } from '../../../src/identity/manager/restore/RestoreFlow.j import { restoreTokenSelectionStep } from '../../../src/identity/manager/restore/discover.js' import { runRestoreFetch } from '../../../src/identity/manager/restore/fetch.js' import { downloadProgress } from '../../../src/identity/manager/restore/progress.js' -import { awaitConfirmedReceipt, awaitOptionalReceipt } from '../../../src/identity/manager/shared/effects/receipts.js' +import { awaitConfirmedReceipt } from '../../../src/identity/manager/shared/effects/receipts.js' import { scopeCallbacks, type EffectCallbacks, type RestoreProgress } from '../../../src/identity/manager/shared/effects/types.js' import type { Step } from '../../../src/identity/manager/reducer.js' import { @@ -344,6 +344,5 @@ test('receipt waits carry no hidden deadline: a slow transaction is not reported }, } await awaitConfirmedReceipt(client as never, '0xabc', 'Agent registration') - await awaitOptionalReceipt(client as never, '0xdef', 'Operator sync') - assert.deepEqual(seen, [{ hash: '0xabc', timeout: 0 }, { hash: '0xdef', timeout: 0 }]) + assert.deepEqual(seen, [{ hash: '0xabc', timeout: 0 }]) }) diff --git a/test/identity/registry/fieldParsers.test.ts b/test/identity/registry/fieldParsers.test.ts index e4ec01b..987ecf0 100644 --- a/test/identity/registry/fieldParsers.test.ts +++ b/test/identity/registry/fieldParsers.test.ts @@ -2,7 +2,6 @@ import test from 'node:test' import assert from 'node:assert/strict' import { arrayField, - booleanField, numberField, objectField, stringField, @@ -48,16 +47,6 @@ test('arrayField returns arrays, otherwise null', () => { assert.equal(arrayField(null, 'a'), null) }) -test('booleanField returns booleans, otherwise undefined', () => { - assert.equal(booleanField({ a: true }, 'a'), true) - assert.equal(booleanField({ a: false }, 'a'), false) - assert.equal(booleanField({ a: 'true' }, 'a'), undefined) - assert.equal(booleanField({ a: 1 }, 'a'), undefined) - assert.equal(booleanField({ a: null }, 'a'), undefined) - assert.equal(booleanField({}, 'a'), undefined) - assert.equal(booleanField(null, 'a'), undefined) -}) - test('field parsers compose for nested registry payloads', () => { const payload: Record = { 'x-ethagent': { diff --git a/test/identity/storage/identityStorage.test.ts b/test/identity/storage/identityStorage.test.ts index 988a387..037beb9 100644 --- a/test/identity/storage/identityStorage.test.ts +++ b/test/identity/storage/identityStorage.test.ts @@ -3,9 +3,9 @@ import assert from 'node:assert/strict' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { setTokenIdentity, getIdentityStatus, clearIdentity } from '../../../src/storage/identity.js' +import { setTokenIdentity, getIdentityStatus } from '../../../src/storage/identity.js' import { saveConfig, type EthagentConfig } from '../../../src/storage/config.js' -import { getSecret, setSecret } from '../../../src/storage/secrets.js' +import { getSecret } from '../../../src/storage/secrets.js' const IDENTITY_ACCOUNT = 'ethereum:default' @@ -106,57 +106,3 @@ test('setTokenIdentity preserves an existing erc8004 block, including operatorVa assert.equal(updated.erc8004?.operatorVaults?.['1'], '0x1111111111111111111111111111111111111111') }) }) - -test('clearIdentity removes ERC-8004 identity metadata without touching other config', async () => { - await withTempHome(async () => { - const config = { - ...baseConfig(), - selectedNetwork: 'base' as const, - erc8004: { - chainId: 8453, - rpcUrl: 'https://base.publicnode.com', - identityRegistryAddress: '0x8004A169FB4a3325136EB29fA0ceB6D2e539a432', - }, - } - await saveConfig(config) - const identity = { - source: 'erc8004' as const, - address: '0x000000000000000000000000000000000000dEaD', - ownerAddress: '0x000000000000000000000000000000000000dEaD', - createdAt: new Date(0).toISOString(), - chainId: 8453, - rpcUrl: 'https://base.publicnode.com', - identityRegistryAddress: '0x8004A169FB4a3325136EB29fA0ceB6D2e539a432', - agentId: '42', - agentUri: 'ipfs://bafy-agent', - state: { name: 'agent' }, - } - const withToken = await setTokenIdentity(config, identity) - - const cleared = await clearIdentity(withToken) - - assert.equal(cleared.identity, undefined) - assert.equal(cleared.selectedNetwork, 'base') - assert.equal(cleared.erc8004?.chainId, 8453) - }) -}) - -test('clearIdentity also removes any legacy local private key secret', async () => { - await withTempHome(async () => { - const config = baseConfig() - await saveConfig(config) - await setSecret(IDENTITY_ACCOUNT, 'legacy-private-key') - assert.equal(await getSecret(IDENTITY_ACCOUNT), 'legacy-private-key') - - await clearIdentity({ - ...config, - identity: { - address: '0x000000000000000000000000000000000000dEaD', - createdAt: new Date(0).toISOString(), - source: 'local-key', - }, - }) - - assert.equal(await getSecret(IDENTITY_ACCOUNT), null) - }) -}) diff --git a/test/run-tests.mjs b/test/run-tests.mjs index 741bfeb..3bcf205 100644 --- a/test/run-tests.mjs +++ b/test/run-tests.mjs @@ -5,13 +5,14 @@ import { spawnSync } from 'node:child_process' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const outDir = path.join(root, '.test-dist') +const coverage = process.argv.includes('--coverage') await fs.rm(outDir, { recursive: true, force: true }) const tscBin = path.join(root, 'node_modules', 'typescript', 'bin', 'tsc') const compile = spawnSync( process.execPath, - [tscBin, '--outDir', outDir, '--noEmit', 'false'], + [tscBin, '--outDir', outDir, '--noEmit', 'false', ...(coverage ? ['--sourceMap', 'true'] : [])], { cwd: root, stdio: 'inherit' }, ) @@ -20,9 +21,18 @@ if (compile.status !== 0) { process.exit(compile.status ?? 1) } +const coverageFlags = coverage + ? [ + '--enable-source-maps', + '--experimental-test-coverage', + '--test-coverage-exclude=**/test/**', + '--test-coverage-exclude=**/node_modules/**', + ] + : [] + const run = spawnSync( process.execPath, - [path.join(outDir, 'test', 'run.js')], + [...coverageFlags, path.join(outDir, 'test', 'run.js')], { cwd: root, stdio: 'inherit' }, ) diff --git a/test/run.ts b/test/run.ts index d8433a4..26f4e54 100644 --- a/test/run.ts +++ b/test/run.ts @@ -46,7 +46,6 @@ import './identity/ens/ensLookup.test.js' import './identity/ens/ensAutomation.test.js' import './identity/ens/ensFlowSmoke.test.js' import './identity/ens/operatorDriftDetection.test.js' -import './identity/ens/resolverDelegation.test.js' import './identity/storage/identityStorage.test.js' import './identity/storage/identityIpfs.test.js' import './identity/storage/ipfsRead.test.js'