Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/core/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<!-- agentnet:memory:start -->
# Shared memory (managed by AgentNet — do not edit between the markers)

<!-- agentnet:memory:end -->
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"./*.js": "./src/*.ts"
},
"scripts": {
"build": "tsup",
"build": "tsup src/index.ts",
"test": "vitest run",
"test:run": "tsx test/test-runtime.ts",
"test:memory": "tsx test/test-memory.ts",
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/account/keyPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export interface KeyPolicy {
// an ephemeral key is already dropped without calling this. clear() is the explicit
// path for when a surface keeps one store across logins (e.g. the persisted toggle
// wiping the in-memory copy while the vault entry stays) — wired when that lands.
clear(): void;
clear(address?: string): Promise<void> | void;
}

// Memory-only: derive once per process, cache, drop on clear(). Current behavior,
Expand Down Expand Up @@ -69,8 +69,11 @@ export function persistedKey(vault: KeyVault): KeyPolicy {
}
return key;
},
clear() {
async clear(address?: string) {
key = undefined;
if (address) {
await vault.remove(address);
}
},
};
}
116 changes: 116 additions & 0 deletions packages/core/src/account/login.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { logout } from "./login.js";
import { tokenFile, configFile, sessionsDir } from "../core/paths.js";
import { saveCodexApiKey, getCodexApiKey } from "./codexAuth.js";
import { ephemeralKey, persistedKey, type KeyVault } from "./keyPolicy.js";
import type { SessionKey } from "../core/crypto.js";

let home: string;

beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "agentnet-test-login-"));
process.env.AGENTNET_HOME = home;
// Make sure directories exist
mkdirSync(join(home, "tokens"), { recursive: true });
mkdirSync(join(home, "sessions"), { recursive: true });
});

afterEach(() => {
delete process.env.AGENTNET_HOME;
rmSync(home, { recursive: true, force: true });
});

describe("unified logout logic", () => {
it("soft logout disconnects cloud but preserves session files and Codex API key", async () => {
// Setup
writeFileSync(configFile(), JSON.stringify({ kind: "gdrive", google_client_id: "client-id" }));
writeFileSync(tokenFile("google"), JSON.stringify({ access_token: "tok" }));

const walletAddress = "5ey2ja1KstPwMQRx7EokG2dfJksAGGPA";
const sessionWalletDir = join(sessionsDir(), walletAddress);
mkdirSync(sessionWalletDir, { recursive: true });
writeFileSync(join(sessionWalletDir, "history.bin"), "some history data");

await saveCodexApiKey("fake-codex-key");

// Perform soft logout
await logout({ policy: "soft" });

// Assert cloud disconnected
expect(existsSync(tokenFile("google"))).toBe(false);

// Config file should only retain app credentials (google_client_id)
const configData = JSON.parse(readFileSyncText(configFile()));
expect(configData.kind).toBeUndefined();
expect(configData.google_client_id).toBe("client-id");

// Assert session files and Codex API key are preserved
expect(existsSync(join(sessionWalletDir, "history.bin"))).toBe(true);
expect(await getCodexApiKey()).toBe("fake-codex-key");
});

it("full logout disconnects cloud, deletes address-specific session logs, and wipes Codex API key", async () => {
// Setup
writeFileSync(configFile(), JSON.stringify({ kind: "gdrive", google_client_id: "client-id" }));
writeFileSync(tokenFile("google"), JSON.stringify({ access_token: "tok" }));

const walletAddress = "5ey2ja1KstPwMQRx7EokG2dfJksAGGPA";
const sessionWalletDir = join(sessionsDir(), walletAddress);
mkdirSync(sessionWalletDir, { recursive: true });
writeFileSync(join(sessionWalletDir, "history.bin"), "some history data");

// Another wallet's session should NOT be wiped
const otherWalletAddress = "otherAddress123";
const otherSessionWalletDir = join(sessionsDir(), otherWalletAddress);
mkdirSync(otherSessionWalletDir, { recursive: true });
writeFileSync(join(otherSessionWalletDir, "history.bin"), "other data");

await saveCodexApiKey("fake-codex-key");

// Perform full logout
await logout({ policy: "full", address: walletAddress });

// Assert cloud disconnected
expect(existsSync(tokenFile("google"))).toBe(false);

// Assert target wallet session files are wiped
expect(existsSync(join(sessionWalletDir, "history.bin"))).toBe(false);
expect(existsSync(sessionWalletDir)).toBe(false);

// Assert other wallet session files are preserved
expect(existsSync(join(otherSessionWalletDir, "history.bin"))).toBe(true);

// Assert Codex API key is wiped
expect(await getCodexApiKey()).toBeNull();
});

it("calls KeyPolicy clear under soft and full logout", async () => {
// Setup mock KeyVault
const mockVault: KeyVault = {
read: vi.fn(),
write: vi.fn(),
remove: vi.fn(),
};

const policy = persistedKey(mockVault);
const walletAddress = "5ey2ja1KstPwMQRx7EokG2dfJksAGGPA";

// Test soft logout
await logout({ policy: "soft", keyPolicy: policy });
// Soft logout calls clear() without address, which clears in-memory but doesn't call vault.remove
expect(mockVault.remove).not.toHaveBeenCalled();

// Test full logout
await logout({ policy: "full", address: walletAddress, keyPolicy: policy });
// Full logout should call clear(address) which invokes vault.remove(address)
expect(mockVault.remove).toHaveBeenCalledWith(walletAddress);
});
});

