@@ -26,6 +26,7 @@ import { createServer, type ServerResponse } from "node:http";
2626import { readFile } from "node:fs/promises" ;
2727import { fileURLToPath } from "node:url" ;
2828import { dirname , join , normalize , extname } from "node:path" ;
29+ import { homedir } from "node:os" ;
2930import {
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
7477const PORT = Number ( process . env . AGENTNET_PORT ?? 4317 ) ;
7578const GOOGLE_AUTHORIZE_URL = process . env . GOOGLE_AUTHORIZE_URL || "" ;
@@ -122,6 +125,71 @@ const REPLAY_BUFFER = 256;
122125let wallet : Wallet | null = null ;
123126let runtime : AgentRuntime | null = null ;
124127let 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).
260328async 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.
465546function 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
10481152http . 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} ) ;
0 commit comments