@seedhape/local-wallet 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-identity.d.ts +45 -0
- package/dist/agent-identity.d.ts.map +1 -0
- package/dist/agent-identity.js +300 -0
- package/dist/agent-identity.js.map +1 -0
- package/dist/hosted.d.ts +82 -0
- package/dist/hosted.d.ts.map +1 -0
- package/dist/hosted.js +180 -0
- package/dist/hosted.js.map +1 -0
- package/dist/identity-backup.d.ts +12 -0
- package/dist/identity-backup.d.ts.map +1 -0
- package/dist/identity-backup.js +130 -0
- package/dist/identity-backup.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +429 -0
- package/dist/index.js.map +1 -0
- package/dist/keychain.d.ts +30 -0
- package/dist/keychain.d.ts.map +1 -0
- package/dist/keychain.js +99 -0
- package/dist/keychain.js.map +1 -0
- package/dist/seedhape-identity.d.ts +87 -0
- package/dist/seedhape-identity.d.ts.map +1 -0
- package/dist/seedhape-identity.js +110 -0
- package/dist/seedhape-identity.js.map +1 -0
- package/dist/server.d.ts +43 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +160 -0
- package/dist/server.js.map +1 -0
- package/dist/web-bot-auth.d.ts +37 -0
- package/dist/web-bot-auth.d.ts.map +1 -0
- package/dist/web-bot-auth.js +87 -0
- package/dist/web-bot-auth.js.map +1 -0
- package/package.json +20 -0
- package/src/agent-identity.ts +293 -0
- package/src/hosted.ts +174 -0
- package/src/identity-backup.ts +134 -0
- package/src/index.ts +374 -0
- package/src/keychain.ts +114 -0
- package/src/seedhape-identity.ts +113 -0
- package/src/server.ts +187 -0
- package/src/web-bot-auth.ts +94 -0
- package/test/identity-backup.test.mjs +90 -0
- package/test/identity-fork.test.mjs +128 -0
- package/test/pay-flow.test.mjs +139 -0
- package/test/setup-flow.test.mjs +47 -0
- package/test/web-bot-auth.test.mjs +134 -0
- package/tsconfig.json +15 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Seedhape local wallet (CLI: `seedhape`).
|
|
4
|
+
*
|
|
5
|
+
* Keeps an agent's payment key in the operating system keychain and signs
|
|
6
|
+
* payments on this computer. Spending rules, budget, pause, and history live in
|
|
7
|
+
* the user's Seedhape account; every payment is checked there first.
|
|
8
|
+
*
|
|
9
|
+
* seedhape setup [--client codex|claude] [--agent <id>] one-step setup
|
|
10
|
+
* seedhape login [--token] sign in to Seedhape
|
|
11
|
+
* seedhape logout | seedhape whoami
|
|
12
|
+
* seedhape wallet:init|wallet:address|wallet:delete <agent-id>
|
|
13
|
+
* seedhape webbotauth:init|status|rotate|revoke [--agent <id>] agent identity key
|
|
14
|
+
* seedhape identity:* ERC-8004 identity tools
|
|
15
|
+
* seedhape run the local MCP server (stdio)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { homedir, hostname } from "node:os";
|
|
19
|
+
import { join, resolve } from "node:path";
|
|
20
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
21
|
+
import { emitKeypressEvents } from "node:readline";
|
|
22
|
+
import { createServer as createHttpServer } from "node:http";
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
25
|
+
import { LocalSessionKeySigner, USDC, type Hex } from "@seedhape/core";
|
|
26
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
27
|
+
import { createServer } from "./server.js";
|
|
28
|
+
import { callHostedTool, deleteToken, loadToken, loginWithBrowser, saveToken, seedhapeUrl, whoAmI } from "./hosted.js";
|
|
29
|
+
import { deleteWebBotAuthKey, loadWebBotAuthKey, saveWebBotAuthKey } from "./keychain.js";
|
|
30
|
+
import { loadSeedhapeHostedAgent, setupSeedhapeHostedAgent } from "./agent-identity.js";
|
|
31
|
+
import { defaultSignatureAgent, generateWebBotAuthKey, registerWebBotAuthKey, revokeWebBotAuthKey } from "./web-bot-auth.js";
|
|
32
|
+
import { createLocalWallet, deleteLocalWallet, exportIdentityControllerPrivateKey, identityControllerPrivateKey, importIdentityController, localWalletAddress, localWalletPrivateKey } from "./keychain.js";
|
|
33
|
+
import { createSelfCustodiedBackupPayload, createSelfCustodiedMetadataHandler, loadSelfCustodiedAgent, restoreSelfCustodiedBackupPayload, setupSelfCustodiedAgent } from "./agent-identity.js";
|
|
34
|
+
import { decryptIdentityBackup, encryptIdentityBackup } from "./identity-backup.js";
|
|
35
|
+
|
|
36
|
+
const AGENT_ID = /^[A-Za-z0-9._-]{1,128}$/;
|
|
37
|
+
const defaultAgentId = () => (hostname().replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 60) || "local") + "-agent";
|
|
38
|
+
const flag = (args: string[], name: string) => { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : undefined; };
|
|
39
|
+
const say = (line: string) => console.error(line);
|
|
40
|
+
|
|
41
|
+
function promptSecret(label: string): Promise<string> {
|
|
42
|
+
const input = process.stdin;
|
|
43
|
+
if (!input.isTTY || typeof input.setRawMode !== "function") throw new Error("Identity backup/restore requires an interactive terminal so the passphrase is not echoed.");
|
|
44
|
+
return new Promise((resolvePrompt, rejectPrompt) => {
|
|
45
|
+
emitKeypressEvents(input);
|
|
46
|
+
input.setRawMode(true);
|
|
47
|
+
input.resume();
|
|
48
|
+
let value = "";
|
|
49
|
+
process.stderr.write(`${label} (hidden): `);
|
|
50
|
+
const cleanup = () => {
|
|
51
|
+
input.off("keypress", onKeypress);
|
|
52
|
+
input.setRawMode(false);
|
|
53
|
+
process.stderr.write("\n");
|
|
54
|
+
};
|
|
55
|
+
const onKeypress = (character: string, key: { name?: string; ctrl?: boolean; meta?: boolean }) => {
|
|
56
|
+
if (key.ctrl && key.name === "c") {
|
|
57
|
+
cleanup();
|
|
58
|
+
rejectPrompt(new Error("Cancelled."));
|
|
59
|
+
} else if (key.name === "return" || key.name === "enter") {
|
|
60
|
+
cleanup();
|
|
61
|
+
resolvePrompt(value);
|
|
62
|
+
} else if (key.name === "backspace") {
|
|
63
|
+
if (value.length) {
|
|
64
|
+
value = value.slice(0, -1);
|
|
65
|
+
process.stderr.write("\b \b");
|
|
66
|
+
}
|
|
67
|
+
} else if (character && !key.ctrl && !key.meta && character >= " ") {
|
|
68
|
+
value += character;
|
|
69
|
+
process.stderr.write("*");
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
input.on("keypress", onKeypress);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async function login(args: string[]) {
|
|
78
|
+
if (args.includes("--token")) {
|
|
79
|
+
const token = (await promptSecret(`Paste a connection token from ${seedhapeUrl()}/connect-mcp`)).trim();
|
|
80
|
+
if (!token) throw new Error("No token entered.");
|
|
81
|
+
await saveToken(token);
|
|
82
|
+
} else {
|
|
83
|
+
await loginWithBrowser(say);
|
|
84
|
+
}
|
|
85
|
+
const account = await whoAmI();
|
|
86
|
+
if (!account.ok) throw new Error(`The saved sign-in did not work: ${account.detail}`);
|
|
87
|
+
say(`✓ Signed in to ${seedhapeUrl()}. Token saved in your keychain.`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Create (or reuse) the keychain wallet and register its public address with the user's Seedhape account. */
|
|
91
|
+
async function registerWallet(agentId: string, chain: string) {
|
|
92
|
+
const address = await createLocalWallet(agentId);
|
|
93
|
+
const key = await localWalletPrivateKey(agentId);
|
|
94
|
+
if (!key) throw new Error("The wallet could not be read back from the keychain.");
|
|
95
|
+
const existing = await callHostedTool("external_agent_wallet_status", {});
|
|
96
|
+
if (existing.toLowerCase().includes(address.toLowerCase())) return { address, alreadyRegistered: true };
|
|
97
|
+
const challenge = JSON.parse(await callHostedTool("external_agent_wallet_challenge", { agent_id: agentId, address, chain })) as { message?: string; error?: string };
|
|
98
|
+
if (!challenge.message) throw new Error(challenge.error ?? "Seedhape did not return a registration challenge.");
|
|
99
|
+
const signature = await privateKeyToAccount(key).signMessage({ message: challenge.message });
|
|
100
|
+
const registered = JSON.parse(await callHostedTool("register_external_agent_wallet", { agent_id: agentId, address, chain, message: challenge.message, signature })) as { registered?: boolean; error?: string };
|
|
101
|
+
if (!registered.registered) throw new Error(registered.error ?? "Seedhape did not register the wallet.");
|
|
102
|
+
return { address, alreadyRegistered: false };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Add both the hosted Seedhape MCP and this local wallet to Codex or Claude Code. */
|
|
106
|
+
function addToClient(client: "codex" | "claude", agentId: string, network: string) {
|
|
107
|
+
const mcpUrl = `${seedhapeUrl()}/mcp`;
|
|
108
|
+
const env = [`SEEDHAPE_LOCAL_AGENT=${agentId}`, `SEEDHAPE_NETWORK=${network}`, `SEEDHAPE_URL=${seedhapeUrl()}`];
|
|
109
|
+
const commands: string[][] = client === "codex"
|
|
110
|
+
? [["codex", "mcp", "add", "Seedhape", "--url", mcpUrl], ["codex", "mcp", "add", "seedhape-wallet", ...env.flatMap((value) => ["--env", value]), "--", "npx", "-y", "@seedhape/local-wallet"]]
|
|
111
|
+
: [["claude", "mcp", "add", "--transport", "http", "seedhape", mcpUrl], ["claude", "mcp", "add", "seedhape-wallet", ...env.flatMap((value) => ["-e", value]), "--", "npx", "-y", "@seedhape/local-wallet"]];
|
|
112
|
+
for (const command of commands) {
|
|
113
|
+
const run = spawnSync(command[0], command.slice(1), { stdio: "inherit" });
|
|
114
|
+
if (run.error || run.status !== 0) say(` Could not run it automatically. Run this yourself:\n ${command.join(" ")}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Create and register this agent's Web Bot Auth key, reusing an existing one. */
|
|
119
|
+
async function initWebBotAuth(agentId: string, signatureAgent?: string) {
|
|
120
|
+
const existing = await loadWebBotAuthKey(agentId);
|
|
121
|
+
if (existing && (!signatureAgent || new URL(signatureAgent).toString() === existing.signatureAgent)) {
|
|
122
|
+
// Re-registering a key this account already owns is a no-op on the server.
|
|
123
|
+
await registerWebBotAuthKey(callHostedTool, agentId, existing);
|
|
124
|
+
return { key: existing, created: false };
|
|
125
|
+
}
|
|
126
|
+
const key = generateWebBotAuthKey(signatureAgent ?? defaultSignatureAgent(seedhapeUrl(), agentId));
|
|
127
|
+
await registerWebBotAuthKey(callHostedTool, agentId, key);
|
|
128
|
+
await saveWebBotAuthKey(agentId, key);
|
|
129
|
+
return { key, created: true };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Replace the key: register the new one, switch to it, then revoke the old one. */
|
|
133
|
+
async function rotateWebBotAuth(agentId: string) {
|
|
134
|
+
const old = await loadWebBotAuthKey(agentId);
|
|
135
|
+
if (!old) throw new Error(`No Web Bot Auth key exists for ${agentId}. Run \`seedhape webbotauth:init\` first.`);
|
|
136
|
+
const next = generateWebBotAuthKey(old.signatureAgent);
|
|
137
|
+
await registerWebBotAuthKey(callHostedTool, agentId, next, true);
|
|
138
|
+
await saveWebBotAuthKey(agentId, next);
|
|
139
|
+
await revokeWebBotAuthKey(callHostedTool, old);
|
|
140
|
+
return next;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function setup(args: string[]) {
|
|
144
|
+
const agentId = flag(args, "--agent") ?? defaultAgentId();
|
|
145
|
+
if (!AGENT_ID.test(agentId)) throw new Error("Agent ids may use letters, digits, '.', '_' and '-'.");
|
|
146
|
+
const network = process.env.SEEDHAPE_NETWORK ?? "base";
|
|
147
|
+
const asset = USDC[network];
|
|
148
|
+
if (!asset) throw new Error(`Unsupported network "${network}". Use one of: ${Object.keys(USDC).join(", ")}`);
|
|
149
|
+
say("1/4 Signing in to Seedhape…");
|
|
150
|
+
if ((await whoAmI()).ok) say(" Already signed in.");
|
|
151
|
+
else await login([]);
|
|
152
|
+
say(`2/4 Creating the wallet "${agentId}" in your keychain and registering its address…`);
|
|
153
|
+
const wallet = await registerWallet(agentId, asset.network);
|
|
154
|
+
say(` ${wallet.alreadyRegistered ? "Already registered" : "Registered"}: ${wallet.address} (the private key stays on this computer)`);
|
|
155
|
+
say("3/4 Creating this agent's identity so merchants can tell it's you…");
|
|
156
|
+
const dataDir = process.env.SEEDHAPE_DATA_DIR ?? join(homedir(), ".seedhape");
|
|
157
|
+
try {
|
|
158
|
+
// Preferred: an on-chain ERC-8004 identity that any merchant can verify. Seedhape hosts its metadata and covers gas.
|
|
159
|
+
const agent = await setupSeedhapeHostedAgent(dataDir, { name: agentId, description: "A self-custody Seedhape agent. Its keys stay on the owner's computer." }, say);
|
|
160
|
+
say(` On-chain identity ready: ${agent.identityRef}`);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
say(` On-chain identity not available (${error instanceof Error ? error.message : String(error)}).`);
|
|
163
|
+
try {
|
|
164
|
+
// Fallback: a Web Bot Auth key registered with Seedhape; works with Seedhape-hosted merchants.
|
|
165
|
+
const identity = await initWebBotAuth(agentId, flag(args, "--signature-agent"));
|
|
166
|
+
say(` Using a Seedhape-registered key instead (${identity.created ? "created" : "already set up"}). Run setup again later to get the on-chain identity.`);
|
|
167
|
+
} catch (fallbackError) {
|
|
168
|
+
// Payments don't depend on it, so setup carries on; the user can retry later.
|
|
169
|
+
say(` Skipped: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}\n Payments still work. Run setup again to retry.`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const client = flag(args, "--client");
|
|
173
|
+
if (client === "codex" || client === "claude") {
|
|
174
|
+
say(`4/4 Adding Seedhape to ${client === "codex" ? "Codex" : "Claude Code"}…`);
|
|
175
|
+
addToClient(client, agentId, network);
|
|
176
|
+
} else {
|
|
177
|
+
say("4/4 Skipped adding to an agent client (use --client codex or --client claude).");
|
|
178
|
+
}
|
|
179
|
+
say(`\nNext:\n • Set its spending limit: ${seedhapeUrl()}/limits/advanced?wallet=${encodeURIComponent(agentId)}\n • Send USDC on ${network} to ${wallet.address}\n • Then ask your agent to buy something.`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function finishFirstUseSetup(agentId: string, network: string, dataDir: string, log: (line: string) => void) {
|
|
183
|
+
const account = await whoAmI();
|
|
184
|
+
if (!account.ok) {
|
|
185
|
+
log("Opening your browser to connect your Seedhape account…");
|
|
186
|
+
await loginWithBrowser(log);
|
|
187
|
+
const signedIn = await whoAmI();
|
|
188
|
+
if (!signedIn.ok) throw new Error(`Sign-in did not finish: ${signedIn.detail}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
log("Creating your local wallet and connecting it to Seedhape…");
|
|
192
|
+
const wallet = await registerWallet(agentId, network);
|
|
193
|
+
|
|
194
|
+
let identity = await loadSeedhapeHostedAgent(dataDir, (error) => log(`Identity refresh: ${error instanceof Error ? error.message : String(error)}`)).catch(() => undefined);
|
|
195
|
+
let webBotAuth = await loadWebBotAuthKey(agentId).catch(() => null);
|
|
196
|
+
if (!identity && !webBotAuth) {
|
|
197
|
+
log("Setting up your agent identity…");
|
|
198
|
+
try {
|
|
199
|
+
const ready = await setupSeedhapeHostedAgent(dataDir, { name: agentId, description: "A self-custody Seedhape agent. Its keys stay on this computer." }, log);
|
|
200
|
+
identity = await loadSeedhapeHostedAgent(dataDir, (error) => log(`Identity refresh: ${error instanceof Error ? error.message : String(error)}`));
|
|
201
|
+
if (!identity) log(`Identity saved (${ready.identityRef}); it will be available after restarting the wallet.`);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
log(`On-chain identity is unavailable (${error instanceof Error ? error.message : String(error)}). Setting up a simpler identity instead…`);
|
|
204
|
+
try { webBotAuth = (await initWebBotAuth(agentId)).key; }
|
|
205
|
+
catch (fallbackError) { log(`Identity setup skipped: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}. Payments can still work.`); }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const key = await localWalletPrivateKey(agentId) as Hex | null;
|
|
209
|
+
if (!key) throw new Error("The local wallet could not be loaded from this computer's keychain.");
|
|
210
|
+
return { address: privateKeyToAccount(key).address, signer: new LocalSessionKeySigner(key), identity: identity ?? null, webBotAuth: webBotAuth ?? null };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function main() {
|
|
214
|
+
const args = process.argv.slice(2);
|
|
215
|
+
const [command, commandAgent, commandOption] = args;
|
|
216
|
+
const dataDir = process.env.SEEDHAPE_DATA_DIR ?? join(homedir(), ".seedhape");
|
|
217
|
+
if (command === "login") return login(args.slice(1));
|
|
218
|
+
if (command === "logout") { say((await deleteToken()) ? "Signed out." : "Not signed in."); return; }
|
|
219
|
+
if (command === "whoami") { const account = await whoAmI(); say(`${seedhapeUrl()}: ${account.detail}`); return; }
|
|
220
|
+
if (command === "setup") return setup(args.slice(1));
|
|
221
|
+
if (command?.startsWith("webbotauth:")) {
|
|
222
|
+
const agentId = flag(args, "--agent") ?? process.env.SEEDHAPE_LOCAL_AGENT ?? defaultAgentId();
|
|
223
|
+
if (command === "webbotauth:init") { const { key, created } = await initWebBotAuth(agentId, flag(args, "--signature-agent")); say(`${created ? "Created and registered" : "Already set up"}: ${key.signatureAgent} (key ${key.keyId})`); return; }
|
|
224
|
+
if (command === "webbotauth:rotate") { const key = await rotateWebBotAuth(agentId); say(`Rotated. New key ${key.keyId}; the old key is revoked.`); return; }
|
|
225
|
+
if (command === "webbotauth:revoke") {
|
|
226
|
+
const key = await loadWebBotAuthKey(agentId);
|
|
227
|
+
if (!key) { say("No Web Bot Auth key to revoke."); return; }
|
|
228
|
+
await revokeWebBotAuthKey(callHostedTool, key);
|
|
229
|
+
await deleteWebBotAuthKey(agentId);
|
|
230
|
+
say("Revoked and removed from your keychain.");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (command === "webbotauth:status") { const key = await loadWebBotAuthKey(agentId); say(key ? `Signing as ${key.signatureAgent}\nKey id: ${key.keyId}\nCreated: ${new Date(key.createdAt * 1000).toISOString()}` : "Not set up. Run `seedhape webbotauth:init`."); return; }
|
|
234
|
+
throw new Error(`Unknown command "${command}". Use webbotauth:init, webbotauth:status, webbotauth:rotate, or webbotauth:revoke.`);
|
|
235
|
+
}
|
|
236
|
+
if (command === "identity:setup") {
|
|
237
|
+
const agent = await setupSelfCustodiedAgent(dataDir);
|
|
238
|
+
console.log(`Self-custodied ERC-8004 identity ready: ${agent.identityRef}`);
|
|
239
|
+
console.log(`Controller: ${agent.controller}`);
|
|
240
|
+
console.log(`Web Bot Auth directory: ${agent.directoryUri}`);
|
|
241
|
+
console.log(`Network: ${agent.chainId === 8453 ? "Base" : "Base Sepolia"}`);
|
|
242
|
+
console.log("Create a complete encrypted backup with `seedhape identity:backup <file>` and keep its passphrase separate.");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (command === "identity:controller-import") {
|
|
246
|
+
const privateKey = await promptSecret("Existing ERC-8004 controller private key (0x-prefixed)");
|
|
247
|
+
const address = await importIdentityController(process.env.SEEDHAPE_AGENT_IDENTITY_CONTROLLER_ID ?? "local-primary", privateKey as Hex);
|
|
248
|
+
console.log(`Imported controller ${address} into the local OS keychain.`);
|
|
249
|
+
console.log("Next run `seedhape identity:import <identity-reference>` to verify ownership and finish local setup.");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (command === "identity:import") {
|
|
253
|
+
if (!commandAgent || commandOption !== "--replace-registration") throw new Error("Import replaces the existing onchain metadata URI. Save the existing metadata, then run: seedhape identity:import eip155:<8453|84532>:<registry-address>:<agent-id> --replace-registration");
|
|
254
|
+
const controller = await identityControllerPrivateKey(process.env.SEEDHAPE_AGENT_IDENTITY_CONTROLLER_ID ?? "local-primary");
|
|
255
|
+
if (!controller) throw new Error("Import the current ERC-8004 controller first with `seedhape identity:controller-import`.");
|
|
256
|
+
const agent = await setupSelfCustodiedAgent(dataDir, commandAgent, true);
|
|
257
|
+
console.log(`Existing self-custodied ERC-8004 identity ready: ${agent.identityRef}`);
|
|
258
|
+
console.log(`Controller: ${agent.controller}`);
|
|
259
|
+
console.log(`Web Bot Auth directory: ${agent.directoryUri}`);
|
|
260
|
+
console.log(`Network: ${agent.chainId === 8453 ? "Base" : "Base Sepolia"}`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (command === "identity:export") {
|
|
264
|
+
if (commandAgent !== "--confirm") throw new Error("This prints your identity controller private key. Re-run as `seedhape identity:export --confirm` only in a trusted local terminal.");
|
|
265
|
+
const key = await exportIdentityControllerPrivateKey(process.env.SEEDHAPE_AGENT_IDENTITY_CONTROLLER_ID ?? "local-primary");
|
|
266
|
+
if (!key) throw new Error("No local identity controller exists. Run `seedhape identity:setup` first.");
|
|
267
|
+
console.error("WARNING: anyone with this key controls the onchain agent identity. Store the next line offline; never paste it into chat or commit it.");
|
|
268
|
+
console.log(key);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (command === "identity:backup") {
|
|
272
|
+
if (!commandAgent) throw new Error("Usage: seedhape identity:backup <encrypted-backup-file>");
|
|
273
|
+
const first = await promptSecret("Choose a backup passphrase (12+ characters)");
|
|
274
|
+
const second = await promptSecret("Repeat backup passphrase");
|
|
275
|
+
if (first !== second) throw new Error("Passphrases do not match.");
|
|
276
|
+
const payload = await createSelfCustodiedBackupPayload(dataDir);
|
|
277
|
+
const encrypted = encryptIdentityBackup(payload, first);
|
|
278
|
+
const destination = resolve(commandAgent);
|
|
279
|
+
await writeFile(destination, `${encrypted}\n`, { flag: "wx", mode: 0o600 });
|
|
280
|
+
console.log(`Encrypted identity backup created at ${destination}. Keep the passphrase separate from the file.`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (command === "identity:restore") {
|
|
284
|
+
if (!commandAgent) throw new Error("Usage: seedhape identity:restore <encrypted-backup-file>");
|
|
285
|
+
const source = resolve(commandAgent);
|
|
286
|
+
const info = await stat(source);
|
|
287
|
+
if (!info.isFile() || info.size > 10 * 1024 * 1024) throw new Error("Identity backup must be a regular file no larger than 10 MB.");
|
|
288
|
+
const passphrase = await promptSecret("Backup passphrase");
|
|
289
|
+
const payload = decryptIdentityBackup(await readFile(source, "utf8"), passphrase);
|
|
290
|
+
const restored = await restoreSelfCustodiedBackupPayload(dataDir, payload);
|
|
291
|
+
console.log(`Restored identity ${restored.identityRef} controlled by ${restored.controller}.`);
|
|
292
|
+
console.log(`Identity keychain slot: ${payload.accountId}. Restore with this SEEDHAPE_AGENT_IDENTITY_CONTROLLER_ID value or leave it unset.`);
|
|
293
|
+
console.log("Verify SEEDHAPE_AGENT_TRUST_* and SEEDHAPE_AGENT_METADATA_ORIGIN match this identity before starting the MCP server.");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (command === "identity:metadata-server") {
|
|
297
|
+
const origin = process.env.SEEDHAPE_AGENT_METADATA_ORIGIN;
|
|
298
|
+
if (!origin) throw new Error("Set SEEDHAPE_AGENT_METADATA_ORIGIN to the public HTTPS origin routed to this metadata server.");
|
|
299
|
+
const publicOrigin = new URL(origin);
|
|
300
|
+
if (publicOrigin.protocol !== "https:" || publicOrigin.pathname !== "/" || publicOrigin.search || publicOrigin.hash) throw new Error("SEEDHAPE_AGENT_METADATA_ORIGIN must be an HTTPS origin without a path.");
|
|
301
|
+
const handler = await createSelfCustodiedMetadataHandler(dataDir);
|
|
302
|
+
const host = process.env.SEEDHAPE_AGENT_METADATA_BIND ?? "127.0.0.1";
|
|
303
|
+
const port = Number(process.env.SEEDHAPE_AGENT_METADATA_PORT ?? "8787");
|
|
304
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("SEEDHAPE_AGENT_METADATA_PORT must be a valid TCP port.");
|
|
305
|
+
const server = createHttpServer(async (incoming, outgoing) => {
|
|
306
|
+
try {
|
|
307
|
+
const url = new URL(incoming.url ?? "/", origin);
|
|
308
|
+
if (url.origin !== publicOrigin.origin) {
|
|
309
|
+
outgoing.statusCode = 400;
|
|
310
|
+
outgoing.end("Invalid metadata origin.");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const response = await handler(new Request(url, { method: incoming.method ?? "GET" }));
|
|
314
|
+
outgoing.statusCode = response.status;
|
|
315
|
+
response.headers.forEach((value, name) => outgoing.setHeader(name, value));
|
|
316
|
+
outgoing.end(incoming.method === "HEAD" ? undefined : Buffer.from(await response.arrayBuffer()));
|
|
317
|
+
} catch (error) {
|
|
318
|
+
outgoing.statusCode = 500;
|
|
319
|
+
outgoing.end(error instanceof Error ? error.message : "Metadata request failed.");
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
server.listen(port, host, () => console.error(`Self-custodied agent metadata handler listening on ${host}:${port}; proxy ${origin}/.well-known/seedhape-agents/* here.`));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (command === "wallet:init") {
|
|
326
|
+
if (!commandAgent) throw new Error("Usage: seedhape wallet:init <agent-id>");
|
|
327
|
+
console.log(await createLocalWallet(commandAgent));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (command === "wallet:address") {
|
|
331
|
+
if (!commandAgent) throw new Error("Usage: seedhape wallet:address <agent-id>");
|
|
332
|
+
const address = await localWalletAddress(commandAgent);
|
|
333
|
+
if (!address) throw new Error(`No local wallet exists for ${commandAgent}.`);
|
|
334
|
+
console.log(address);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (command === "wallet:delete") {
|
|
338
|
+
if (!commandAgent) throw new Error("Usage: seedhape wallet:delete <agent-id>");
|
|
339
|
+
console.log((await deleteLocalWallet(commandAgent)) ? "Deleted." : "No wallet found.");
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (command && command !== "serve") throw new Error(`Unknown command "${command}". Run \`seedhape setup\` to get started.`);
|
|
343
|
+
|
|
344
|
+
// Default: run the local MCP server over stdio for Codex, Claude Code, or another MCP client.
|
|
345
|
+
const network = process.env.SEEDHAPE_NETWORK ?? "base";
|
|
346
|
+
const asset = USDC[network];
|
|
347
|
+
if (!asset) throw new Error(`Unsupported network "${network}". Use one of: ${Object.keys(USDC).join(", ")}`);
|
|
348
|
+
const agentId = process.env.SEEDHAPE_LOCAL_AGENT ?? process.env.SEEDHAPE_LOCAL_KEYTAR_AGENT ?? defaultAgentId();
|
|
349
|
+
const key = await localWalletPrivateKey(agentId) as Hex | null;
|
|
350
|
+
const hasSavedAccount = Boolean(await loadToken());
|
|
351
|
+
const identity = await loadSeedhapeHostedAgent(process.env.SEEDHAPE_DATA_DIR ?? join(homedir(), ".seedhape"), (error) => say(`seedhape: could not refresh the agent's key directory: ${error instanceof Error ? error.message : String(error)}`)).catch((error) => { say(`seedhape: on-chain identity unavailable (${error instanceof Error ? error.message : String(error)}); continuing without it.`); return undefined; });
|
|
352
|
+
const webBotAuth = await loadWebBotAuthKey(agentId).catch(() => null);
|
|
353
|
+
const server = createServer({
|
|
354
|
+
agentId,
|
|
355
|
+
address: key ? privateKeyToAccount(key).address : null,
|
|
356
|
+
signer: key ? new LocalSessionKeySigner(key) : null,
|
|
357
|
+
webBotAuth,
|
|
358
|
+
identity: identity ?? null,
|
|
359
|
+
asset,
|
|
360
|
+
absoluteMaxPerPayment: BigInt(process.env.SEEDHAPE_ABSOLUTE_MAX ?? "1000000"),
|
|
361
|
+
setupNeeded: !hasSavedAccount || !key || (!identity && !webBotAuth),
|
|
362
|
+
finishSetup: () => finishFirstUseSetup(agentId, asset.network, dataDir, say),
|
|
363
|
+
onSetupError: (error) => say(`seedhape: browser setup did not finish (${error instanceof Error ? error.message : String(error)}). Try any wallet tool again to retry.`),
|
|
364
|
+
});
|
|
365
|
+
await server.connect(new StdioServerTransport());
|
|
366
|
+
const shutdown = () => process.exit(0);
|
|
367
|
+
process.on("SIGINT", shutdown);
|
|
368
|
+
process.on("SIGTERM", shutdown);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
main().catch((error) => {
|
|
372
|
+
console.error(`seedhape: ${error instanceof Error ? error.message : String(error)}`);
|
|
373
|
+
process.exit(1);
|
|
374
|
+
});
|
package/src/keychain.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import keytar from "keytar";
|
|
2
|
+
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
|
|
3
|
+
import type { Address, Hex } from "@seedhape/core";
|
|
4
|
+
|
|
5
|
+
const SERVICE = "seedhape-agent-wallet";
|
|
6
|
+
const IDENTITY_SERVICE = "seedhape-agent-identity-controller";
|
|
7
|
+
const IDENTITY_KEYSTORE_SERVICE = "seedhape-agent-identity-keystore";
|
|
8
|
+
|
|
9
|
+
export async function createLocalWallet(agentId: string): Promise<Address> {
|
|
10
|
+
const existing = await keytar.getPassword(SERVICE, agentId);
|
|
11
|
+
if (existing) return privateKeyToAccount(existing as Hex).address;
|
|
12
|
+
const privateKey = generatePrivateKey();
|
|
13
|
+
await keytar.setPassword(SERVICE, agentId, privateKey);
|
|
14
|
+
return privateKeyToAccount(privateKey).address;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function localWalletAddress(agentId: string): Promise<Address | null> {
|
|
18
|
+
const privateKey = await keytar.getPassword(SERVICE, agentId);
|
|
19
|
+
return privateKey ? privateKeyToAccount(privateKey as Hex).address : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function localWalletPrivateKey(agentId: string): Promise<Hex | null> {
|
|
23
|
+
const privateKey = await keytar.getPassword(SERVICE, agentId);
|
|
24
|
+
return privateKey as Hex | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function deleteLocalWallet(agentId: string): Promise<boolean> {
|
|
28
|
+
return keytar.deletePassword(SERVICE, agentId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Identity controller is stored under a separate OS-keychain service from payment wallets. */
|
|
32
|
+
export async function createOrGetIdentityController(agentId: string): Promise<Hex> {
|
|
33
|
+
const existing = await keytar.getPassword(IDENTITY_SERVICE, agentId);
|
|
34
|
+
if (existing) return existing as Hex;
|
|
35
|
+
const privateKey = generatePrivateKey();
|
|
36
|
+
await keytar.setPassword(IDENTITY_SERVICE, agentId, privateKey);
|
|
37
|
+
const persisted = await keytar.getPassword(IDENTITY_SERVICE, agentId);
|
|
38
|
+
if (!persisted) throw new Error("Could not persist identity controller in the operating system keychain.");
|
|
39
|
+
return persisted as Hex;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Import a controller only into an empty slot, or accept the same key idempotently. */
|
|
43
|
+
export async function importIdentityController(agentId: string, privateKey: Hex): Promise<Address> {
|
|
44
|
+
const importedAddress = privateKeyToAccount(privateKey).address;
|
|
45
|
+
const existing = await keytar.getPassword(IDENTITY_SERVICE, agentId);
|
|
46
|
+
if (existing && privateKeyToAccount(existing as Hex).address.toLowerCase() !== importedAddress.toLowerCase()) {
|
|
47
|
+
throw new Error("A different identity controller is already stored in this OS keychain slot. Set SEEDHAPE_AGENT_IDENTITY_CONTROLLER_ID to a fresh slot.");
|
|
48
|
+
}
|
|
49
|
+
if (!existing) await keytar.setPassword(IDENTITY_SERVICE, agentId, privateKey);
|
|
50
|
+
return importedAddress;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function identityControllerPrivateKey(agentId: string): Promise<Hex | null> {
|
|
54
|
+
return await keytar.getPassword(IDENTITY_SERVICE, agentId) as Hex | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Explicit recovery export; callers must only invoke this in a trusted local terminal. */
|
|
58
|
+
export async function exportIdentityControllerPrivateKey(agentId: string): Promise<Hex | null> {
|
|
59
|
+
return identityControllerPrivateKey(agentId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function createOrGetIdentityKeyStoreMasterKey(agentId: string): Promise<Buffer> {
|
|
63
|
+
const existing = await keytar.getPassword(IDENTITY_KEYSTORE_SERVICE, agentId);
|
|
64
|
+
if (existing) return Buffer.from(existing, "base64url");
|
|
65
|
+
const masterKey = Buffer.from(generatePrivateKey().slice(2), "hex");
|
|
66
|
+
await keytar.setPassword(IDENTITY_KEYSTORE_SERVICE, agentId, masterKey.toString("base64url"));
|
|
67
|
+
const persisted = await keytar.getPassword(IDENTITY_KEYSTORE_SERVICE, agentId);
|
|
68
|
+
if (!persisted) throw new Error("Could not persist the identity key-store key in the operating system keychain.");
|
|
69
|
+
return Buffer.from(persisted, "base64url");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function identityKeyStoreMasterKey(agentId: string): Promise<Buffer | null> {
|
|
73
|
+
const existing = await keytar.getPassword(IDENTITY_KEYSTORE_SERVICE, agentId);
|
|
74
|
+
return existing ? Buffer.from(existing, "base64url") : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function restoreIdentityCredentials(agentId: string, controllerKey: Hex, masterKey: Buffer): Promise<void> {
|
|
78
|
+
if (masterKey.length !== 32) throw new Error("Identity backup key-store key must be 32 bytes.");
|
|
79
|
+
const controller = privateKeyToAccount(controllerKey).address;
|
|
80
|
+
const currentController = await keytar.getPassword(IDENTITY_SERVICE, agentId);
|
|
81
|
+
const currentMaster = await keytar.getPassword(IDENTITY_KEYSTORE_SERVICE, agentId);
|
|
82
|
+
if (currentController && privateKeyToAccount(currentController as Hex).address.toLowerCase() !== controller.toLowerCase()) {
|
|
83
|
+
throw new Error("A different identity controller is already stored under this OS keychain account. Use a fresh SEEDHAPE_AGENT_IDENTITY_CONTROLLER_ID or back up the existing identity first.");
|
|
84
|
+
}
|
|
85
|
+
if (currentMaster && !Buffer.from(currentMaster, "base64url").equals(masterKey)) {
|
|
86
|
+
throw new Error("A different identity key-store key is already stored under this OS keychain account. Use a fresh SEEDHAPE_AGENT_IDENTITY_CONTROLLER_ID or back up the existing identity first.");
|
|
87
|
+
}
|
|
88
|
+
if (!currentController) await keytar.setPassword(IDENTITY_SERVICE, agentId, controllerKey);
|
|
89
|
+
if (!currentMaster) await keytar.setPassword(IDENTITY_KEYSTORE_SERVICE, agentId, masterKey.toString("base64url"));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Web Bot Auth (Ed25519 key that proves which agent is calling) ──────────
|
|
93
|
+
const WEB_BOT_AUTH_SERVICE = "seedhape-web-bot-auth";
|
|
94
|
+
|
|
95
|
+
export type StoredWebBotAuthKey = {
|
|
96
|
+
privateKeyPem: string;
|
|
97
|
+
publicJwk: { kty: "OKP"; crv: "Ed25519"; x: string };
|
|
98
|
+
keyId: string;
|
|
99
|
+
signatureAgent: string;
|
|
100
|
+
createdAt: number;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export async function saveWebBotAuthKey(agentId: string, key: StoredWebBotAuthKey): Promise<void> {
|
|
104
|
+
await keytar.setPassword(WEB_BOT_AUTH_SERVICE, agentId, JSON.stringify(key));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function loadWebBotAuthKey(agentId: string): Promise<StoredWebBotAuthKey | null> {
|
|
108
|
+
const raw = await keytar.getPassword(WEB_BOT_AUTH_SERVICE, agentId);
|
|
109
|
+
return raw ? JSON.parse(raw) as StoredWebBotAuthKey : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function deleteWebBotAuthKey(agentId: string): Promise<boolean> {
|
|
113
|
+
return keytar.deletePassword(WEB_BOT_AUTH_SERVICE, agentId);
|
|
114
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-chain (ERC-8004) identity for a self-custody agent, with Seedhape doing the
|
|
3
|
+
* hosting so the user configures nothing:
|
|
4
|
+
*
|
|
5
|
+
* • Seedhape tells the wallet which Base registry / AgentTrust contract to use.
|
|
6
|
+
* • Seedhape can top up the identity controller's gas once per account.
|
|
7
|
+
* • Seedhape serves the agent's public registration and key directory.
|
|
8
|
+
*
|
|
9
|
+
* Every key stays on this machine. Merchants reject key-directory signatures
|
|
10
|
+
* older than 5 minutes, so while the wallet runs it re-signs the directory and
|
|
11
|
+
* uploads just the signature every few minutes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { PrivateKeyAccount } from "viem/accounts";
|
|
15
|
+
import { FileAgentMetadataPublisher, type AgentMetadataPublisher, type AgentOperationalKeyStore, type AgentTrustConfig } from "@seedhape/core";
|
|
16
|
+
import { SeedhapeAuthError, SeedhapeUnreachableError, loadToken, seedhapeUrl } from "./hosted.js";
|
|
17
|
+
|
|
18
|
+
export type IdentityConfig =
|
|
19
|
+
| { enabled: false }
|
|
20
|
+
| { enabled: true; network: "base" | "base-sepolia"; chainId: 8453 | 84532; identityRegistry: `0x${string}`; trustContract: `0x${string}`; rpcUrl: string; signatureAgent: string; minimumControllerBalanceWei: string; gasSponsored: boolean };
|
|
21
|
+
|
|
22
|
+
/** Directory signatures last just under the 5-minute maximum and are refreshed well before expiry. */
|
|
23
|
+
export const DIRECTORY_LIFETIME_SECONDS = 290;
|
|
24
|
+
export const DIRECTORY_REFRESH_MS = 240_000;
|
|
25
|
+
|
|
26
|
+
async function call<T>(method: "GET" | "POST" | "PUT", path: string, body?: unknown): Promise<T> {
|
|
27
|
+
const token = await loadToken();
|
|
28
|
+
if (!token) throw new SeedhapeAuthError();
|
|
29
|
+
let response: Response;
|
|
30
|
+
try {
|
|
31
|
+
response = await fetch(`${seedhapeUrl()}${path}`, {
|
|
32
|
+
method,
|
|
33
|
+
headers: { authorization: `Bearer ${token}`, ...(body ? { "content-type": "application/json" } : {}) },
|
|
34
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
35
|
+
signal: AbortSignal.timeout(60_000),
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
throw new SeedhapeUnreachableError(error);
|
|
39
|
+
}
|
|
40
|
+
if (response.status === 401) throw new SeedhapeAuthError();
|
|
41
|
+
const parsed = await response.json().catch(() => ({})) as T & { error?: string };
|
|
42
|
+
if (!response.ok) throw new Error(parsed.error ?? `Seedhape returned HTTP ${response.status}.`);
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const fetchIdentityConfig = () => call<IdentityConfig>("GET", "/api/local-wallet/identity");
|
|
47
|
+
|
|
48
|
+
export function trustConfigFrom(config: Extract<IdentityConfig, { enabled: true }>): AgentTrustConfig {
|
|
49
|
+
return { network: config.network, chainId: config.chainId, identityRegistry: config.identityRegistry, contractAddress: config.trustContract, rpcUrl: config.rpcUrl };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const requestGasSponsorship = (controller: string) =>
|
|
53
|
+
call<{ outcome: "funded" } | { outcome: "pending"; transactionHash: string } | { outcome: "manual"; reason: string }>("POST", "/api/local-wallet/identity/sponsor", { controller });
|
|
54
|
+
|
|
55
|
+
/** Must match the hosted ownershipMessage() exactly. */
|
|
56
|
+
export const ownershipMessage = (identityRef: string, keyId: string) => `Seedhape self-custody agent metadata\nidentity=${identityRef}\nkey=${keyId}`;
|
|
57
|
+
|
|
58
|
+
export type PublishedDirectory = { identityRef: string; keyId: string; keyStoreRef: string; publicJwk: Record<string, unknown>; directoryUri: string };
|
|
59
|
+
|
|
60
|
+
/** Sign the key directory locally and upload only the signature (plus public key) to Seedhape. */
|
|
61
|
+
export async function uploadDirectory(keyStore: AgentOperationalKeyStore, directory: PublishedDirectory, owner?: PrivateKeyAccount): Promise<void> {
|
|
62
|
+
const signer = await keyStore.getSigner(directory.keyStoreRef);
|
|
63
|
+
const signed = await signer.signDirectory({ url: directory.directoryUri, keyId: directory.keyId, expiresInSeconds: DIRECTORY_LIFETIME_SECONDS });
|
|
64
|
+
await call("PUT", "/api/local-wallet/identity/directory", {
|
|
65
|
+
identityRef: directory.identityRef, keyId: directory.keyId, publicJwk: directory.publicJwk,
|
|
66
|
+
signature: signed.Signature, signatureInput: signed["Signature-Input"],
|
|
67
|
+
...(owner ? { ownerSignature: await owner.signMessage({ message: ownershipMessage(directory.identityRef, directory.keyId) }) } : {}),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Metadata publisher used by setupAgent: keeps the local files (so identity
|
|
73
|
+
* backups keep working) and publishes the same metadata through Seedhape.
|
|
74
|
+
*/
|
|
75
|
+
export class SeedhapeMetadataPublisher implements AgentMetadataPublisher {
|
|
76
|
+
private readonly files: FileAgentMetadataPublisher;
|
|
77
|
+
private refresher: ReturnType<typeof setInterval> | undefined;
|
|
78
|
+
|
|
79
|
+
constructor(metadataDirectory: string, private readonly signatureAgent: string, private readonly keyStore: AgentOperationalKeyStore, private readonly owner: PrivateKeyAccount) {
|
|
80
|
+
this.files = new FileAgentMetadataPublisher(metadataDirectory, signatureAgent);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async publishWebBotAuthDirectory(input: { instanceId: string; identityRef: string; keyId: string; keyStoreRef: string; publicJwk: Record<string, unknown> }) {
|
|
84
|
+
const published = await this.files.publishWebBotAuthDirectory(input);
|
|
85
|
+
const directory = { ...input, directoryUri: published.directoryUri };
|
|
86
|
+
await uploadDirectory(this.keyStore, directory, this.owner);
|
|
87
|
+
// Setup keeps going (on-chain transactions, then a live check of the directory), so keep the signature fresh meanwhile.
|
|
88
|
+
this.stop();
|
|
89
|
+
this.refresher = setInterval(() => { void uploadDirectory(this.keyStore, directory).catch(() => undefined); }, DIRECTORY_REFRESH_MS);
|
|
90
|
+
this.refresher.unref?.();
|
|
91
|
+
return published;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async publishRegistration(input: { identityRef: string; registration: Record<string, unknown> }): Promise<string> {
|
|
95
|
+
const uri = await this.files.publishRegistration(input);
|
|
96
|
+
await call("PUT", "/api/local-wallet/identity/registration", input);
|
|
97
|
+
return uri;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
stop() { if (this.refresher) clearInterval(this.refresher); this.refresher = undefined; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Keep a ready agent's directory fresh while the local wallet runs. Returns a function that refreshes immediately. */
|
|
104
|
+
export function startDirectoryRefresher(keyStore: AgentOperationalKeyStore, directory: PublishedDirectory, onError: (error: unknown) => void = () => {}) {
|
|
105
|
+
let last = 0;
|
|
106
|
+
const refresh = async (force = false) => {
|
|
107
|
+
if (!force && Date.now() - last < 60_000) return;
|
|
108
|
+
try { await uploadDirectory(keyStore, directory); last = Date.now(); } catch (error) { onError(error); }
|
|
109
|
+
};
|
|
110
|
+
const timer = setInterval(() => { void refresh(true); }, DIRECTORY_REFRESH_MS);
|
|
111
|
+
timer.unref?.();
|
|
112
|
+
return { refresh, stop: () => clearInterval(timer) };
|
|
113
|
+
}
|