import { readFileSync } from "node:fs";
function readFileSyncText(path: string): string {
return readFileSync(path, "utf8");
}
41 changes: 38 additions & 3 deletions packages/core/src/account/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
// live separately (oauth.ts → tokens/google.json). Our server stores nothing.

import { readFile, writeFile, rm } from "node:fs/promises";
import { configFile, tokenFile, rootDir, ensureDir } from "../core/paths.js";
import { join } from "node:path";
import { configFile, tokenFile, rootDir, ensureDir, sessionsDir } from "../core/paths.js";
import { deleteCodexApiKey } from "./codexAuth.js";
import type { KeyPolicy, KeyVault } from "./keyPolicy.js";
import { buildStorage, type StorageConfig, type StorageKind } from "./storage/adapter.js";
import { manualStorage, migrateLocalSessions } from "./storage/manual.js";
import { mirrorStorage, type CloudStatus } from "./storage/mirror.js";
Expand Down Expand Up @@ -173,8 +176,40 @@ export async function disconnectCloud(): Promise<void> {
}
}

/** @deprecated use disconnectCloud — kept for callers still importing logout. */
export const logout = disconnectCloud;
/** "soft": drop in-memory state + cloud binding (default). "full": soft + wipe this wallet's local data. */
export type LogoutPolicy = "soft" | "full";

/**
* Unified logout. Soft = forget cloud binding + in-memory session key. Full = soft +
* delete this wallet's local session logs and cached Codex API key.
*
* Always KEEP: keypair file, installed skills, claude/codex own home dirs, cli-map.
* Pass `address` when policy is "full" (the wallet's base58 address).
* Pass `keyPolicy` to also clear its cached session key (and vault if address is provided).
*/
export async function logout(opts: {
policy?: LogoutPolicy;
address?: string;
keyPolicy?: KeyPolicy;
} = {}): Promise<void> {
const { policy = "soft", address, keyPolicy } = opts;

await disconnectCloud();

if (policy === "full") {
if (keyPolicy) {
await keyPolicy.clear(address);
}
if (address) {
await rm(join(sessionsDir(), address), { recursive: true, force: true });
}
await deleteCodexApiKey();
} else {
if (keyPolicy) {
await keyPolicy.clear();
}
}
}

