diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80520a3..560ec9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,20 @@ jobs: working-directory: contracts - run: forge test -vv working-directory: contracts + + chain: + name: Chain suite (anvil) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + submodules: recursive + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + - uses: foundry-rs/foundry-toolchain@v1 + with: + version: v1.5.1 + - run: npm ci + - run: npm run test:chain diff --git a/README.md b/README.md index 8580f7b..95a4ee3 100644 --- a/README.md +++ b/README.md @@ -456,7 +456,8 @@ With `--json`, every history and onchain command prints one line of ASCII-only J | --- | --- | | `ETHAGENT_OPERATOR_KEY` | The operator key for `save --operator`, `fetch`, `restore --operator`, `profile --operator`, `custody --add-operator --operator`, and `ens --operator`. Inject it from your OS keychain; never type it into a command. | | `ETHAGENT_RPC_URL` | The RPC endpoint asked first for the agent's network. Use one that keeps history when the public endpoints can't reach back far enough for a wallet search. | -| `ETHAGENT_IPFS_API_URL` | Where snapshots are uploaded, in place of Pinata. | +| `ETHAGENT_ENS_RPC_URL` | An Ethereum Mainnet RPC endpoint asked first for every ENS read and write. | +| `ETHAGENT_IPFS_API_URL` | Where snapshots are uploaded, in place of Pinata: an IPFS HTTP API (`/api/v0/add`). No Pinata credential is needed then. | | `PINATA_JWT` | IPFS storage credential, if you haven't set one up in the manager. | | `PINATA_GATEWAY_URL` | Your own IPFS gateway. Downloads try it before any other source. | | `ETHAGENT_IPFS_GATEWAYS` | Gateways to download from, separated by commas, in place of the built-in ones. | @@ -465,6 +466,17 @@ With `--json`, every history and onchain command prints one line of ASCII-only J | `ETHAGENT_HARNESS_FILES` | Extra instruction files to keep in sync, separated by commas. | | `ETHAGENT_NO_DAEMON` | Set to `1` to turn off background sync. | +## Development + +```bash +npm ci +npm run typecheck +npm test # unit tests, no network +npm run test:chain # every command that sends, on local chains (needs Foundry) +``` + +`npm run test:chain` starts two local Anvil chains (one for ENS on Ethereum Mainnet, one for the agent registry on Base) and serves IPFS from the test runner. Stand-ins for the registry and the ENS contracts, in `contracts/test/chain`, sit at the real addresses, and the real Vault is deployed by ethagent itself. It then walks one agent's whole life with `--yes`: create, profile, ENS link and records, Advanced custody, the operator key, an operator save, `check`, restoring on a fresh machine, ENS delete, Simple custody, and a transfer the receiver restores. A test wallet signs in place of the browser tab, and nothing leaves the machine. CI runs it on every pull request. + ## Updating ethagent updates itself: `npx ethagent` always fetches the latest version, so new releases reach you with nothing to install. Check your version with `ethagent --version`. diff --git a/contracts/test/chain/ChainEns.sol b/contracts/test/chain/ChainEns.sol new file mode 100644 index 0000000..75b6f2a --- /dev/null +++ b/contracts/test/chain/ChainEns.sol @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +// Stand-ins for the ENS contracts, for the local test chain only. The chain suite places +// their runtime code at the real mainnet addresses, so none keeps constructor state, and +// each reaches the others at those fixed addresses. They cover what ethagent and viem +// call: the registry's owner, resolver and subnode writes; the public resolver's text, +// addr, multicall and delegation; and the universal resolver's resolveWithGateways. + +address constant ENS_REGISTRY = 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e; + +interface IChainEnsRegistry { + function owner(bytes32 node) external view returns (address); + function resolver(bytes32 node) external view returns (address); + function isApprovedForAll(address owner, address operator) external view returns (bool); +} + +/// @notice The registry, as ENSRegistry minus the root bootstrap: `testSetOwner` hands +/// out top-level names, which only the test chain can do. +contract ChainEnsRegistry { + struct Record { + address owner; + address resolver; + uint64 ttl; + } + + mapping(bytes32 => Record) private _records; + mapping(address => mapping(address => bool)) private _operators; + + event NewOwner(bytes32 indexed node, bytes32 indexed label, address owner); + event Transfer(bytes32 indexed node, address owner); + event NewResolver(bytes32 indexed node, address resolver); + event ApprovalForAll(address indexed owner, address indexed operator, bool approved); + + error NotAuthorized(); + + modifier authorised(bytes32 node) { + address current = _records[node].owner; + if (current != msg.sender && !_operators[current][msg.sender]) revert NotAuthorized(); + _; + } + + function testSetOwner(bytes32 node, address newOwner) external { + _records[node].owner = newOwner; + emit Transfer(node, newOwner); + } + + function setOwner(bytes32 node, address newOwner) external authorised(node) { + _records[node].owner = newOwner; + emit Transfer(node, newOwner); + } + + function setSubnodeOwner(bytes32 node, bytes32 label, address newOwner) + public + authorised(node) + returns (bytes32 subnode) + { + subnode = keccak256(abi.encodePacked(node, label)); + _records[subnode].owner = newOwner; + emit NewOwner(node, label, newOwner); + } + + function setSubnodeRecord(bytes32 node, bytes32 label, address newOwner, address newResolver, uint64 newTtl) + external + { + bytes32 subnode = setSubnodeOwner(node, label, newOwner); + if (_records[subnode].resolver != newResolver) { + _records[subnode].resolver = newResolver; + emit NewResolver(subnode, newResolver); + } + _records[subnode].ttl = newTtl; + } + + function setResolver(bytes32 node, address newResolver) external authorised(node) { + _records[node].resolver = newResolver; + emit NewResolver(node, newResolver); + } + + function setApprovalForAll(address operator, bool approved) external { + _operators[msg.sender][operator] = approved; + emit ApprovalForAll(msg.sender, operator, approved); + } + + function owner(bytes32 node) external view returns (address) { + return _records[node].owner; + } + + function resolver(bytes32 node) external view returns (address) { + return _records[node].resolver; + } + + function ttl(bytes32 node) external view returns (uint64) { + return _records[node].ttl; + } + + function recordExists(bytes32 node) external view returns (bool) { + return _records[node].owner != address(0); + } + + function isApprovedForAll(address owner_, address operator) external view returns (bool) { + return _operators[owner_][operator]; + } +} + +/// @notice The public resolver: text and addr records, delegation, and multicall, with +/// the registry's owner, its approved operators, or a delegate allowed to write. +contract ChainPublicResolver { + mapping(bytes32 => mapping(string => string)) private _texts; + mapping(bytes32 => address) private _addrs; + mapping(address => mapping(bytes32 => mapping(address => bool))) private _delegates; + + event TextChanged(bytes32 indexed node, string indexed indexedKey, string key, string value); + event AddrChanged(bytes32 indexed node, address a); + event Approved(address owner, bytes32 indexed node, address indexed delegate, bool indexed approved); + + error NotAuthorized(); + + modifier authorised(bytes32 node) { + if (!_isAuthorised(node)) revert NotAuthorized(); + _; + } + + function setText(bytes32 node, string calldata key, string calldata value) external authorised(node) { + _texts[node][key] = value; + emit TextChanged(node, key, key, value); + } + + function text(bytes32 node, string calldata key) external view returns (string memory) { + return _texts[node][key]; + } + + function setAddr(bytes32 node, address a) external authorised(node) { + _addrs[node] = a; + emit AddrChanged(node, a); + } + + function addr(bytes32 node) external view returns (address payable) { + return payable(_addrs[node]); + } + + function addr(bytes32 node, uint256 coinType) external view returns (bytes memory) { + if (coinType != 60 || _addrs[node] == address(0)) return ""; + return abi.encodePacked(_addrs[node]); + } + + function approve(bytes32 node, address delegate, bool approved) external { + _delegates[msg.sender][node][delegate] = approved; + emit Approved(msg.sender, node, delegate, approved); + } + + function isApprovedFor(address owner, bytes32 node, address delegate) external view returns (bool) { + return _delegates[owner][node][delegate]; + } + + function multicall(bytes[] calldata data) external returns (bytes[] memory results) { + results = new bytes[](data.length); + for (uint256 i = 0; i < data.length; i++) { + (bool ok, bytes memory result) = address(this).delegatecall(data[i]); + if (!ok) { + assembly { + revert(add(result, 32), mload(result)) + } + } + results[i] = result; + } + } + + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == 0x01ffc9a7 || interfaceId == 0x3b3b57de || interfaceId == 0x59d1d43c + || interfaceId == 0xf1cb7e06; + } + + function _isAuthorised(bytes32 node) private view returns (bool) { + address owner = IChainEnsRegistry(ENS_REGISTRY).owner(node); + return owner == msg.sender || IChainEnsRegistry(ENS_REGISTRY).isApprovedForAll(owner, msg.sender) + || _delegates[owner][node][msg.sender]; + } +} + +/// @notice The universal resolver viem's getEnsAddress goes through: it hashes the +/// DNS-encoded name, finds its resolver in the registry, and forwards the call. +contract ChainUniversalResolver { + function resolveWithGateways(bytes calldata name, bytes calldata data, string[] calldata) + external + view + returns (bytes memory result, address resolverAddress) + { + bytes32 node = _namehash(name, 0); + resolverAddress = IChainEnsRegistry(ENS_REGISTRY).resolver(node); + if (resolverAddress == address(0)) return ("", address(0)); + (bool ok, bytes memory answer) = resolverAddress.staticcall(data); + if (!ok) return ("", resolverAddress); + return (answer, resolverAddress); + } + + function _namehash(bytes calldata name, uint256 offset) private pure returns (bytes32) { + uint256 length = uint8(name[offset]); + if (length == 0) return bytes32(0); + bytes32 label = keccak256(name[offset + 1:offset + 1 + length]); + return keccak256(abi.encodePacked(_namehash(name, offset + 1 + length), label)); + } +} diff --git a/contracts/test/chain/ChainIdentityRegistry.sol b/contracts/test/chain/ChainIdentityRegistry.sol new file mode 100644 index 0000000..88b5feb --- /dev/null +++ b/contracts/test/chain/ChainIdentityRegistry.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +interface IERC721ReceiverChain { + function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data) + external + returns (bytes4); +} + +/// @notice Stand-in for the ERC-8004 identity registry, for the local test chain only. +/// The chain suite places its runtime code at the real registry address, so it keeps +/// no constructor state: token IDs start at 1 because unset storage reads as zero. +/// It covers what ethagent calls: register, tokenURI, setAgentURI, and the ERC-721 +/// transfers a Vault deposit and unwrap make. It is not enumerable, like the real one, +/// so owner lookups go through Transfer logs. +contract ChainIdentityRegistry { + uint256 private _lastId; + mapping(uint256 => address) private _owners; + mapping(address => uint256) private _balances; + mapping(uint256 => string) private _uris; + mapping(uint256 => address) private _approved; + mapping(address => mapping(address => bool)) private _operators; + + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId); + event ApprovalForAll(address indexed owner, address indexed operator, bool approved); + event Registered(uint256 indexed agentId, address indexed owner, string agentURI); + event URIUpdated(uint256 indexed agentId, string newURI, address indexed updatedBy); + + error NotAuthorized(); + error NonexistentToken(); + error UnsafeRecipient(); + + function register(string calldata agentURI) external returns (uint256 agentId) { + agentId = ++_lastId; + _owners[agentId] = msg.sender; + _balances[msg.sender] += 1; + _uris[agentId] = agentURI; + emit Transfer(address(0), msg.sender, agentId); + emit Registered(agentId, msg.sender, agentURI); + } + + function setAgentURI(uint256 agentId, string calldata newURI) external { + if (!_isApprovedOrOwner(msg.sender, agentId)) revert NotAuthorized(); + _uris[agentId] = newURI; + emit URIUpdated(agentId, newURI, msg.sender); + } + + function tokenURI(uint256 tokenId) external view returns (string memory) { + if (_owners[tokenId] == address(0)) revert NonexistentToken(); + return _uris[tokenId]; + } + + function getMetadata(uint256, string calldata) external pure returns (bytes memory) { + return ""; + } + + function ownerOf(uint256 tokenId) public view returns (address owner) { + owner = _owners[tokenId]; + if (owner == address(0)) revert NonexistentToken(); + } + + function balanceOf(address owner) external view returns (uint256) { + return _balances[owner]; + } + + function approve(address to, uint256 tokenId) external { + address owner = ownerOf(tokenId); + if (msg.sender != owner && !_operators[owner][msg.sender]) revert NotAuthorized(); + _approved[tokenId] = to; + emit Approval(owner, to, tokenId); + } + + function getApproved(uint256 tokenId) external view returns (address) { + return _approved[tokenId]; + } + + function setApprovalForAll(address operator, bool approved) external { + _operators[msg.sender][operator] = approved; + emit ApprovalForAll(msg.sender, operator, approved); + } + + function isApprovedForAll(address owner, address operator) external view returns (bool) { + return _operators[owner][operator]; + } + + function transferFrom(address from, address to, uint256 tokenId) public { + if (!_isApprovedOrOwner(msg.sender, tokenId) || ownerOf(tokenId) != from) revert NotAuthorized(); + delete _approved[tokenId]; + _balances[from] -= 1; + _balances[to] += 1; + _owners[tokenId] = to; + emit Transfer(from, to, tokenId); + } + + function safeTransferFrom(address from, address to, uint256 tokenId) external { + safeTransferFrom(from, to, tokenId, ""); + } + + function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public { + transferFrom(from, to, tokenId); + if (to.code.length > 0) { + bytes4 answer = IERC721ReceiverChain(to).onERC721Received(msg.sender, from, tokenId, data); + if (answer != IERC721ReceiverChain.onERC721Received.selector) revert UnsafeRecipient(); + } + } + + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == 0x80ac58cd || interfaceId == 0x5b5e139f || interfaceId == 0x01ffc9a7; + } + + function _isApprovedOrOwner(address spender, uint256 tokenId) private view returns (bool) { + address owner = ownerOf(tokenId); + return spender == owner || _approved[tokenId] == spender || _operators[owner][spender]; + } +} diff --git a/package.json b/package.json index fb45922..d821761 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "typecheck": "tsc --noEmit", "test": "node test/run-tests.mjs", "test:coverage": "node test/run-tests.mjs --coverage", + "test:chain": "node test/run-chain.mjs", "previews": "tsx scripts/render-previews.ts", "prepack": "npm run build", "contracts:build": "cd contracts && forge build", diff --git a/src/cli/daemon.ts b/src/cli/daemon.ts index 9ffcf6a..4918376 100644 --- a/src/cli/daemon.ts +++ b/src/cli/daemon.ts @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process' +import { execFileSync, spawn, type ExecFileSyncOptionsWithStringEncoding } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -67,10 +67,12 @@ export function isPidAlive(pid: number): boolean { } // The daemon touches its pid file every DAEMON_HEARTBEAT_MS. A pid file left quiet for -// longer than DAEMON_STALE_MS belongs to a daemon that is gone, even when the operating -// system has since handed its pid to some other process. +// longer than DAEMON_STALE_MS no longer vouches for its pid: the daemon may be gone, and +// the operating system may have handed the pid to some other process. export const DAEMON_HEARTBEAT_MS = 30_000 export const DAEMON_STALE_MS = 120_000 +const DAEMON_TAKEOVER_WAIT_MS = 5_000 +const COMMAND_LINE_TIMEOUT_MS = 5_000 function heartbeatFresh(): boolean { try { @@ -86,20 +88,71 @@ function liveDaemonPid(): number | null { return pid && isPidAlive(pid) && heartbeatFresh() ? pid : null } -export function touchDaemonPid(): void { - if (readDaemonPid() !== process.pid) return +export type CommandLineReader = (pid: number) => string | null + +// What the process behind a pid was started as, or null when the system won't say. +export function readProcessCommandLine(pid: number): string | null { try { - const now = new Date() - fs.utimesSync(daemonPidPath(), now, now) - } catch {} + if (process.platform === 'linux') { + return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').join(' ').trim() || null + } + const options: ExecFileSyncOptionsWithStringEncoding = { + encoding: 'utf8', + timeout: COMMAND_LINE_TIMEOUT_MS, + windowsHide: true, + stdio: ['ignore', 'pipe', 'ignore'], + } + if (process.platform === 'win32') { + // -NoProfile: the profile holds ethagent's own shell hook, which runs --ensure-daemon. + const query = `(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine` + return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', query], options).trim() || null + } + return execFileSync('ps', ['-ww', '-o', 'command=', '-p', String(pid)], options).trim() || null + } catch { + return null + } +} + +// Every ethagent watcher, of any version, runs as `node ... main.tsx watch`, with --daemon +// when it was started in the background. +export function isWatcherCommandLine(commandLine: string | null): boolean { + return commandLine !== null && /main\.tsx["']?\s+watch(?:\s|$)/.test(commandLine) +} + +// The pid of the daemon, beating or not, or null. A pid that is alive but quiet is the +// daemon only when its process is an ethagent watcher: one from before the heartbeat, or +// one whose beats stalled. Anything else is a pid the system gave to another process. +function daemonPid(read: CommandLineReader): number | null { + const pid = readDaemonPid() + if (!pid || !isPidAlive(pid)) return null + return heartbeatFresh() || isWatcherCommandLine(read(pid)) ? pid : null +} + +// Beats the pid file. False tells the watcher to stop: sync was paused or disabled while +// it ran, or another live process now holds the file. +export function touchDaemonPid(): boolean { + if (daemonDisabled()) return false + const pid = readDaemonPid() + if (pid !== null && pid !== process.pid && isPidAlive(pid)) return false + if (pid === process.pid) { + try { + const now = new Date() + fs.utimesSync(daemonPidPath(), now, now) + } catch {} + return true + } + // The file was removed, or left by a pid that is gone: take it back. + try { + fs.writeFileSync(daemonPidPath(), `${process.pid}\n`) + return true + } catch { + return false + } } -export function daemonStatus(): { running: boolean; pid: number | null } { - const pid = liveDaemonPid() - if (pid) return { running: true, pid } - // A leftover file would otherwise read as running the moment its pid is reused. - if (readDaemonPid() !== null) clearDaemonPid() - return { running: false, pid: null } +export function daemonStatus(read: CommandLineReader = readProcessCommandLine): { running: boolean; pid: number | null } { + const pid = daemonPid(read) + return pid ? { running: true, pid } : { running: false, pid: null } } export function writeDaemonPid(): void { @@ -107,21 +160,36 @@ export function writeDaemonPid(): void { fs.writeFileSync(daemonPidPath(), `${process.pid}\n`, 'utf8') } -export function tryClaimDaemonPid(): boolean { +// Takes the pid file for a watcher that is starting. A watcher that is beating keeps it. +// One that is alive but quiet is an older version or has stalled, so it is stopped first, +// and the file is taken only once that process is gone: an old watcher removes the pid +// file on its way out. +export async function claimDaemonPid(read: CommandLineReader = readProcessCommandLine): Promise { fs.mkdirSync(getConfigDir(), { recursive: true }) - try { - fs.writeFileSync(daemonPidPath(), `${process.pid}\n`, { flag: 'wx' }) - return true - } catch { - const existing = daemonStatus() - if (existing.running && existing.pid !== process.pid) return false + for (let attempt = 0; attempt < 2; attempt += 1) { try { - fs.writeFileSync(daemonPidPath(), `${process.pid}\n`) + fs.writeFileSync(daemonPidPath(), `${process.pid}\n`, { flag: 'wx' }) return true - } catch { - return false + } catch {} + const beating = liveDaemonPid() + if (beating && beating !== process.pid) return false + const quiet = beating ? null : daemonPid(read) + if (quiet && quiet !== process.pid) { + try { process.kill(quiet, 'SIGTERM') } catch {} + if (!(await pidGone(quiet))) return false } + clearDaemonPid() } + return false +} + +async function pidGone(pid: number): Promise { + const deadline = Date.now() + DAEMON_TAKEOVER_WAIT_MS + while (isPidAlive(pid)) { + if (Date.now() >= deadline) return false + await new Promise(resolve => setTimeout(resolve, 100)) + } + return true } export function clearDaemonPid(): void { @@ -130,6 +198,12 @@ export function clearDaemonPid(): void { } catch {} } +// Removes the pid file only while it is still this process's: a watcher that was taken +// over must not delete its successor's. +export function releaseDaemonPid(): void { + if (readDaemonPid() === process.pid) clearDaemonPid() +} + export function daemonEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const out: NodeJS.ProcessEnv = {} for (const [key, value] of Object.entries(env)) { @@ -141,7 +215,9 @@ export function daemonEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { export function ensureDaemon(): boolean { if (daemonDisabled()) return false - if (daemonStatus().running) return false + // Only a beating daemon is left alone. A watcher that went quiet (an older version, or + // one that stalled) is taken over by the one started here, as it claims the pid file. + if (liveDaemonPid()) return false try { fs.mkdirSync(getConfigDir(), { recursive: true }) const out = fs.openSync(daemonLogPath(), 'a') @@ -159,9 +235,10 @@ export function ensureDaemon(): boolean { } } -export function stopDaemon(): boolean { - // Only a pid that is still beating is ours to signal; a stale one may be anything now. - const pid = liveDaemonPid() +export function stopDaemon(read: CommandLineReader = readProcessCommandLine): boolean { + // A quiet pid is signalled only when its process is an ethagent watcher; otherwise it + // may be anything now. + const pid = daemonPid(read) let stopped = false if (pid) { try { diff --git a/src/cli/onchain/check.ts b/src/cli/onchain/check.ts index 85eceae..7d4f3c1 100644 --- a/src/cli/onchain/check.ts +++ b/src/cli/onchain/check.ts @@ -37,7 +37,7 @@ async function localChangedFiles(identity: EthagentIdentity): Promise openBrowserWalletSession({ title: 'ethagent create', onReady }), openExternal: url => openExternalUrl(url), saveConfig, diff --git a/src/cli/onchain/custody.ts b/src/cli/onchain/custody.ts index 4c117da..e82a7e7 100644 --- a/src/cli/onchain/custody.ts +++ b/src/cli/onchain/custody.ts @@ -28,7 +28,7 @@ export type CustodySeams = { client: (registry: Erc8004RegistryConfig) => CustodyClient } -const defaultSeams: CustodySeams = { +export const defaultSeams: CustodySeams = { client: registry => createErc8004PublicClient(registry), } diff --git a/src/cli/onchain/custodyWrite.ts b/src/cli/onchain/custodyWrite.ts index 5dcc7d7..5eaca61 100644 --- a/src/cli/onchain/custodyWrite.ts +++ b/src/cli/onchain/custodyWrite.ts @@ -36,7 +36,7 @@ import { } from '../../identity/manager/shared/operatorAccess.js' import type { ProfileUpdates, Step } from '../../identity/manager/reducer.js' import { continuityVaultStatus } from '../../identity/continuity/storage/status.js' -import { resolveValidatedPinataJwt } from '../../identity/storage/pinataJwt.js' +import { resolveUploadCredential, resolveValidatedPinataJwt } from '../../identity/storage/pinataJwt.js' import { signMessage } from '../../identity/crypto/eth.js' import { openBrowserWalletSession, type BrowserWalletReady, type BrowserWalletSession } from '../../identity/wallet/browserWallet.js' import { openExternalUrl } from '../../utils/openExternal.js' @@ -81,7 +81,7 @@ export const defaultCustodyWriteSeams: CustodyWriteSeams = { revoke: revokeVaultOperatorsBeforeWithdraw, recordVault: recordDeployedVault, publish: runRebackupSigningInSession, - resolveJwt: resolveValidatedPinataJwt, + resolveJwt: resolveUploadCredential, vaultStatus: continuityVaultStatus, pullHarness: pullHarnessSoulMemoryIntoVault, openSession: onReady => openBrowserWalletSession({ title: 'ethagent custody', onReady }), diff --git a/src/cli/onchain/ens.ts b/src/cli/onchain/ens.ts index 498c723..0f85941 100644 --- a/src/cli/onchain/ens.ts +++ b/src/cli/onchain/ens.ts @@ -1,10 +1,10 @@ import { getAddress, type Address, type PublicClient } from 'viem' import type { EthagentConfig, EthagentIdentity } from '../../storage/config.js' import { saveConfig } from '../../storage/config.js' -import { createMainnetClient, normalizeEthDomain, splitSubdomainName, validateAgentEnsLink } from '../../identity/ens/ensLookup.js' +import { createMainnetClient, ensRpcUrl, normalizeEthDomain, splitSubdomainName, validateAgentEnsLink } from '../../identity/ens/ensLookup.js' import { readAddressRecord, readTextRecords } from '../../identity/ens/ensAutomation/read.js' import { resolveRegistryForIdentity } from '../../identity/registry/registryConfig.js' -import { DEFAULT_ETHEREUM_RPC_URL, type Erc8004RegistryConfig } from '../../identity/registry/erc8004.js' +import type { Erc8004RegistryConfig } from '../../identity/registry/erc8004.js' import { humanOwnerAddress } from '../../identity/manager/custody/helpers.js' import { readIdentityStateString } from '../../identity/manager/custody/state.js' import { resolveVaultAddress } from '../../identity/manager/custody/transactions.js' @@ -28,7 +28,7 @@ import { isWalletCancelled } from '../../identity/manager/shared/utils.js' import type { EffectCallbacks } from '../../identity/manager/shared/effects/types.js' import type { Step } from '../../identity/manager/reducer.js' import { continuityVaultStatus } from '../../identity/continuity/storage/status.js' -import { resolveValidatedPinataJwt } from '../../identity/storage/pinataJwt.js' +import { resolveUploadCredential, resolveValidatedPinataJwt } from '../../identity/storage/pinataJwt.js' import { openBrowserWalletSession, type BrowserWalletSession, type BrowserWalletReady } from '../../identity/wallet/browserWallet.js' import { createLocalKeySender, type LocalKeySender } from '../../identity/wallet/localKeyWallet.js' import { openExternalUrl } from '../../utils/openExternal.js' @@ -88,13 +88,13 @@ export type EnsSeams = { saveConfig: (config: EthagentConfig) => Promise } -const defaultSeams: EnsSeams = { +export const defaultSeams: EnsSeams = { ensClient: () => createMainnetEnsPublicClient(), readClient: () => createMainnetClient(), - operatorSender: privateKey => createLocalKeySender({ privateKey, chainId: 1, rpcUrl: DEFAULT_ETHEREUM_RPC_URL }), + operatorSender: privateKey => createLocalKeySender({ privateKey, chainId: 1, rpcUrl: ensRpcUrl() }), openSession: onReady => openBrowserWalletSession({ title: 'ethagent ENS', onReady }), publish: runRebackupSigningInSession, - resolveJwt: resolveValidatedPinataJwt, + resolveJwt: resolveUploadCredential, vaultStatus: continuityVaultStatus, pullHarness: pullHarnessSoulMemoryIntoVault, openExternal: url => openExternalUrl(url), @@ -385,7 +385,7 @@ export async function runEnsCommand(args: string[], deps: HistoryDeps, seams: En } catch (err) { throw new HistoryError(3, `The configured Pinata JWT is invalid or unreachable (${err instanceof Error ? err.message : String(err)}). Nothing was sent.`) } - if (!jwt) throw new HistoryError(3, 'No IPFS storage credential configured, so the name cannot be published.', 'Save one with `ethagent storage --set` (or export PINATA_JWT), then retry. Nothing was sent.') + if (jwt === undefined) throw new HistoryError(3, 'No IPFS storage credential configured, so the name cannot be published.', 'Save one with `ethagent storage --set` (or export PINATA_JWT), then retry. Nothing was sent.') saveReady = { jwt } await seams.pullHarness(identity).catch(() => []) } diff --git a/src/cli/onchain/profile.ts b/src/cli/onchain/profile.ts index f15f0fc..0ad1ef8 100644 --- a/src/cli/onchain/profile.ts +++ b/src/cli/onchain/profile.ts @@ -4,7 +4,7 @@ import type { EthagentConfig, EthagentIdentity } from '../../storage/config.js' import { saveConfig } from '../../storage/config.js' import { resolveRegistryForIdentity } from '../../identity/registry/registryConfig.js' import { continuityVaultStatus } from '../../identity/continuity/storage/status.js' -import { resolveValidatedPinataJwt } from '../../identity/storage/pinataJwt.js' +import { resolveUploadCredential, resolveValidatedPinataJwt } from '../../identity/storage/pinataJwt.js' import { validateAgentIconReference, isAgentIconUrl } from '../../identity/profile/agentIcon.js' import { resolveAgentIconPath, deriveAgentName } from '../../identity/manager/shared/effects/profilePrep.js' import { runRebackupSigningInSession } from '../../identity/manager/continuity/effects.js' @@ -54,11 +54,11 @@ export type ProfileSeams = { saveConfig: (config: EthagentConfig) => Promise } -const defaultSeams: ProfileSeams = { +export const defaultSeams: ProfileSeams = { publishOwner: runRebackupSigningInSession, publishOperator: runOperatorWalletRebackup, operatorRunner: createLocalKeySignAndTransaction, - resolveJwt: resolveValidatedPinataJwt, + resolveJwt: resolveUploadCredential, vaultStatus: continuityVaultStatus, pullHarness: pullHarnessSoulMemoryIntoVault, imageExists: async file => fs.stat(file).then(stat => stat.isFile(), () => false), diff --git a/src/cli/onchain/restore.ts b/src/cli/onchain/restore.ts index fd4ced8..cadc3ac 100644 --- a/src/cli/onchain/restore.ts +++ b/src/cli/onchain/restore.ts @@ -78,7 +78,7 @@ export type RestoreSeams = { saveConfig: (config: EthagentConfig) => Promise } -const defaultSeams: RestoreSeams = { +export const defaultSeams: RestoreSeams = { resolveTokenId: resolveAgentTokenIdToCandidate, resolveEns: resolveAgentEnsToCandidate, ensTokenChain: async name => { diff --git a/src/cli/onchain/shared.ts b/src/cli/onchain/shared.ts index 08fe1d4..c4f4ee1 100644 --- a/src/cli/onchain/shared.ts +++ b/src/cli/onchain/shared.ts @@ -36,7 +36,7 @@ export async function requireStorage(resolveJwt: () => Promise { return Buffer.concat(chunks).toString('utf8') } -const defaultSeams: StorageSeams = { +export const defaultSeams: StorageSeams = { has: hasPinataJwt, save: async input => { const saved = await savePinataJwt(input) diff --git a/src/cli/onchain/transfer.ts b/src/cli/onchain/transfer.ts index dce0396..f12ba59 100644 --- a/src/cli/onchain/transfer.ts +++ b/src/cli/onchain/transfer.ts @@ -3,14 +3,15 @@ import type { EthagentConfig } from '../../storage/config.js' import { saveConfig } from '../../storage/config.js' import { resolveRegistryForIdentity } from '../../identity/registry/registryConfig.js' import { continuityVaultStatus } from '../../identity/continuity/storage/status.js' -import { resolveValidatedPinataJwt } from '../../identity/storage/pinataJwt.js' +import { resolveUploadCredential, resolveValidatedPinataJwt } from '../../identity/storage/pinataJwt.js' import { assertTokenNotInVault, TokenInVaultError } from '../../identity/manager/custody/preflight.js' import { resolveTransferTargetAddress, runTokenTransferSigning } from '../../identity/manager/transfer/effects.js' import { humanOwnerAddress } from '../../identity/manager/custody/helpers.js' import { openExternalUrl } from '../../utils/openExternal.js' +import { openBrowserWalletSession } from '../../identity/wallet/browserWallet.js' import { pullHarnessSoulMemoryIntoVault } from '../sync.js' import { emitJson, failFrom, HistoryError, parseHistoryArgs, requireIdentity, type HistoryDeps } from '../history/shared.js' -import { quietCallbacks, requireStorage, walletCancelled } from './shared.js' +import { quietCallbacks, requireStorage, walletCancelled, WalletTab, type OpenSession } from './shared.js' export const TRANSFER_USAGE = 'ethagent transfer [--yes] [--no-open] [--json]' @@ -34,17 +35,19 @@ export type TransferSeams = { resolveJwt: typeof resolveValidatedPinataJwt vaultStatus: typeof continuityVaultStatus pullHarness: typeof pullHarnessSoulMemoryIntoVault + openSession: OpenSession openExternal: (url: string) => void saveConfig: (config: EthagentConfig) => Promise } -const defaultSeams: TransferSeams = { +export const defaultSeams: TransferSeams = { resolveTarget: handle => resolveTransferTargetAddress(handle), assertNotInVault: assertTokenNotInVault, sign: runTokenTransferSigning, - resolveJwt: resolveValidatedPinataJwt, + resolveJwt: resolveUploadCredential, vaultStatus: continuityVaultStatus, pullHarness: pullHarnessSoulMemoryIntoVault, + openSession: onReady => openBrowserWalletSession({ title: 'ethagent transfer', onReady }), openExternal: url => openExternalUrl(url), saveConfig, } @@ -113,8 +116,11 @@ export async function runTransferCommand(args: string[], deps: HistoryDeps, seam const jwt = await requireStorage(seams.resolveJwt) await seams.pullHarness(identity).catch(() => []) const noOpen = Boolean(values['no-open']) + const tab = new WalletTab(seams.openSession, deps.io, json, noOpen, seams.openExternal) let result try { + const session = await tab.get() + await (json ? deps.io.err : deps.io.out)('Sign with the owner wallet, switch to the receiver wallet when asked, then approve the publish with the owner.\n') result = await seams.sign({ kind: 'token-transfer-signing', identity, @@ -123,18 +129,13 @@ export async function runTransferCommand(args: string[], deps: HistoryDeps, seam targetAddress: target, pinataJwt: jwt, returnTo: { kind: 'menu' }, - }, quietCallbacks({ - onWalletReady: ready => { - if (!ready) return - const sink = json ? deps.io.err : deps.io.out - void sink(`Approve in your browser wallet tab: ${ready.url}\nSign with the owner wallet, switch to the receiver wallet when asked, then approve the publish with the owner.\n`) - if (!noOpen) seams.openExternal(ready.url) - }, - })) + }, quietCallbacks(), { session }) } catch (err: unknown) { const cancelled = walletCancelled(err, []) if (cancelled) throw cancelled throw err + } finally { + await tab.close() } await seams.saveConfig({ ...config, identity: result.identity }) const output = { applied: true, ...summary, snapshot: result.snapshotCid, txHash: result.txHash } diff --git a/src/cli/operatorSave.ts b/src/cli/operatorSave.ts index 800ce09..f4619f8 100644 --- a/src/cli/operatorSave.ts +++ b/src/cli/operatorSave.ts @@ -4,7 +4,7 @@ import { resolveRegistryForIdentity } from '../identity/registry/registryConfig. import { continuityVaultStatus, continuityWorkingTreeStatus } from '../identity/continuity/storage/status.js' import { listPublishedContinuitySnapshots } from '../identity/continuity/snapshots.js' import { discoverOwnedAgentBackupByTokenId } from '../identity/registry/erc8004/discovery.js' -import { resolveValidatedPinataJwt } from '../identity/storage/pinataJwt.js' +import { resolveUploadCredential, resolveValidatedPinataJwt } from '../identity/storage/pinataJwt.js' import { resolveVaultAddress } from '../identity/manager/custody/transactions.js' import { runOperatorWalletRebackup } from '../identity/manager/continuity/vault.js' import { deriveAgentName } from '../identity/manager/shared/effects/profilePrep.js' @@ -30,11 +30,11 @@ export type RunOperatorSaveDeps = { discoverOwnedAgentBackupByTokenId: typeof discoverOwnedAgentBackupByTokenId } -const defaultDeps: RunOperatorSaveDeps = { +export const defaultOperatorSaveDeps: RunOperatorSaveDeps = { readOperatorKey, loadConfig, saveConfig, - resolveValidatedPinataJwt, + resolveValidatedPinataJwt: resolveUploadCredential, continuityVaultStatus, continuityWorkingTreeStatus, listPublishedContinuitySnapshots, @@ -44,7 +44,7 @@ const defaultDeps: RunOperatorSaveDeps = { discoverOwnedAgentBackupByTokenId, } -export async function runOperatorSave(args: string[] = [], deps: RunOperatorSaveDeps = defaultDeps): Promise { +export async function runOperatorSave(args: string[] = [], deps: RunOperatorSaveDeps = defaultOperatorSaveDeps): Promise { const json = args.includes('--json') const fail = (code: number, message: string): number => { if (json) stdout.write(saveJson({ ok: false, code, error: message })) @@ -95,7 +95,7 @@ export async function runOperatorSave(args: string[] = [], deps: RunOperatorSave const detail = err instanceof Error ? err.message : String(err) return fail(3, `The configured Pinata JWT is invalid or unreachable (${detail}). Replace it with \`ethagent storage --set\` (reads the JWT from stdin), then retry.`) } - if (!jwt) { + if (jwt === undefined) { return fail(3, 'No IPFS storage credential configured, so the snapshot cannot be pinned. Save one with `ethagent storage --set` (or export PINATA_JWT), then retry.') } diff --git a/src/cli/save.ts b/src/cli/save.ts index b133b0b..8a05786 100644 --- a/src/cli/save.ts +++ b/src/cli/save.ts @@ -3,7 +3,7 @@ import { loadConfig, saveConfig, type EthagentConfig, type EthagentIdentity } fr import { resolveRegistryForIdentity } from '../identity/registry/registryConfig.js' import { continuityVaultStatus, continuityWorkingTreeStatus } from '../identity/continuity/storage/status.js' import { listPublishedContinuitySnapshots } from '../identity/continuity/snapshots.js' -import { resolveValidatedPinataJwt } from '../identity/storage/pinataJwt.js' +import { resolveUploadCredential, resolveValidatedPinataJwt } from '../identity/storage/pinataJwt.js' import { runRebackupSigning } from '../identity/manager/continuity/effects.js' import type { EffectCallbacks } from '../identity/manager/shared/effects/types.js' import { isWalletCancelled } from '../identity/manager/shared/utils.js' @@ -30,7 +30,7 @@ export type RunSaveDeps = { const defaultDeps: RunSaveDeps = { loadConfig, saveConfig, - resolveValidatedPinataJwt, + resolveValidatedPinataJwt: resolveUploadCredential, continuityVaultStatus, continuityWorkingTreeStatus, listPublishedContinuitySnapshots, @@ -95,7 +95,7 @@ export async function runSave(args: string[] = [], deps: RunSaveDeps = defaultDe const detail = err instanceof Error ? err.message : String(err) return fail(3, `The configured Pinata JWT is invalid or unreachable (${detail}). The wallet was not opened. Replace it with \`ethagent storage --set\` (reads the JWT from stdin), then retry \`ethagent save\`.`) } - if (!jwt) { + if (jwt === undefined) { return fail(3, 'No IPFS storage credential configured, so the snapshot cannot be pinned and the wallet was not opened. Save one with `ethagent storage --set` (reads the JWT from stdin), or export PINATA_JWT in this shell, then retry `ethagent save`.') } diff --git a/src/cli/watch.ts b/src/cli/watch.ts index 3684ff4..893ad3a 100644 --- a/src/cli/watch.ts +++ b/src/cli/watch.ts @@ -5,14 +5,14 @@ import { continuityVaultRef } from '../identity/continuity/storage/paths.js' import { runSync } from './sync.js' import { BUILT_IN_ADAPTERS, adapterManagedFilePaths } from './syncAdapters/index.js' import { - clearDaemonPid, + claimDaemonPid, daemonDisabled, daemonLogPath, daemonStatus, DAEMON_HEARTBEAT_MS, + releaseDaemonPid, stopDaemon, touchDaemonPid, - tryClaimDaemonPid, } from './daemon.js' const DEBOUNCE_MS = 400 @@ -110,12 +110,17 @@ export async function runWatch(argv: string[]): Promise { return 0 } - if (!tryClaimDaemonPid()) { + if (!(await claimDaemonPid())) { if (!isDaemon) process.stdout.write('ethagent: watcher already running\n') return 0 } - const heartbeat = setInterval(touchDaemonPid, DAEMON_HEARTBEAT_MS) + const heartbeat = setInterval(() => { + if (touchDaemonPid()) return + logDaemon('stopping: sync was paused, or another watcher took over') + cleanup() + process.exit(0) + }, DAEMON_HEARTBEAT_MS) let rescan: NodeJS.Timeout | null = null let timer: NodeJS.Timeout | null = null let syncing = false @@ -137,7 +142,7 @@ export async function runWatch(argv: string[]): Promise { if (timer) clearTimeout(timer) for (const watcher of watched.values()) { try { watcher.close() } catch {} } watched.clear() - clearDaemonPid() + releaseDaemonPid() } process.on('SIGTERM', () => { cleanup(); process.exit(0) }) process.on('SIGINT', () => { cleanup(); process.exit(0) }) diff --git a/src/identity/ens/ensLookup.ts b/src/identity/ens/ensLookup.ts index 7289e0c..32ca1b5 100644 --- a/src/identity/ens/ensLookup.ts +++ b/src/identity/ens/ensLookup.ts @@ -12,6 +12,7 @@ export { splitSubdomainName, } from './ensLookup/names.js' export { createMainnetClient } from './ensLookup/client.js' +export { ensRpcUrl, ensRpcUrls } from './ensLookup/constants.js' export { resolveEnsAddress, readEthagentTextRecords, readResolverAddress } from './ensLookup/resolve.js' export { parseAgentTokenReference } from './ensLookup/tokenReference.js' export { discoverOwnedEnsNameDetails, discoverOwnedEnsNames } from './ensLookup/discovery.js' diff --git a/src/identity/ens/ensLookup/client.ts b/src/identity/ens/ensLookup/client.ts index 88a9a15..9dd926c 100644 --- a/src/identity/ens/ensLookup/client.ts +++ b/src/identity/ens/ensLookup/client.ts @@ -1,7 +1,7 @@ import { createPublicClient, type PublicClient } from 'viem' import { mainnet } from 'viem/chains' import { adaptiveRpcTransport } from '../../../net/rpc.js' -import { ENS_RPC_URLS } from './constants.js' +import { ensRpcUrls } from './constants.js' // ENS lives on mainnet. Reads go through the adaptive transport: each endpoint gets the // bound it has earned, a slow one gets a competitor, and nothing is cut off by a fixed @@ -9,7 +9,7 @@ import { ENS_RPC_URLS } from './constants.js' export function createMainnetClient(): PublicClient { return createPublicClient({ chain: mainnet, - transport: adaptiveRpcTransport(ENS_RPC_URLS), + transport: adaptiveRpcTransport(ensRpcUrls()), }) } diff --git a/src/identity/ens/ensLookup/constants.ts b/src/identity/ens/ensLookup/constants.ts index fecfddf..f138e42 100644 --- a/src/identity/ens/ensLookup/constants.ts +++ b/src/identity/ens/ensLookup/constants.ts @@ -9,6 +9,17 @@ export const ENS_RPC_URLS = [ 'https://rpc.ankr.com/eth', ] as const +// ETHAGENT_ENS_RPC_URL puts an Ethereum Mainnet RPC of your choice ahead of the public +// ones for every ENS read and write. +export function ensRpcUrls(): string[] { + const own = process.env.ETHAGENT_ENS_RPC_URL?.trim() + return own ? [own, ...ENS_RPC_URLS.filter(url => url !== own)] : [...ENS_RPC_URLS] +} + +export function ensRpcUrl(): string { + return ensRpcUrls()[0]! +} + export const ETH_NAME_PATTERN = /^([a-z0-9-]+\.)+eth$/i export const ENS_REGISTRY_ABI = parseAbi([ diff --git a/src/identity/manager/ens/transactions.ts b/src/identity/manager/ens/transactions.ts index 9c6f918..fd1449c 100644 --- a/src/identity/manager/ens/transactions.ts +++ b/src/identity/manager/ens/transactions.ts @@ -1,12 +1,11 @@ import type { Address, Hex, PublicClient } from 'viem' import { blockTimeMsForChain, - DEFAULT_ETHEREUM_RPC_URL, RegisterAgentPreflightError, createErc8004PublicClient, supportedErc8004ChainForId, } from '../../registry/erc8004.js' -import { encodeSetEnsip25TextRecord, readEthagentTextRecords } from '../../ens/ensLookup.js' +import { encodeSetEnsip25TextRecord, ensRpcUrl, readEthagentTextRecords } from '../../ens/ensLookup.js' import { encodeEnsRecordsTransaction, encodeEnsRegistryTransaction, readAddressRecord, type EnsSetupPlan, type EnsSubdomainDeletePlan } from '../../ens/ensAutomation.js' import type { AgentEnsRecordState, AgentEnsRecords, AgentRecordDiff } from '../../ens/agentRecords.js' import { changedRecords, clearedRecords, diffRecords } from '../../ens/agentRecords.js' @@ -282,7 +281,7 @@ async function refreshEnsSetupAgainstChain(setup: EnsSetupPlan, publicClient: Pu export function createMainnetEnsPublicClient(): PublicClient { return createErc8004PublicClient({ chainId: 1, - rpcUrl: DEFAULT_ETHEREUM_RPC_URL, + rpcUrl: ensRpcUrl(), }) } diff --git a/src/identity/manager/transfer/effects.ts b/src/identity/manager/transfer/effects.ts index 1660bb9..67f1598 100644 --- a/src/identity/manager/transfer/effects.ts +++ b/src/identity/manager/transfer/effects.ts @@ -26,7 +26,7 @@ import { withEthagentPointers, } from '../../registry/erc8004.js' import { resolveValidatedPinataJwt, savePinataJwt } from '../../storage/pinataJwt.js' -import { openBrowserWalletSession, prepareTransactionGasFee } from '../../wallet/browserWallet.js' +import { openBrowserWalletSession, prepareTransactionGasFee, type BrowserWalletSession } from '../../wallet/browserWallet.js' import { resolveEnsAddress } from '../../ens/ensLookup.js' import type { Step } from '../reducer.js' import type { EffectCallbacks } from '../shared/effects/types.js' @@ -119,6 +119,7 @@ export async function runTokenTransferStorageSubmit( export async function runTokenTransferSigning( step: Extract, callbacks: EffectCallbacks, + opts: { session?: BrowserWalletSession } = {}, ): Promise { if (!step.identity.agentId) throw new Error('Cannot prepare token transfer: identity is missing an agent token ID') const transferAgentId = step.identity.agentId @@ -132,7 +133,10 @@ export async function runTokenTransferSigning( const senderChallenge = createTransferContinuitySnapshotChallenge({ token, ownerAddress, targetAddress, role: 'sender' }) const targetChallenge = createTransferContinuitySnapshotChallenge({ token, ownerAddress, targetAddress, role: 'receiver' }) - const session = await openBrowserWalletSession({ onReady: callbacks.onWalletReady, ...(callbacks.signal ? { signal: callbacks.signal } : {}) }) + // A caller's tab (the headless command's) stays open for it to close; one opened here + // closes with the transfer. + const ownSession = !opts.session + const session = opts.session ?? await openBrowserWalletSession({ onReady: callbacks.onWalletReady, ...(callbacks.signal ? { signal: callbacks.signal } : {}) }) try { callbacks.onTokenTransferProgress?.(tokenTransferProgressForPhase('sender-sign', ownerAddress, targetAddress)) @@ -281,8 +285,10 @@ export async function runTokenTransferSigning( } } finally { - await session.close().catch(() => {}) - callbacks.onWalletReady(null) + if (ownSession) { + await session.close().catch(() => {}) + callbacks.onWalletReady(null) + } } } diff --git a/src/identity/storage/pinataJwt.ts b/src/identity/storage/pinataJwt.ts index 6c2010f..72c41b1 100644 --- a/src/identity/storage/pinataJwt.ts +++ b/src/identity/storage/pinataJwt.ts @@ -1,5 +1,5 @@ import { getSecret, hasSecret, rmSecret, setSecret, type KeyBackend } from '../../storage/secrets.js' -import { extractPinataJwt, validatePinataJwt, type FetchLike } from './ipfs.js' +import { DEFAULT_IPFS_API_URL, extractPinataJwt, isPinataUploadUrl, validatePinataJwt, type FetchLike } from './ipfs.js' const ACCOUNT = 'pinata:jwt' @@ -43,6 +43,14 @@ export async function resolveValidatedPinataJwt(fetchImpl: FetchLike = fetch): P return validatePinataJwt(jwt, fetchImpl) } +// What a save needs to pin: a validated Pinata JWT when uploads go to Pinata, or '' when +// ETHAGENT_IPFS_API_URL points at another IPFS API that needs none. undefined means a +// Pinata credential is needed and missing. +export async function resolveUploadCredential(fetchImpl: FetchLike = fetch): Promise { + if (!isPinataUploadUrl(DEFAULT_IPFS_API_URL)) return '' + return resolveValidatedPinataJwt(fetchImpl) +} + export function invalidatePinataJwtCache(): void { cached = undefined } diff --git a/test/chain/harness.ts b/test/chain/harness.ts new file mode 100644 index 0000000..bb840a5 --- /dev/null +++ b/test/chain/harness.ts @@ -0,0 +1,182 @@ +import fs from 'node:fs' +import path from 'node:path' +import { + createWalletClient, + getAddress, + http, + namehash, + type Address, + type Hex, +} from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { base, mainnet } from 'viem/chains' +import type { BrowserWalletReady, BrowserWalletSession } from '../../src/identity/wallet/browserWallet.js' +import { defaultHistoryDeps, type HistoryDeps } from '../../src/cli/history/shared.js' +import { captureIo, type CapturedIo } from '../support/home.js' + +// Shared setup for the chain suite (run by test/run-chain.mjs). Every request that is +// not to this machine is refused, so a test can never reach a public RPC or gateway. + +const MAINNET_RPC = requiredEnv('ETHAGENT_CHAIN_MAINNET_RPC') +const BASE_RPC = requiredEnv('ETHAGENT_CHAIN_BASE_RPC') +const ARTIFACTS = requiredEnv('ETHAGENT_CHAIN_ARTIFACTS') + +function requiredEnv(name: string): string { + const value = process.env[name] + if (!value) throw new Error(`${name} is not set; run the chain suite with \`npm run test:chain\``) + return value +} + +const realFetch = globalThis.fetch +globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(typeof input === 'string' || input instanceof URL ? String(input) : input.url) + if (url.hostname !== '127.0.0.1' && url.hostname !== 'localhost') { + throw new TypeError(`fetch failed: the chain suite refuses ${url.host}`) + } + return realFetch(input, init) +}) as typeof fetch + +// Anvil's well-known development keys: funded on both chains, never real. +export const KEYS = { + owner: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + operator: '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', + receiver: '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a', + parent: '0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6', +} as const satisfies Record + +export type Role = keyof typeof KEYS +export const ADDRESS: Record = Object.fromEntries( + Object.entries(KEYS).map(([role, key]) => [role, privateKeyToAccount(key).address]), +) as Record + +export const REGISTRY = getAddress('0x8004A169FB4a3325136EB29fA0ceB6D2e539a432') +const ENS_REGISTRY = getAddress('0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e') +const PUBLIC_RESOLVER = getAddress('0xF29100983E058B709F3D539b0c765937B804AC15') +const UNIVERSAL_RESOLVER = getAddress('0xeeeeeeee14d718c2b47d9923deab1335e144eeee') + +export async function rpc(url: string, method: string, params: unknown[] = []): Promise { + const response = await realFetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }) + const body = await response.json() as { result?: T; error?: { message: string } } + if (body.error) throw new Error(`${method}: ${body.error.message}`) + return body.result as T +} + +function runtimeCode(file: string, contract: string): Hex { + const artifact = JSON.parse(fs.readFileSync(path.join(ARTIFACTS, file, `${contract}.json`), 'utf8')) as { deployedBytecode: { object: Hex } } + return artifact.deployedBytecode.object +} + +// Puts the stand-in contracts at the real addresses on a fresh pair of chains. +export async function placeContracts(): Promise { + await rpc(BASE_RPC, 'anvil_setCode', [REGISTRY, runtimeCode('ChainIdentityRegistry.sol', 'ChainIdentityRegistry')]) + await rpc(MAINNET_RPC, 'anvil_setCode', [ENS_REGISTRY, runtimeCode('ChainEns.sol', 'ChainEnsRegistry')]) + await rpc(MAINNET_RPC, 'anvil_setCode', [PUBLIC_RESOLVER, runtimeCode('ChainEns.sol', 'ChainPublicResolver')]) + await rpc(MAINNET_RPC, 'anvil_setCode', [UNIVERSAL_RESOLVER, runtimeCode('ChainEns.sol', 'ChainUniversalResolver')]) +} + +// Hands a top-level name to an address, as buying it would. +export async function giveName(name: string, to: Address): Promise { + const wallet = createWalletClient({ account: privateKeyToAccount(KEYS.parent), chain: mainnet, transport: http(MAINNET_RPC) }) + await wallet.writeContract({ + address: ENS_REGISTRY, + abi: [{ type: 'function', name: 'testSetOwner', stateMutability: 'nonpayable', inputs: [{ name: 'node', type: 'bytes32' }, { name: 'owner', type: 'address' }], outputs: [] }], + functionName: 'testSetOwner', + args: [namehash(name), to], + }) +} + +// Sends the agent token from one wallet to another, as the owner does in their own +// wallet after a prepared transfer; ethagent never moves it. +export async function sendToken(from: Role, to: Address, tokenId: bigint): Promise { + const wallet = createWalletClient({ account: privateKeyToAccount(KEYS[from]), chain: base, transport: http(BASE_RPC) }) + await wallet.writeContract({ + address: REGISTRY, + abi: [{ type: 'function', name: 'safeTransferFrom', stateMutability: 'nonpayable', inputs: [{ name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'tokenId', type: 'uint256' }], outputs: [] }], + functionName: 'safeTransferFrom', + args: [ADDRESS[from], to, tokenId], + }) +} + +const CHAINS = { 1: { chain: mainnet, rpc: MAINNET_RPC }, 8453: { chain: base, rpc: BASE_RPC } } as const + +// A wallet session that signs with the development keys instead of a browser tab. A +// request naming its account gets that one; anything else gets the account in use, +// which a test switches like a person switching accounts in their wallet. +export class TestWallet { + current: Role = 'owner' + readonly prompts: string[] = [] + opened = 0 + + use(role: Role): this { + this.current = role + return this + } + + private roleFor(expected?: Address): Role { + if (!expected) return this.current + const role = (Object.keys(ADDRESS) as Role[]).find(item => ADDRESS[item].toLowerCase() === expected.toLowerCase()) + if (!role) throw new Error(`the test wallet holds no key for ${expected}`) + return role + } + + private walletFor(chainId: number, role: Role) { + const target = CHAINS[chainId as keyof typeof CHAINS] + if (!target) throw new Error(`the test wallet has no chain ${chainId}`) + return createWalletClient({ account: privateKeyToAccount(KEYS[role]), chain: target.chain, transport: http(target.rpc) }) + } + + readonly session: BrowserWalletSession = { + url: 'http://127.0.0.1/test-wallet', + requestSignature: async req => { + const role = this.roleFor(req.expectedAccount) + const account = ADDRESS[role] + const message = req.message ?? req.messageForAccount?.(account) + if (message === undefined) throw new Error('signature request without a message') + this.prompts.push(`sign:${req.purpose ?? 'unknown'}:${role}`) + return { account, message, signature: await privateKeyToAccount(KEYS[role]).signMessage({ message }) } + }, + sendTransaction: async req => { + const role = this.roleFor(req.expectedAccount) + this.prompts.push(`send:${req.purpose ?? 'unknown'}:${role}`) + const txHash = await this.walletFor(req.chainId, role).sendTransaction({ + to: req.to ?? null, + data: req.data, + ...(req.value ? { value: BigInt(req.value) } : {}), + } as never) + return { account: ADDRESS[role], txHash } + }, + requestSignatureAndTransaction: async req => { + const signed = await this.session.requestSignature(req) + const prepared = await req.prepareTransaction(signed) + const role = this.roleFor(signed.account) + this.prompts.push(`send:${req.purpose ?? 'unknown'}:${role}`) + const txHash = await this.walletFor(req.chainId, role).sendTransaction({ + to: prepared.to, + data: prepared.data, + ...(prepared.value ? { value: BigInt(prepared.value) } : {}), + } as never) + return { ...signed, txHash, prepared: prepared.prepared } + }, + close: async () => {}, + } + + open = async (onReady: (ready: BrowserWalletReady) => void): Promise => { + this.opened += 1 + onReady({ url: this.session.url }) + return this.session + } +} + +// History deps over the real config in this process's HOME, with captured output and +// an optional injected operator key. +export function chainDeps(operatorKey?: Role): HistoryDeps & { io: CapturedIo } { + const deps: HistoryDeps & { io: CapturedIo } = { ...defaultHistoryDeps(), io: captureIo(), env: process.env } + if (operatorKey) deps.operatorKey = { ok: true, key: KEYS[operatorKey], address: ADDRESS[operatorKey] } + return deps +} + +export const RPC = { mainnet: MAINNET_RPC, base: BASE_RPC } diff --git a/test/chain/lifecycle.test.ts b/test/chain/lifecycle.test.ts new file mode 100644 index 0000000..125e2e1 --- /dev/null +++ b/test/chain/lifecycle.test.ts @@ -0,0 +1,246 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { createPublicClient, getAddress, http, namehash, parseAbi, type Address } from 'viem' +import { base, mainnet } from 'viem/chains' +import { ADDRESS, chainDeps, giveName, KEYS, placeContracts, REGISTRY, RPC, sendToken, TestWallet } from './harness.js' +import { loadConfig, type EthagentIdentity } from '../../src/storage/config.js' +import { continuityVaultRef } from '../../src/identity/continuity/storage/paths.js' +import { runCreateCommand, defaultSeams as createSeams } from '../../src/cli/onchain/create.js' +import { runProfileCommand, defaultSeams as profileSeams } from '../../src/cli/onchain/profile.js' +import { runEnsCommand, defaultSeams as ensSeams } from '../../src/cli/onchain/ens.js' +import { runCustodyCommand, defaultSeams as custodyReadSeams } from '../../src/cli/onchain/custody.js' +import { defaultCustodyWriteSeams } from '../../src/cli/onchain/custodyWrite.js' +import { runRestoreCommand, defaultSeams as restoreSeams } from '../../src/cli/onchain/restore.js' +import { runCheckCommand } from '../../src/cli/onchain/check.js' +import { runTransferCommand, defaultSeams as transferSeams } from '../../src/cli/onchain/transfer.js' +import { runOperatorSave, defaultOperatorSaveDeps } from '../../src/cli/operatorSave.js' + +// One agent's whole life on the local chains, in order. Every step sends real +// transactions through the code the commands run, signed by the test wallet or the +// operator key, and is checked against what the chains then hold. + +const wallet = new TestWallet() +const walletSeams = { openSession: wallet.open, openExternal: () => {} } +const custodyWrite = { ...defaultCustodyWriteSeams, ...walletSeams } +const baseClient = createPublicClient({ chain: base, transport: http(RPC.base) }) +const mainnetClient = createPublicClient({ chain: mainnet, transport: http(RPC.mainnet) }) + +const REGISTRY_ABI = parseAbi([ + 'function ownerOf(uint256) view returns (address)', + 'function tokenURI(uint256) view returns (string)', +]) +const VAULT_ABI = parseAbi([ + 'function agentOwner(address registry, uint256 agentId) view returns (address)', + 'function metadataOperators(address registry, uint256 agentId, address operator) view returns (bool)', +]) +const ENS_ABI = parseAbi([ + 'function owner(bytes32) view returns (address)', + 'function resolver(bytes32) view returns (address)', + 'function text(bytes32, string) view returns (string)', +]) +const ENS_REGISTRY = getAddress('0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e') +const NAME = 'agent.example.eth' + +function ok(code: number, deps: ReturnType): void { + assert.equal(code, 0, deps.io.stdout() + deps.io.stderr()) +} + +async function identity(): Promise { + const config = await loadConfig() + assert.ok(config?.identity) + return config.identity +} + +async function tokenId(): Promise { + return BigInt((await identity()).agentId!) +} + +async function holder(): Promise
{ + return baseClient.readContract({ address: REGISTRY, abi: REGISTRY_ABI, functionName: 'ownerOf', args: [await tokenId()] }) +} + +async function onchainUri(): Promise { + return baseClient.readContract({ address: REGISTRY, abi: REGISTRY_ABI, functionName: 'tokenURI', args: [await tokenId()] }) +} + +async function captureStdout(fn: () => Promise): Promise<{ result: T; out: string }> { + const original = process.stdout.write + let out = '' + process.stdout.write = ((chunk: unknown) => { out += String(chunk); return true }) as typeof process.stdout.write + try { + return { result: await fn(), out } + } finally { + process.stdout.write = original + } +} + +async function editMemory(line: string): Promise { + const file = continuityVaultRef(await identity()).memoryPath + await fs.appendFile(file, `\n- ${line}\n`) +} + +test.before(async () => { + await placeContracts() + await giveName('example.eth', ADDRESS.owner) +}) + +test('the suite cannot reach a public RPC or gateway', async () => { + await assert.rejects(fetch('https://mainnet.base.org'), /refuses mainnet.base.org/) + await assert.rejects(fetch('https://ethereum.publicnode.com'), /refuses/) +}) + +test('create --yes mints the agent and pins its first snapshot', async () => { + const deps = chainDeps() + ok(await runCreateCommand(['--name', 'chain agent', '--network', 'base', '--yes', '--json'], deps, { ...createSeams, ...walletSeams, custody: custodyWrite }), deps) + const out = deps.io.json() as Record + assert.equal(out.owner, ADDRESS.owner) + assert.equal(await holder(), ADDRESS.owner) + assert.equal(await onchainUri(), (await identity()).agentUri) +}) + +test('profile --yes publishes the new name and description', async () => { + const before = await onchainUri() + const deps = chainDeps() + ok(await runProfileCommand(['--name', 'chain agent two', '--description', 'runs on the test chain', '--yes', '--json'], deps, { ...profileSeams, ...walletSeams }), deps) + const after = await identity() + assert.equal((after.state as Record).name, 'chain agent two') + assert.notEqual(await onchainUri(), before) + assert.equal(await onchainUri(), after.agentUri) +}) + +test('ens --yes creates the subname, writes the agent records, and publishes it', async () => { + const deps = chainDeps() + ok(await runEnsCommand([NAME, '--yes', '--json'], deps, { ...ensSeams, ...walletSeams }), deps) + assert.equal((await identity()).state?.ensName, NAME) + const node = namehash(NAME) + assert.equal(await mainnetClient.readContract({ address: ENS_REGISTRY, abi: ENS_ABI, functionName: 'owner', args: [node] }), ADDRESS.owner) + const read = chainDeps() + ok(await runEnsCommand(['--json'], read, ensSeams), read) + assert.equal((read.io.json().link as Record).ok, true) +}) + +test('ens --set --yes writes a text record in one transaction', async () => { + const deps = chainDeps() + ok(await runEnsCommand(['--set', 'url=https://agent.example', '--yes', '--json'], deps, { ...ensSeams, ...walletSeams }), deps) + const node = namehash(NAME) + const resolver = await mainnetClient.readContract({ address: ENS_REGISTRY, abi: ENS_ABI, functionName: 'resolver', args: [node] }) + assert.equal(await mainnetClient.readContract({ address: resolver, abi: ENS_ABI, functionName: 'text', args: [node, 'url'] }), 'https://agent.example') +}) + +test('custody --advanced --yes deploys a Vault, deposits the token, and saves', async () => { + const deps = chainDeps() + ok(await runCustodyCommand(['--advanced', '--yes', '--json'], deps, custodyReadSeams, custodyWrite), deps) + const state = (await identity()).state as Record + assert.equal(state.custodyMode, 'advanced') + const vault = getAddress(state.operatorVaultAddress!) + assert.equal(await holder(), vault) + assert.equal(await baseClient.readContract({ address: vault, abi: VAULT_ABI, functionName: 'agentOwner', args: [REGISTRY, await tokenId()] }), ADDRESS.owner) + const verify = chainDeps() + ok(await runCustodyCommand(['--verify', '--json'], verify, custodyReadSeams), verify) +}) + +test('custody --add-operator --operator --yes approves the operator key in the Vault', async () => { + const deps = chainDeps('operator') + ok(await runCustodyCommand(['--add-operator', '--operator', '--yes', '--json'], deps, custodyReadSeams, custodyWrite), deps) + const vault = getAddress(((await identity()).state as Record).operatorVaultAddress!) + assert.equal(await baseClient.readContract({ address: vault, abi: VAULT_ABI, functionName: 'metadataOperators', args: [REGISTRY, await tokenId(), ADDRESS.operator] }), true) +}) + +test('save --operator publishes through the Vault with no wallet, then skips when nothing changed', async () => { + await editMemory('saved by the operator key on the test chain') + const deps = { ...defaultOperatorSaveDeps, readOperatorKey: () => ({ ok: true as const, key: KEYS.operator, address: ADDRESS.operator }) } + const opened = wallet.opened + const first = await captureStdout(() => runOperatorSave(['--json'], deps)) + assert.equal(first.result, 0, first.out) + const published = JSON.parse(first.out.trim()) as Record + assert.equal(published.published, true) + assert.equal(published.verification, 'verified') + assert.equal(await onchainUri(), published.agentUri) + assert.equal(wallet.opened, opened, 'the operator save never opens a wallet') + const again = await captureStdout(() => runOperatorSave(['--json'], deps)) + assert.equal(JSON.parse(again.out.trim()).skipped, true) +}) + +test('check finds nothing to fix and reads the real onchain pointer', async () => { + const deps = chainDeps() + ok(await runCheckCommand(['--json'], deps), deps) + const out = deps.io.json() as Record + assert.deepEqual(out.attention, []) + assert.equal(out.chain.agentUri, 'in-sync') + assert.equal(out.chain.custody, 'advanced') +}) + +test('restore --owner lists the agent while the Vault holds it', async () => { + const deps = chainDeps() + ok(await runRestoreCommand(['--owner', ADDRESS.owner, '--network', 'base', '--json'], deps, restoreSeams), deps) + const agents = deps.io.json().agents as Array> + assert.deepEqual(agents.map(agent => agent.agentId), [String(await tokenId())]) +}) + +test('restore --operator --yes rebuilds the vault on a fresh machine, byte for byte', async () => { + const original = await identity() + const memory = await fs.readFile(continuityVaultRef(original).memoryPath, 'utf8') + const home = process.env.HOME! + const fresh = await fs.mkdtemp(path.join(os.tmpdir(), 'ethagent-chain-fresh-')) + process.env.HOME = fresh + try { + const deps = chainDeps('operator') + ok(await runRestoreCommand([String(await tokenIdFrom(original)), '--network', 'base', '--operator', '--yes', '--json'], deps, restoreSeams), deps) + const restored = await identity() + assert.equal(restored.agentId, original.agentId) + assert.equal(await fs.readFile(continuityVaultRef(restored).memoryPath, 'utf8'), memory) + } finally { + process.env.HOME = home + await fs.rm(fresh, { recursive: true, force: true }) + } +}) + +function tokenIdFrom(id: EthagentIdentity): bigint { + return BigInt(id.agentId!) +} + +test('ens --delete --yes clears the records, removes the subname, and publishes the agent without it', async () => { + const deps = chainDeps() + ok(await runEnsCommand(['--delete', '--yes', '--json'], deps, { ...ensSeams, ...walletSeams }), deps) + assert.equal(await mainnetClient.readContract({ address: ENS_REGISTRY, abi: ENS_ABI, functionName: 'owner', args: [namehash(NAME)] }), '0x0000000000000000000000000000000000000000') + assert.equal((await identity()).state?.ensName ?? '', '') +}) + +test('custody --simple --yes revokes the operator, withdraws the token, and saves Simple', async () => { + const vault = getAddress(((await identity()).state as Record).operatorVaultAddress!) + const deps = chainDeps() + ok(await runCustodyCommand(['--simple', '--yes', '--json'], deps, custodyReadSeams, custodyWrite), deps) + assert.equal(await holder(), ADDRESS.owner) + assert.equal(((await identity()).state as Record).custodyMode, 'simple') + assert.equal(await baseClient.readContract({ address: vault, abi: VAULT_ABI, functionName: 'agentOwner', args: [REGISTRY, await tokenId()] }), '0x0000000000000000000000000000000000000000') +}) + +test('transfer --yes re-encrypts the agent for the receiver, who restores it after the token moves', async () => { + const deps = chainDeps() + wallet.use('owner') + ok(await runTransferCommand([ADDRESS.receiver, '--yes', '--json'], deps, { ...transferSeams, ...walletSeams }), deps) + assert.equal(await holder(), ADDRESS.owner, 'ethagent never moves the token itself') + assert.ok(wallet.prompts.some(prompt => prompt.endsWith(':receiver')), 'the receiver signed in the same wallet session') + + const original = await identity() + const memory = await fs.readFile(continuityVaultRef(original).memoryPath, 'utf8') + await sendToken('owner', ADDRESS.receiver, tokenIdFrom(original)) + const home = process.env.HOME! + const fresh = await fs.mkdtemp(path.join(os.tmpdir(), 'ethagent-chain-receiver-')) + process.env.HOME = fresh + try { + wallet.use('receiver') + const restore = chainDeps() + ok(await runRestoreCommand([String(tokenIdFrom(original)), '--network', 'base', '--yes', '--json'], restore, { ...restoreSeams, ...walletSeams }), restore) + const restored = await identity() + assert.equal(getAddress(restored.ownerAddress ?? restored.address), ADDRESS.receiver) + assert.equal(await fs.readFile(continuityVaultRef(restored).memoryPath, 'utf8'), memory) + } finally { + process.env.HOME = home + wallet.use('owner') + await fs.rm(fresh, { recursive: true, force: true }) + } +}) diff --git a/test/cli/bootstrapAutosync.test.ts b/test/cli/bootstrapAutosync.test.ts index 1a30d20..86ff4ab 100644 --- a/test/cli/bootstrapAutosync.test.ts +++ b/test/cli/bootstrapAutosync.test.ts @@ -2,27 +2,37 @@ import test from 'node:test' import assert from 'node:assert/strict' import fs from 'node:fs/promises' import path from 'node:path' +import { spawn } from 'node:child_process' +import { once } from 'node:events' import { injectManagedBlock, parseManagedContext, renderManagedBlock } from '../../src/cli/syncAdapters/managedBlock.js' import { claudeCodeAdapter, mergeClaudeHooks, removeClaudeHooks } from '../../src/cli/syncAdapters/claude-code.js' import { addGenericTarget, genericAdapter, readGenericTargets } from '../../src/cli/syncAdapters/generic.js' import { makeInstructionFileAdapter } from '../../src/cli/syncAdapters/instructionFileAdapter.js' import { + claimDaemonPid, clearDaemonPid, daemonDisabled, daemonStatus, ensureDaemon, isPaused, + isPidAlive, + isWatcherCommandLine, pauseSync, readDaemonPid, + readProcessCommandLine, + releaseDaemonPid, resumeSync, writeDaemonPid, daemonPidPath, stopDaemon, touchDaemonPid, + type CommandLineReader, } from '../../src/cli/daemon.js' import { withHome } from '../support/home.js' const context = { soul: '# SOUL.md\n\nsoul body\n', memory: '# MEMORY.md\n\nmemory body\n' } +const watcherCommand: CommandLineReader = () => 'node --import tsx/esm /opt/ethagent/src/cli/main.tsx watch --daemon' +const otherCommand: CommandLineReader = () => 'node /srv/app/server.js' test('injectManagedBlock is idempotent: an identical second write does not touch the file', async () => { await withHome(async home => { @@ -243,22 +253,144 @@ test('daemon is single-instance: ensureDaemon is a no-op while a live pid file e test('a pid file that stopped beating is not a running daemon, and its pid is never signalled', async () => { await withHome(async () => { // The test runner's own pid stands in for a pid the system handed to another process. + await writeQuietPid(process.pid) + assert.equal(daemonStatus(otherCommand).running, false) + assert.equal(readDaemonPid(), process.pid, 'reading the status changes nothing') + assert.equal(stopDaemon(otherCommand), false, 'a quiet pid that is not a watcher is not ours to signal') + await writeQuietPid(process.pid) + assert.equal(touchDaemonPid(), true) + assert.equal(daemonStatus(otherCommand).running, true, 'a heartbeat makes it current again') + clearDaemonPid() + }) +}) + +test('a watcher that never beats is still the daemon, and stopDaemon stops it', async () => { + await withHome(async () => { + await withStandIn(async (pid, exited) => { + // A watcher from before the heartbeat: alive, quiet, and a watcher by its command line. + await writeQuietPid(pid) + assert.deepEqual(daemonStatus(watcherCommand), { running: true, pid }) + assert.equal(stopDaemon(watcherCommand), true) + await exited + assert.equal(readDaemonPid(), null) + }) + }) +}) + +test('a starting watcher takes over a quiet one, once that one is gone', async () => { + await withHome(async () => { + await withStandIn(async pid => { + await writeQuietPid(pid) + assert.equal(await claimDaemonPid(watcherCommand), true) + assert.equal(isPidAlive(pid), false, 'the old watcher was stopped before the file was taken') + assert.equal(readDaemonPid(), process.pid) + clearDaemonPid() + }) + }) +}) + +test('a starting watcher claims over a quiet pid that is not a watcher, without signalling it', async () => { + await withHome(async () => { + await withStandIn(async pid => { + await writeQuietPid(pid) + assert.equal(await claimDaemonPid(otherCommand), true) + assert.equal(readDaemonPid(), process.pid) + assert.equal(isPidAlive(pid), true, 'the other process is left alone') + clearDaemonPid() + }) + }) +}) + +test('a starting watcher leaves a beating one in place', async () => { + await withHome(async () => { + await withStandIn(async pid => { + await writePid(pid) + assert.equal(await claimDaemonPid(watcherCommand), false) + assert.equal(readDaemonPid(), pid) + assert.equal(isPidAlive(pid), true) + assert.equal(ensureDaemon(), false) + }) + }) +}) + +test('the heartbeat tells a watcher to stop when sync is paused or another watcher took over', async () => { + await withHome(async () => { writeDaemonPid() - const old = new Date(Date.now() - 10 * 60_000) - await fs.utimes(daemonPidPath(), old, old) - assert.equal(daemonStatus().running, false) - assert.equal(readDaemonPid(), null, 'the stale file is cleared') + assert.equal(touchDaemonPid(), true) + clearDaemonPid() + assert.equal(touchDaemonPid(), true, 'a removed file is taken back') + assert.equal(readDaemonPid(), process.pid) + + clearDaemonPid() + pauseSync() writeDaemonPid() - await fs.utimes(daemonPidPath(), old, old) - assert.equal(stopDaemon(), false, 'a stale pid is not ours to signal') + assert.equal(touchDaemonPid(), false, 'paused while running') + resumeSync() + + await withStandIn(async pid => { + await writePid(pid) + assert.equal(touchDaemonPid(), false, 'another live process holds the file') + releaseDaemonPid() + assert.equal(readDaemonPid(), pid, "a successor's file is left in place") + }) writeDaemonPid() - await fs.utimes(daemonPidPath(), old, old) - touchDaemonPid() - assert.equal(daemonStatus().running, true, 'a heartbeat makes it current again') - clearDaemonPid() + releaseDaemonPid() + assert.equal(readDaemonPid(), null) }) }) +test('isWatcherCommandLine recognizes a watcher of any version on every platform', () => { + for (const line of [ + 'node --import file:///home/a/.npm/_npx/1f/node_modules/tsx/dist/esm/index.mjs /home/a/.npm/_npx/1f/node_modules/ethagent/src/cli/main.tsx watch --daemon', + 'node --import file:///C:/Users/a/dev/ethagent/node_modules/tsx/dist/esm/index.mjs C:\\Users\\a\\dev\\ethagent\\src\\cli\\main.tsx watch --daemon', + '"C:\\Program Files\\nodejs\\node.exe" --import file:///C:/x/tsx/dist/esm/index.mjs "C:\\Users\\a b\\ethagent\\src\\cli\\main.tsx" watch --daemon', + 'node --import tsx/esm /opt/ethagent/src/cli/main.tsx watch', + ]) { + assert.equal(isWatcherCommandLine(line), true, line) + } + for (const line of [ + null, + '', + 'node /srv/app/server.js', + 'node --import tsx/esm /opt/ethagent/src/cli/main.tsx', + 'node --import tsx/esm /opt/ethagent/src/cli/main.tsx status --json', + 'node /opt/other/watch --daemon', + ]) { + assert.equal(isWatcherCommandLine(line), false, String(line)) + } +}) + +test('readProcessCommandLine reads how a live process was started', () => { + const line = readProcessCommandLine(process.pid) + assert.ok(line?.includes('run.js'), `got: ${line}`) + assert.equal(isWatcherCommandLine(line), false) +}) + +async function writePid(pid: number): Promise { + await fs.mkdir(path.dirname(daemonPidPath()), { recursive: true }) + await fs.writeFile(daemonPidPath(), `${pid}\n`, 'utf8') +} + +// A pid file whose last beat was ten minutes ago. +async function writeQuietPid(pid: number): Promise { + await writePid(pid) + const old = new Date(Date.now() - 10 * 60_000) + await fs.utimes(daemonPidPath(), old, old) +} + +// A live process for a pid file to point at. It is killed when the test is done with it. +async function withStandIn(fn: (pid: number, exited: Promise) => Promise): Promise { + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }) + const exited = new Promise(resolve => { child.once('exit', () => resolve()) }) + await once(child, 'spawn') + try { + await fn(child.pid!, exited) + } finally { + child.kill() + await exited + } +} + async function withEnv(key: string, value: string, fn: () => Promise): Promise { const prev = process.env[key] process.env[key] = value diff --git a/test/cli/setupCommands.test.ts b/test/cli/setupCommands.test.ts index e34996e..5993b6c 100644 --- a/test/cli/setupCommands.test.ts +++ b/test/cli/setupCommands.test.ts @@ -146,13 +146,15 @@ function transferSeams(record: { signed?: boolean; saved?: EthagentConfig } = {} return getAddress(handle) }, assertNotInVault: async () => {}, - sign: async step => { + sign: async (step, _callbacks, opts) => { + assert.ok(opts?.session, 'both signatures and the publish go through the command\'s wallet tab') record.signed = true return { identity: { ...step.identity, backup: { cid: 'bafytransfer' } as never }, snapshotCid: 'bafytransfer', txHash: '0xpublish' } }, resolveJwt: async () => 'jwt', vaultStatus: async () => ({ ready: true }) as never, pullHarness: async () => [], + openSession: async () => ({ close: async () => {} }) as never, openExternal: () => {}, saveConfig: async config => { record.saved = config }, } diff --git a/test/run-chain.mjs b/test/run-chain.mjs new file mode 100644 index 0000000..9e4fc4c --- /dev/null +++ b/test/run-chain.mjs @@ -0,0 +1,178 @@ +// Runs the chain suite: every command that sends, against two local Anvil chains (an +// Ethereum Mainnet stand-in for ENS and a Base stand-in for the agent registry), with the +// stand-in contracts from contracts/test/chain placed at the real addresses and a local +// IPFS endpoint served from this process. Needs Foundry (anvil and forge) on PATH. +import fs from 'node:fs/promises' +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn, spawnSync } from 'node:child_process' +import { CID } from 'multiformats/cid' +import { sha256 } from 'multiformats/hashes/sha2' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const outDir = path.join(root, '.test-dist') +const children = [] + +function fail(message) { + console.error(`chain suite: ${message}`) + process.exitCode = 1 +} + +function which(command) { + return spawnSync(command, ['--version'], { stdio: 'ignore' }).status === 0 +} + +async function rpc(url, method, params = []) { + const response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }) + const body = await response.json() + if (body.error) throw new Error(`${method}: ${body.error.message}`) + return body.result +} + +async function startAnvil(chainId, port, extra = []) { + const child = spawn('anvil', ['--port', String(port), '--chain-id', String(chainId), '--silent', ...extra], { stdio: 'ignore' }) + children.push(child) + const url = `http://127.0.0.1:${port}` + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + if (Number(await rpc(url, 'eth_chainId')) === chainId) return url + } catch {} + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error(`anvil for chain ${chainId} did not start on port ${port}`) +} + +// A minimal IPFS API and gateway: add returns a raw-codec CIDv1 of the bytes, and both +// cat and /ipfs/ serve them back, so the reader's hash check passes. +function startIpfs() { + const blobs = new Map() + const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url ?? '/', 'http://127.0.0.1') + const chunks = [] + for await (const chunk of req) chunks.push(chunk) + const body = Buffer.concat(chunks) + if (url.pathname === '/api/v0/add') { + const form = await new Request('http://127.0.0.1/', { method: 'POST', headers: req.headers, body }).formData() + const file = [...form.values()].find(value => typeof value !== 'string') + const bytes = new Uint8Array(await file.arrayBuffer()) + const cid = CID.createV1(0x55, await sha256.digest(bytes)).toString() + blobs.set(cid, bytes) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ Hash: cid, Name: file.name, Size: String(bytes.length) })) + return + } + const cid = url.pathname === '/api/v0/cat' ? url.searchParams.get('arg') : url.pathname.replace(/^\/ipfs\//, '').split('/')[0] + const bytes = cid ? blobs.get(cid) : undefined + if (!bytes) { + res.writeHead(404) + res.end('not found') + return + } + res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': String(bytes.length) }) + res.end(Buffer.from(bytes)) + } catch (err) { + res.writeHead(500) + res.end(String(err)) + } + }) + return new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve({ server, url: `http://127.0.0.1:${server.address().port}` }))) +} + +async function main() { + if (!which('anvil') || !which('forge')) { + fail('anvil and forge are needed; install Foundry (https://getfoundry.sh).') + return + } + const forge = spawnSync('forge', ['build', '--offline'], { cwd: path.join(root, 'contracts'), stdio: 'inherit' }) + if (forge.status !== 0) { + const online = spawnSync('forge', ['build'], { cwd: path.join(root, 'contracts'), stdio: 'inherit' }) + if (online.status !== 0) return fail('forge build failed') + } + await fs.rm(outDir, { recursive: true, force: true }) + const tsc = spawnSync(process.execPath, [path.join(root, 'node_modules', 'typescript', 'bin', 'tsc'), '--outDir', outDir, '--noEmit', 'false'], { cwd: root, stdio: 'inherit' }) + if (tsc.status !== 0) return fail('typescript did not compile') + + // Owner lookups scan registry logs from the registry's real start block on Base, so the + // Base chain starts just past it instead of at block 0. + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'ethagent-chain-')) + const genesis = path.join(home, 'base-genesis.json') + // Every fork through Cancun from block 0 and a base fee, spelled out: an Anvil that + // reads a bare genesis as pre-London refuses EIP-1559 transactions. + await fs.writeFile(genesis, JSON.stringify({ + config: { + chainId: 8453, + homesteadBlock: 0, + eip150Block: 0, + eip155Block: 0, + eip158Block: 0, + byzantiumBlock: 0, + constantinopleBlock: 0, + petersburgBlock: 0, + istanbulBlock: 0, + berlinBlock: 0, + londonBlock: 0, + mergeNetsplitBlock: 0, + terminalTotalDifficulty: 0, + terminalTotalDifficultyPassed: true, + shanghaiTime: 0, + cancunTime: 0, + }, + baseFeePerGas: '0x3b9aca00', + nonce: '0x0', + timestamp: `0x${Math.floor(Date.now() / 1000).toString(16)}`, + extraData: '0x', + gasLimit: '0x1c9c380', + difficulty: '0x0', + number: `0x${(41_663_800).toString(16)}`, + alloc: {}, + })) + const [mainnet, base, ipfs] = await Promise.all([startAnvil(1, 18545), startAnvil(8453, 18546, ['--init', genesis]), startIpfs()]) + try { + const env = { + ...process.env, + HOME: home, + USERPROFILE: home, + ETHAGENT_CHAIN_MAINNET_RPC: mainnet, + ETHAGENT_CHAIN_BASE_RPC: base, + ETHAGENT_CHAIN_ARTIFACTS: path.join(root, 'contracts', 'out'), + ETHAGENT_RPC_URL: base, + ETHAGENT_ENS_RPC_URL: mainnet, + ETHAGENT_IPFS_API_URL: ipfs.url, + ETHAGENT_IPFS_GATEWAYS: ipfs.url, + ETHAGENT_IPFS_ROUTERS: '', + ETHAGENT_HOSTS_FILE: '', + ETHAGENT_NO_DAEMON: '1', + } + delete env.PINATA_JWT + delete env.PINATA_GATEWAY_URL + delete env.ETHAGENT_OPERATOR_KEY + const dir = path.join(outDir, 'test', 'chain') + const files = (await fs.readdir(dir)).filter(name => name.endsWith('.test.js')).sort().map(name => path.join(dir, name)) + // Not spawnSync: the IPFS endpoint lives in this process and must keep answering. + const status = await new Promise(resolve => { + const run = spawn(process.execPath, ['--test', '--test-concurrency=1', '--test-timeout=120000', ...files], { cwd: root, stdio: 'inherit', env }) + children.push(run) + run.on('exit', code => resolve(code ?? 1)) + }) + if (status !== 0) process.exitCode = status + } finally { + ipfs.server.close() + await fs.rm(home, { recursive: true, force: true }) + await fs.rm(outDir, { recursive: true, force: true }) + } +} + +try { + await main() +} catch (err) { + fail(err instanceof Error ? err.stack ?? err.message : String(err)) +} finally { + for (const child of children) child.kill('SIGTERM') +}