Skip to content

Commit ec5ac96

Browse files
authored
Merge pull request #121 from IQCoreTeam/feat/issue-118-progressive-unlock
feat: add progressive wallet unlock flow
2 parents 7802936 + 1015d1e commit ec5ac96

16 files changed

Lines changed: 830 additions & 185 deletions

File tree

surfaces/localhost/src/index.ts

Lines changed: 156 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { createServer, type ServerResponse } from "node:http";
2626
import { readFile } from "node:fs/promises";
2727
import { fileURLToPath } from "node:url";
2828
import { dirname, join, normalize, extname } from "node:path";
29+
import { homedir } from "node:os";
2930
import {
3031
connect,
3132
createChatSession,
@@ -58,7 +59,6 @@ import {
5859
saveGoogleCreds,
5960
hasGoogleCreds,
6061
isCloudConnected,
61-
isInitialized,
6262
marketplaceEnv,
6363
saveHeliusKey,
6464
maskedHeliusKey,
@@ -69,7 +69,10 @@ import {
6969
loadGithubToken,
7070
registerVerifiedWork,
7171
workflowMintsAmong,
72+
localWallet,
73+
manualStorage,
7274
} from "@iqlabs-official/agent-sdk";
75+
import { SessionStore } from "@iqlabs-official/agent-sdk/account/store";
7376

7477
const PORT = Number(process.env.AGENTNET_PORT ?? 4317);
7578
const GOOGLE_AUTHORIZE_URL = process.env.GOOGLE_AUTHORIZE_URL || "";
@@ -122,6 +125,71 @@ const REPLAY_BUFFER = 256;
122125
let wallet: Wallet | null = null;
123126
let runtime: AgentRuntime | null = null;
124127
let walletAddress: string | null = null;
128+
let guestWallet: Wallet | null = null;
129+
let walletEpoch = 0;
130+
131+
const GUEST_WALLET_PATH = join(
132+
process.env.AGENTNET_HOME || join(homedir(), ".agentnet"),
133+
"guest-wallet.json",
134+
);
135+
136+
async function deviceGuestWallet(): Promise<Wallet> {
137+
if (guestWallet) return guestWallet;
138+
const loaded = await localWallet(GUEST_WALLET_PATH);
139+
// The device key may sign the fixed session-key message, but it is not a chain wallet.
140+
// Keep marketplace reads available to the agent while making every transaction-signing
141+
// path fail closed until the user connects a real wallet.
142+
guestWallet = {
143+
...loaded.wallet,
144+
async signTransaction() {
145+
throw new Error("Connect a wallet to use on-chain actions.");
146+
},
147+
async signAllTransactions() {
148+
throw new Error("Connect a wallet to use on-chain actions.");
149+
},
150+
} as Wallet;
151+
return guestWallet;
152+
}
153+
154+
async function ensureGuestRuntime(): Promise<AgentRuntime> {
155+
if (walletAddress && wallet) return ensureRuntime(wallet);
156+
const guest = await deviceGuestWallet();
157+
if (wallet !== guest) {
158+
wallet = guest;
159+
runtime = null;
160+
walletEpoch += 1;
161+
}
162+
return ensureRuntime(guest);
163+
}
164+
165+
// Preserve the value-first conversation when the user unlocks. Guest pages are decrypted
166+
// with the device key and appended into the real wallet's local store, which re-encrypts
167+
// them with the wallet-derived key. Existing destination sessions are never duplicated.
168+
async function migrateGuestSessions(realWallet: Wallet): Promise<void> {
169+
const guest = await deviceGuestWallet();
170+
const source = new SessionStore(guest, manualStorage(guest.address));
171+
const destination = new SessionStore(realWallet, manualStorage(realWallet.address));
172+
const existing = new Set((await destination.listMine()).map((s) => s.sessionId));
173+
for (const meta of await source.listMine()) {
174+
const session = await source.load(meta.sessionId);
175+
if (!session) continue;
176+
let start = 0;
177+
if (existing.has(meta.sessionId)) {
178+
const current = await destination.load(meta.sessionId);
179+
if (!current || current.messages.length >= session.messages.length) continue;
180+
const samePrefix = current.messages.every((message, index) =>
181+
JSON.stringify(message) === JSON.stringify(session.messages[index]),
182+
);
183+
if (!samePrefix) continue;
184+
start = current.messages.length;
185+
}
186+
if (session.messages.length === 0) {
187+
await destination.recordMeta(meta);
188+
continue;
189+
}
190+
for (const message of session.messages.slice(start)) await destination.appendMessage(meta, message);
191+
}
192+
}
125193

126194
// Latest drive-mirror sync result + the hook the active chat sets to surface it
127195
// (cloud writes are otherwise silent). One value; the connected chat reflects it.
@@ -258,16 +326,21 @@ async function submitGoogleAuthCode(c: Client, code: string) {
258326
// Build the runtime from a freshly connected wallet (idempotent for this host: a
259327
// second connect with the same address is a no-op so re-opened tabs don't rebuild).
260328
async function connectWallet(address: string, signature: Uint8Array): Promise<void> {
261-
if (runtime && walletAddress === address) return;
262-
wallet = webWallet(address, signature, signTransactionViaUi);
263-
walletAddress = address;
264-
// Only build the runtime immediately if storage is already configured (returning user).
265-
// First-time users go through onboarding (storage picker) before runtime is needed;
266-
// building it here with no-cloud config and then rebuilding after Drive OAuth caused
267-
// the chat SSE to attach to a stale local runtime while the user was in Chrome.
268-
if (await isInitialized()) {
269-
await rebuildRuntime(wallet);
329+
if (walletAddress === address) return;
330+
const connected = webWallet(address, signature, signTransactionViaUi);
331+
// A damaged guest store must never lock the user out of connecting a wallet —
332+
// the guest copy stays on disk, so a later connect can retry the migration.
333+
try {
334+
await migrateGuestSessions(connected);
335+
} catch (e) {
336+
console.error("[wallet] guest session migration failed:", e);
270337
}
338+
wallet = connected;
339+
walletAddress = address;
340+
walletEpoch += 1;
341+
// Local storage is always available. Rebuild now so the WebView can reopen directly into
342+
// the unlocked runtime without routing through the old blocking storage setup.
343+
await rebuildRuntime(connected);
271344
}
272345

273346
// ── one connected UI (one SSE stream) ──
@@ -442,11 +515,19 @@ function attachAuthHandlers(c: Client) {
442515
}
443516
return;
444517
case "startGoogleLogin":
518+
if (!walletAddress) {
519+
c.send({ type: "toast", text: "Connect a wallet to enable cross-device sync." });
520+
return;
521+
}
445522
beginGoogleLogin(c);
446523
return;
447524
// One-tap reconnect after a dead cloud sign-in (mirror reports reason:"reauth").
448525
// Re-runs the same Google flow (native on Android, fixed-redirect on web).
449526
case "reconnectCloud":
527+
if (!walletAddress) {
528+
c.send({ type: "toast", text: "Connect a wallet to enable cross-device sync." });
529+
return;
530+
}
450531
beginGoogleLogin(c);
451532
return;
452533
case "googleAuthCode":
@@ -464,6 +545,7 @@ function attachAuthHandlers(c: Client) {
464545
// but they should still work when the active SSE client is in onboarding/storage setup.
465546
function attachMarketHandlers(c: Client) {
466547
let mktPromise: ReturnType<typeof marketplaceEnv> | null = null;
548+
let mktEpoch = -1;
467549
// One-time per market session: have we pulled the wallet's owned NFT skills from chain
468550
// and installed them locally yet? vscode does this at chat "ready" via env.loadOwnedSkills;
469551
// localhost has no such env wiring, so we drive it from the first owned-skills read below.
@@ -482,6 +564,11 @@ function attachMarketHandlers(c: Client) {
482564
}
483565
async function getMarket() {
484566
if (!wallet) throw new Error("Wallet not connected.");
567+
if (mktEpoch !== walletEpoch) {
568+
mktPromise = null;
569+
ownedSynced = false;
570+
mktEpoch = walletEpoch;
571+
}
485572
if (!mktPromise) {
486573
const currentWallet = wallet;
487574
mktPromise = withMarketTimeout(
@@ -587,6 +674,40 @@ function attachMarketHandlers(c: Client) {
587674
return;
588675
}
589676
}
677+
678+
// Guests may browse every public read surface, but no wallet-specific read or chain
679+
// write reaches marketplaceEnv. This is the server-side invariant behind the UI locks.
680+
if (!walletAddress) {
681+
switch (m.type) {
682+
case "ownedSkills":
683+
c.send({ type: "ownedSkills", names: [], mints: {}, disposedMints: {}, cards: [], workflowMints: [] });
684+
return;
685+
case "getBalance":
686+
c.send({ type: "balance", lamports: null });
687+
return;
688+
case "buySkill":
689+
c.send({ type: "buyResult", skillId: m.skillId ?? "", ok: false, error: "Connect a wallet to buy skills." });
690+
return;
691+
case "buyAllSkills":
692+
case "buyRequiredSkills":
693+
c.send({ type: "buyAllResult", wallet: m.wallet ?? "", ok: false, bought: 0, failed: 0, error: "Connect a wallet to buy skills." });
694+
return;
695+
case "publishSkill":
696+
c.send({ type: "publishResult", ok: false, error: "Connect a wallet to publish skills." });
697+
return;
698+
case "postNote":
699+
c.send({ type: "postNoteResult", skillId: m.skillId ?? "", ok: false, error: "Connect a wallet to comment." });
700+
return;
701+
case "postAgentNote":
702+
c.send({ type: "agentNoteResult", agentWallet: m.agentWallet ?? "", ok: false, error: "Connect a wallet to post." });
703+
return;
704+
case "disposeSkill":
705+
case "reEquipSkill":
706+
case "airdrop":
707+
c.send({ type: "toast", text: "Connect a wallet to use this action." });
708+
return;
709+
}
710+
}
590711
let mkt;
591712
try {
592713
mkt = await getMarket();
@@ -774,7 +895,7 @@ function attachChat(id: string, c: Client, rt: AgentRuntime) {
774895
walletAddress: () => walletAddress,
775896
storageInfo: async () => ({ info: await getStorageInfo(), options: STORAGE_OPTIONS, googleCredsConfigured: await hasGoogleCreds() }),
776897
connectCloud: async (cfg) => {
777-
if (wallet) {
898+
if (wallet && walletAddress) {
778899
await switchStorage(wallet, { kind: cfg.kind, location: cfg.location, authHeader: cfg.authHeader } as StorageConfig);
779900
await rebuildRuntime(wallet);
780901
}
@@ -787,9 +908,11 @@ function attachChat(id: string, c: Client, rt: AgentRuntime) {
787908
},
788909
disconnectWallet: async () => {
789910
await disconnectCloud();
790-
wallet = null;
791911
walletAddress = null;
792912
runtime = null;
913+
wallet = await deviceGuestWallet();
914+
walletEpoch += 1;
915+
await rebuildRuntime(wallet);
793916
c.send({ type: "clear" });
794917
c.send({ type: "init", defaultPath: null, cloudKind: null, hasWallet: false });
795918
},
@@ -803,8 +926,13 @@ function attachChat(id: string, c: Client, rt: AgentRuntime) {
803926
},
804927
});
805928
attachAuthHandlers(c);
929+
attachWalletConnection(c);
806930
c.recvs.push(async (m: any) => {
807-
if (m?.type === "ready") await pushCliStatus(c);
931+
if (m?.type === "ready") {
932+
c.send({ type: "init", defaultPath: null, cloudKind: null, hasWallet: !!walletAddress });
933+
c.send({ type: "wallet", address: walletAddress });
934+
await pushCliStatus(c);
935+
}
808936
});
809937
attachMarketHandlers(c);
810938

@@ -876,35 +1004,18 @@ function attachChat(id: string, c: Client, rt: AgentRuntime) {
8761004
};
8771005
}
8781006

879-
// Before a wallet exists, a client is in ONBOARDING: its recv handles the wallet
880-
// handshake plus market RPC/Helius settings (so the storage screen can configure Helius
881-
// before any runtime exists). On connect we build the runtime; the onboarding webview then
882-
// navigates to / (chat), which opens a FRESH SSE client that finds the runtime ready and
883-
// attaches chat. So an onboarding client never carries chat itself — clean separation.
884-
function attachOnboarding(c: Client) {
885-
attachAuthHandlers(c);
886-
attachMarketHandlers(c);
887-
1007+
function attachWalletConnection(c: Client) {
8881008
c.recvs.push(async (m: any) => {
889-
if (m?.type === "ready") {
890-
c.send({ type: "init", defaultPath: null, cloudKind: null, hasWallet: !!wallet });
891-
return;
892-
}
893-
if (m?.type === "connectWallet" && typeof m.address === "string" && Array.isArray(m.signature)) {
894-
try {
895-
await connectWallet(m.address, Uint8Array.from(m.signature));
896-
} catch (e) {
897-
c.send({ type: "toast", text: "Wallet connect failed: " + (e as Error).message });
898-
return;
899-
}
900-
// storageConfigured lets the UI skip the storage picker on a returning device —
901-
// the gdrive choice + token persist, so re-walking that screen (and re-auth) is
902-
// pointless. Only a true first run needs the picker.
903-
c.send({ type: "walletConnected", address: walletAddress, storageOptions: STORAGE_OPTIONS, storageConfigured: await isCloudConnected() });
904-
c.send({ type: "storage", info: await getStorageInfo(), options: STORAGE_OPTIONS, googleCredsConfigured: await hasGoogleCreds() });
905-
await pushCliStatus(c);
1009+
if (m?.type !== "connectWallet" || typeof m.address !== "string" || !Array.isArray(m.signature)) return;
1010+
try {
1011+
await connectWallet(m.address, Uint8Array.from(m.signature));
1012+
} catch (e) {
1013+
c.send({ type: "toast", text: "Wallet connect failed: " + (e as Error).message });
9061014
return;
9071015
}
1016+
c.send({ type: "walletConnected", address: walletAddress, storageOptions: STORAGE_OPTIONS, storageConfigured: await isCloudConnected() });
1017+
c.send({ type: "storage", info: await getStorageInfo(), options: STORAGE_OPTIONS, googleCredsConfigured: await hasGoogleCreds() });
1018+
await pushCliStatus(c);
9081019
});
9091020
}
9101021

@@ -928,7 +1039,7 @@ const http = createServer(async (req, res) => {
9281039
const path = url.pathname;
9291040

9301041
// ── SSE: open this UI's event stream (server→UI). A fresh connection gets a new
931-
// client id + chat/onboarding attachment. A RECONNECT (?client=<id>&cursor=<seq>,
1042+
// client id + chat attachment. A RECONNECT (?client=<id>&cursor=<seq>,
9321043
// or Last-Event-ID header) rebinds the existing client to the new response and
9331044
// replays events after the cursor — so a brief WebView/network drop loses nothing,
9341045
// without re-running ready or re-attaching the dispatcher. ──
@@ -963,24 +1074,17 @@ const http = createServer(async (req, res) => {
9631074
res.write(`event: client\ndata: ${JSON.stringify({ client: id })}\n\n`);
9641075
res.on("close", () => { clearInterval(ka); scheduleTeardown(id, c); });
9651076
if (runtime) attachChat(id, c, runtime);
966-
// Wallet is connected this session but the runtime is null — the user finished
967-
// onboarding and navigated to /chat (a fresh SSE), or a reconnect/reopen raced the
968-
// deferred build (connectWallet defers it during onboarding to avoid the Chrome-OAuth
969-
// stale-runtime race). Build it now and bind CHAT instead of falling back to onboarding
970-
// (which would re-send `init` and silently drop chat messages). We do NOT gate on
971-
// isInitialized(): local storage ALWAYS works and cloud is optional — connect() mirrors
972-
// cloud only if one was configured — so a user who chose "continue without cloud" still
973-
// gets a working chat (matches the desktop/VSCode connect(wallet) path). Only a true
974-
// process restart (wallet lost from memory) re-onboards.
1077+
// A connected wallet or persistent guest identity always receives a chat runtime.
1078+
// Local storage is immediate; cloud sync remains an optional unlock action.
9751079
else if (wallet) {
9761080
attachChat(id, c, await ensureRuntime(wallet));
9771081
}
978-
else attachOnboarding(c);
1082+
else attachChat(id, c, await ensureGuestRuntime());
9791083
return;
9801084
}
9811085

9821086
// ── RPC: one UI→server command. Routed to its client's recv (the dispatcher or the
983-
// onboarding handler). The reply is not in the HTTP response — it streams back over
1087+
// dispatcher). The reply is not in the HTTP response — it streams back over
9841088
// that client's SSE (same as WS: send is async/push). ──
9851089
if (req.method === "POST" && path === "/rpc") {
9861090
const id = url.searchParams.get("client") ?? "";
@@ -1046,5 +1150,5 @@ const http = createServer(async (req, res) => {
10461150
});
10471151

10481152
http.listen(PORT, () => {
1049-
console.log(`AgentNet localhost → http://localhost:${PORT}/ (connect a wallet to begin)`);
1153+
console.log(`AgentNet localhost → http://localhost:${PORT}/ (guest chat ready)`);
10501154
});

surfaces/webview/src/App.tsx

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,13 +152,13 @@ export function App() {
152152
);
153153
}
154154

155-
// The 4-domain shell (screen-rearrangement.md §9) as a horizontal pager:
156-
// Chat · Skills · Agent · Market. A single floating glass bar slides its highlight as you
155+
// The value-first shell from issue #118 as a horizontal pager:
156+
// Chat · Market · Rank · Settings. A single floating bar slides its highlight as you
157157
// swipe between pages. Chat stays mounted (composer draft + scroll survive); the market
158158
// machine mounts only for the active page (it shares one store, so multiple live copies
159159
// would fight). Chat history lives in a left push-reveal drawer, opened by a right swipe
160160
// from Chat (page 0) — every other horizontal swipe pages between tabs.
161-
const LAST = 3; // Chat(0) Skills(1) Agent(2) Market(3)
161+
const LAST = 3; // Chat(0) Market(1) Rank(2) Settings(3)
162162

163163
function TabShell() {
164164
const { state, send } = useStore();
@@ -318,9 +318,9 @@ function TabShell() {
318318
<div className="an-page">
319319
<ChatScreen onOpenDrawer={() => changeDrawer(true)} />
320320
</div>
321-
<MarketPage marketTab="skills" active={idx === 1} />
321+
<MarketPage marketTab="market" active={idx === 1} />
322322
<MarketPage marketTab="profile" active={idx === 2} />
323-
<MarketPage marketTab="market" active={idx === 3} />
323+
<SettingsPage active={idx === 3} />
324324
</div>
325325
</div>
326326

@@ -354,3 +354,31 @@ function MarketPage({ marketTab, active }: { marketTab: "skills" | "profile" | "
354354
</div>
355355
);
356356
}
357+
358+
function SettingsPage({ active }: { active: boolean }) {
359+
const [showSkills, setShowSkills] = useState(false);
360+
if (!active) {
361+
return (
362+
<div className="an-page">
363+
<div className="flex h-full items-center justify-center bg-zinc-950">
364+
<span className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-700 border-t-transparent" />
365+
</div>
366+
</div>
367+
);
368+
}
369+
return (
370+
<div className="an-page">
371+
{showSkills ? (
372+
<MarketScreen tab="skills" onBack={() => setShowSkills(false)} />
373+
) : (
374+
<Sessions
375+
embedded
376+
initialMode="configure"
377+
settingsRoot
378+
onClose={() => undefined}
379+
onOpenSkills={() => setShowSkills(true)}
380+
/>
381+
)}
382+
</div>
383+
);
384+
}

0 commit comments

Comments
 (0)