/** Save Google OAuth app credentials without touching the storage kind. */
export async function saveGoogleCreds(clientId: string, clientSecret: string): Promise<void> {
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/account/transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { PublicKey, type Transaction, type VersionedTransaction } from "@solana/web3.js";
import type { Wallet } from "../runtime/contract.js";

export interface WalletTransport {
connect(): Promise<{ uri: string; approved: Promise<{ address: string }> }>; // uri → QR
signMessage(msg: Uint8Array): Promise<Uint8Array>;
signTransaction<T>(tx: T): Promise<T>;
disconnect(): Promise<void>;
}

export function remoteWallet(t: WalletTransport, address: string): Wallet {
return {
address,
publicKey: new PublicKey(address),
async signMessage(msg: Uint8Array): Promise<Uint8Array> {
return t.signMessage(msg);
},
async signTransaction<T extends Transaction | VersionedTransaction>(tx: T): Promise<T> {
return t.signTransaction(tx);
},
async signAllTransactions<T extends Transaction | VersionedTransaction>(txs: T[]): Promise<T[]> {
const results: T[] = [];
for (const tx of txs) {
results.push(await t.signTransaction(tx));
}
return results;
},
};
}
20 changes: 17 additions & 3 deletions packages/core/src/account/webWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ export function pubkeyToAddress(pubkey: Uint8Array): string {

// address: base58 from the connected wallet (provider.publicKey.toString()).
// sessionKeySig: the wallet's signature over SESSION_KEY_MESSAGE's bytes.
export function webWallet(address: string, sessionKeySig: Uint8Array): Wallet {
export function webWallet(
address: string,
sessionKeySig: Uint8Array,
signTransaction?: <T extends Transaction | VersionedTransaction>(tx: T) => Promise<T>,
signAllTransactions?: <T extends Transaction | VersionedTransaction>(txs: T[]) => Promise<T[]>,
): Wallet {
const expected = new TextEncoder().encode(SESSION_KEY_MESSAGE);
const sameBytes = (a: Uint8Array, b: Uint8Array) =>
a.length === b.length && a.every((v, i) => v === b[i]);
Expand All @@ -50,10 +55,19 @@ export function webWallet(address: string, sessionKeySig: Uint8Array): Wallet {
}
return sessionKeySig;
},
async signTransaction<T extends Transaction | VersionedTransaction>(_tx: T): Promise<T> {
async signTransaction<T extends Transaction | VersionedTransaction>(tx: T): Promise<T> {
if (signTransaction) return signTransaction(tx);
throw new Error("on-chain signing not wired through the web wallet yet (Track 2).");
},
async signAllTransactions<T extends Transaction | VersionedTransaction>(_txs: T[]): Promise<T[]> {
async signAllTransactions<T extends Transaction | VersionedTransaction>(txs: T[]): Promise<T[]> {
if (signAllTransactions) return signAllTransactions(txs);
if (signTransaction) {
const results: T[] = [];
for (const tx of txs) {
results.push(await signTransaction(tx));
}
return results;
}
throw new Error("on-chain signing not wired through the web wallet yet (Track 2).");
},
};
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/chat/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface ChatEnv {
connectCloud?(cfg: { kind: string; location?: string; authHeader?: string }): Promise<void>;
disconnectCloud?(): Promise<void>;
disconnectWallet?(): Promise<void>;
logoutFull?(): Promise<void>;
openCloud?(kind: string, location?: string): Promise<void>;
walletAddress(): string | null; // for the "My Wallet" view
storageInfo(): Promise<{ info: unknown; options: unknown }>; // header storage pill
Expand Down Expand Up @@ -291,6 +292,7 @@ export function createChatSession(
case "connectCloud": await env.connectCloud?.({ kind: m.kind, location: m.location, authHeader: m.authHeader }); await pushStorage(); await pushSessions(); break;
case "disconnectCloud": await env.disconnectCloud?.(); await pushStorage(); await pushSessions(); break;
case "disconnectWallet": await env.disconnectWallet?.(); break;
case "logoutFull": await env.logoutFull?.(); break;
case "openCloud": await env.openCloud?.(m.kind, m.location); break;
case "wallet": transport.send({ type: "wallet", address: env.walletAddress() }); break;
// ── marketplace: search → buy → install (delegated to the host) ──
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/chat/ui/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@ export function onboardingHtml(): string {
<input id="walletPath" spellcheck="false" placeholder="/path/to/id.json" />
<div class="hint" id="walletHint">If no keypair exists here, a new one is created at this path.</div>
<button class="primary" id="connectBtn">Use this wallet</button>

<div style="margin-top: 10px; display: flex; flex-direction: column; align-items: center;">
<div style="width: 100%; height: 1px; background: var(--vscode-panel-border); margin: 12px 0;"></div>
<button class="ghost" id="qrLoginBtn" style="margin-top: 0;">QR to login (Phone wallet)</button>
</div>

<div id="qrContainer" style="display: none; flex-direction: column; align-items: center; margin-top: 15px;">
<div style="font-size: 0.9em; font-weight: bold; margin-bottom: 8px; text-align: center; color: var(--vscode-foreground);">Scan with Phantom / Solflare</div>
<img id="qrCodeImg" style="width: 200px; height: 200px; padding: 10px; background: white; border-radius: 8px;" />
<div style="font-size: 0.85em; opacity: 0.7; margin-top: 8px; text-align: center; color: var(--vscode-foreground);">Waiting for approval on your phone...</div>
</div>

<div class="note">
This keypair is your wallet. The same wallet = the same key that decrypts
your sessions on any device.
Expand Down Expand Up @@ -265,6 +277,9 @@ export function onboardingHtml(): string {
vscode.postMessage({ type: 'connectWallet', path: p });
});
if (!WEB) $('walletPath').addEventListener('input', () => $('walletPath').classList.remove('bad'));
if (!WEB) $('qrLoginBtn').addEventListener('click', () => {
vscode.postMessage({ type: 'startQrLogin' });
});

function pickOption(el, kind) {
chosenKind = kind;
Expand Down Expand Up @@ -303,6 +318,13 @@ export function onboardingHtml(): string {
if (m.type === 'init') {
if (m.defaultPath) $('walletPath').value = m.defaultPath;
cloudPreselect = m.cloudKind || null; // e.g. "gdrive" if already connected
} else if (m.type === 'showQr') {
$('walletPath').style.display = 'none';
$('connectBtn').style.display = 'none';
$('walletHint').style.display = 'none';
$('qrLoginBtn').style.display = 'none';
$('qrContainer').style.display = 'flex';
$('qrCodeImg').src = m.qrImage;
} else if (m.type === 'walletConnected') {
// Browser/mobile: the wallet is the whole onboarding — local save is always on
// and a cloud can be added later from chat, so go straight to chat (this socket
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ export type { WalletFileState, LoadResult } from "./account/localWallet.js";
// front-end's signature over the fixed session-key message — wallet-agnostic, the
// front-end picks the provider. Local surfaces use localWallet; web uses this.
export { webWallet, SESSION_KEY_MESSAGE } from "./account/webWallet.js";
export { remoteWallet } from "./account/transport.js";
export type { WalletTransport } from "./account/transport.js";
export {
startClaudeLogin,
isClaudeLoggedIn,
Expand Down Expand Up @@ -116,6 +118,7 @@ export {
saveGoogleCreds,
hasGoogleCreds,
} from "./account/login.js";
export type { LogoutPolicy } from "./account/login.js";
export { STORAGE_OPTIONS } from "./account/storage/adapter.js";
export type { StorageConfig, StorageKind } from "./account/storage/adapter.js";
export { manualStorage } from "./account/storage/manual.js";
Expand Down
29 changes: 29 additions & 0 deletions packages/wallet-connect/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@iqlabs-official/wallet-connect",
"version": "0.0.1",
"type": "module",
"private": true,
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts",
"./*.js": "./src/*.ts"
},
"scripts": {
"build": "tsup src/index.ts"
},
"dependencies": {
"@walletconnect/sign-client": "^2.13.0",
"@walletconnect/utils": "^2.13.0",
"qrcode": "^1.5.3",
"bs58": "^4.0.1"
},
"devDependencies": {
"@iqlabs-official/agent-sdk": "workspace:*",
"@solana/web3.js": "^1.98.0",
"@types/node": "^20.0.0",
"@types/qrcode": "^1.5.5",
"tsup": "^8.0.0",
"typescript": "^5.6.0"
}
}
Loading