diff --git a/apps/desktop/.env.example b/apps/desktop/.env.example index 18f56e21..d6547f7e 100644 --- a/apps/desktop/.env.example +++ b/apps/desktop/.env.example @@ -2,31 +2,73 @@ # Multica Desktop Environment Configuration # ============================================================================= # -# Local Development: -# Just run `pnpm dev` - no .env file needed (uses defaults) +# This file documents all available environment variables for Desktop builds. +# Copy this file and rename it based on your target environment. +# +# ============================================================================= +# Build Commands +# ============================================================================= +# +# Development (no .env file needed): +# pnpm dev # Uses hardcoded dev defaults # # Staging Build: -# `pnpm build:staging` uses .env.staging +# pnpm build:staging # Uses .env.staging # # Production Build: -# `pnpm build` uses .env.production +# pnpm build:production # Uses .env.production +# +# Default Build (uses .env.production): +# pnpm build:desktop # Same as build:production +# +# ============================================================================= +# Build Output +# ============================================================================= +# +# After build completes, installer packages are located at: +# +# apps/desktop/release/{version}/ +# ├── Multica-{version}-arm64.dmg # macOS Apple Silicon +# ├── Multica-{version}-x64.dmg # macOS Intel +# ├── Multica-Windows-{version}-Setup.exe # Windows +# ├── Multica-Linux-{version}.AppImage # Linux AppImage +# └── Multica-Linux-{version}.deb # Linux deb +# +# Compiled code (before packaging) is in: +# apps/desktop/out/ +# +# ============================================================================= +# Configuration Guide +# ============================================================================= +# +# For production builds, create .env.production with the following variables: +# +# Required: +# MAIN_VITE_GATEWAY_URL - WebSocket Gateway URL for remote connections +# MAIN_VITE_WEB_URL - Web App URL for OAuth login flow +# +# Example .env.production: +# MAIN_VITE_GATEWAY_URL=https://gateway.multica.ai +# MAIN_VITE_WEB_URL=https://www.multica.ai +# +# ============================================================================= +# Variable Naming Convention +# ============================================================================= # -# Variable naming convention: # MAIN_VITE_* - Main process only (Node.js, full system access) # RENDERER_VITE_* - Renderer process only (browser context) # VITE_* - All processes # # ============================================================================= -# URL Configuration -# ============================================================================= -# -# MAIN_VITE_GATEWAY_URL - WebSocket Gateway -# Hub connects to Gateway for remote device access (QR code pairing) -# -# MAIN_VITE_WEB_URL - Web App URL -# Desktop opens this URL for user login (OAuth flow) -# +# Environment Variables # ============================================================================= +# MAIN_VITE_GATEWAY_URL +# WebSocket Gateway URL - Hub connects here for remote device access (QR code pairing) +# Production example: https://gateway.multica.ai MAIN_VITE_GATEWAY_URL=http://localhost:3000 + +# MAIN_VITE_WEB_URL +# Web App URL - Desktop opens this URL for user login (OAuth flow) +# Production example: https://www.multica.ai MAIN_VITE_WEB_URL=http://localhost:3000 diff --git a/apps/desktop/src/main/ipc/auth.ts b/apps/desktop/src/main/ipc/auth.ts index 200e6edd..8449295d 100644 --- a/apps/desktop/src/main/ipc/auth.ts +++ b/apps/desktop/src/main/ipc/auth.ts @@ -9,7 +9,6 @@ */ import http from "node:http"; -import crypto from "node:crypto"; import { ipcMain, shell, BrowserWindow } from "electron"; import { existsSync, @@ -18,7 +17,7 @@ import { mkdirSync, } from "node:fs"; import { join, dirname } from "node:path"; -import { DATA_DIR } from "@multica/utils"; +import { DATA_DIR, generateEncryptedId, isValidEncryptedId } from "@multica/utils"; import type { AuthUser } from "@multica/types"; // ============================================================================ @@ -46,37 +45,6 @@ interface AuthFileData { const AUTH_FILE_PATH = join(DATA_DIR, "auth.json"); -/** - * SHA-256 hash function. - */ -function sha256(text: string): string { - return crypto.createHash("sha256").update(text, "utf8").digest("hex"); -} - -/** - * Generate encrypted Device ID. - * Algorithm (consistent with devv-sdk and Web): - * 1. Generate UUID - * 2. SHA-256 hash of UUID, take first 32 chars - * 3. SHA-256 hash of step 2 result, take first 8 chars - * 4. Return: step3[0:8] + step2[0:32] = 40 chars - * - * This encrypted format is stored directly (not the raw UUID). - */ -function generateEncryptedDeviceId(): string { - const uuid = crypto.randomUUID(); - const firstHash = sha256(uuid).slice(0, 32); - const finalId = sha256(firstHash).slice(0, 8) + firstHash; - return finalId; -} - -/** - * Validate device ID format (40 hex characters). - */ -function isValidDeviceId(deviceId: string): boolean { - return typeof deviceId === "string" && /^[a-f0-9]{40}$/i.test(deviceId); -} - /** * Read raw auth file data, handling all edge cases. * Returns null if file doesn't exist or is invalid. @@ -123,32 +91,26 @@ function writeAuthFile(data: Partial): boolean { /** * Get or create a persistent Device ID. * Device ID persists across logins/logouts - it represents the device, not the user. - * The stored value is already encrypted (40 hex chars), not the raw UUID. + * The stored value is encrypted (40 hex chars). */ export function getOrCreateDeviceId(): string { const existing = readAuthFile(); - // If we have a valid encrypted deviceId (40 hex chars), return it - if (existing?.deviceId && isValidDeviceId(existing.deviceId)) { + // If we have a valid encrypted deviceId, return it + if (existing?.deviceId && isValidEncryptedId(existing.deviceId)) { return existing.deviceId; } // Generate new encrypted deviceId - const newDeviceId = generateEncryptedDeviceId(); + const newDeviceId = generateEncryptedId(); console.log("[Auth] Generated new Device ID:", newDeviceId.slice(0, 8) + "..."); - // If there was an old-format deviceId (UUID), we'll replace it - if (existing?.deviceId && !isValidDeviceId(existing.deviceId)) { - console.log("[Auth] Migrating old-format Device ID to encrypted format"); - } - - // Preserve any existing auth data while adding/updating deviceId + // Preserve any existing auth data while adding deviceId const dataToSave: Partial = existing ? { ...existing, deviceId: newDeviceId } : { deviceId: newDeviceId }; if (!writeAuthFile(dataToSave)) { - // Write failed, but we can still return the generated ID for this session console.error("[Auth] Failed to persist new Device ID"); } @@ -189,7 +151,7 @@ function saveAuthData(sid: string, user: AuthUser, passedDeviceId?: string): boo try { // Use passed deviceId from Web if valid, otherwise use local one let deviceId: string; - if (passedDeviceId && isValidDeviceId(passedDeviceId)) { + if (passedDeviceId && isValidEncryptedId(passedDeviceId)) { deviceId = passedDeviceId; console.log("[Auth] Using Device ID from Web browser:", deviceId.slice(0, 8) + "..."); } else { diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 73213be9..dc5cf23c 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -5,7 +5,6 @@ import { TooltipProvider } from '@multica/ui/components/ui/tooltip' import { Toaster } from './components/toaster' import Layout from './pages/layout' import HomePage from './pages/home' -import ChatPage from './pages/chat' import ProfilePage from './pages/agent/profile' import SkillsPage from './pages/agent/skills' import ToolsPage from './pages/agent/tools' @@ -73,7 +72,7 @@ const router = createHashRouter([ ), }, - { path: 'chat', element: }, + { path: 'chat', element: null }, { path: 'agent/profile', element: }, { path: 'agent/skills', element: }, { path: 'agent/tools', element: }, diff --git a/apps/desktop/src/renderer/src/components/local-chat.tsx b/apps/desktop/src/renderer/src/components/local-chat.tsx index 39c6c0b3..0f842a05 100644 --- a/apps/desktop/src/renderer/src/components/local-chat.tsx +++ b/apps/desktop/src/renderer/src/components/local-chat.tsx @@ -73,12 +73,13 @@ export function LocalChat({ initialPrompt }: LocalChatProps) { const currentMeta = current ? providers.find((p) => p.id === current.provider) : null // Auto-send initial prompt after a short delay - const hasSentInitialPrompt = useRef(false) + const lastPromptRef = useRef(undefined) useEffect(() => { - if (!agentId || !initialPrompt || hasSentInitialPrompt.current) return + if (!agentId || !initialPrompt) return + if (initialPrompt === lastPromptRef.current) return const timer = setTimeout(() => { - hasSentInitialPrompt.current = true + lastPromptRef.current = initialPrompt sendMessage(initialPrompt) // Remove prompt from URL to prevent re-sending on back navigation navigate('/chat', { replace: true }) diff --git a/apps/desktop/src/renderer/src/pages/layout.tsx b/apps/desktop/src/renderer/src/pages/layout.tsx index 1651e447..476f61c7 100644 --- a/apps/desktop/src/renderer/src/pages/layout.tsx +++ b/apps/desktop/src/renderer/src/pages/layout.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from 'react' import { Outlet, NavLink, useLocation, useNavigate } from 'react-router-dom' import { Button } from '@multica/ui/components/ui/button' import { MulticaIcon } from '@multica/ui/components/multica-icon' @@ -43,6 +44,7 @@ import { } from '@multica/ui/components/ui/sidebar' import { cn } from '@multica/ui/lib/utils' import { ModeToggle } from '../components/mode-toggle' +import { LocalChat } from '../components/local-chat' import { DeviceConfirmDialog } from '../components/device-confirm-dialog' import { UpdateNotification } from '../components/update-notification' import { useAuthStore } from '../stores/auth' @@ -151,8 +153,20 @@ export default function Layout() { const location = useLocation() const navigate = useNavigate() const isAgentActive = location.pathname.startsWith('/agent') + const isOnChat = location.pathname === '/chat' const { user, clearAuth } = useAuthStore() + // Lazy mount: only mount Chat on first visit, then keep it mounted forever + const [chatMounted, setChatMounted] = useState(false) + useEffect(() => { + if (isOnChat && !chatMounted) setChatMounted(true) + }, [isOnChat, chatMounted]) + + // Extract initialPrompt from URL search params when navigating to /chat?prompt=... + const initialPrompt = isOnChat + ? new URLSearchParams(location.search).get('prompt') ?? undefined + : undefined + const handleLogout = async () => { await clearAuth() navigate('/login') @@ -285,7 +299,14 @@ export default function Layout() {
- +
+ +
+ {chatMounted && ( +
+ +
+ )}
diff --git a/apps/desktop/src/renderer/src/pages/login.tsx b/apps/desktop/src/renderer/src/pages/login.tsx index 3c4bb428..ca13663a 100644 --- a/apps/desktop/src/renderer/src/pages/login.tsx +++ b/apps/desktop/src/renderer/src/pages/login.tsx @@ -23,14 +23,20 @@ export default function LoginPage() { if (isLoading) { return ( -
+
) } return ( -
+
{/* Brand */}
@@ -44,7 +50,12 @@ export default function LoginPage() {

{/* Sign In */} - diff --git a/apps/gateway/telegram/telegram-user.store.ts b/apps/gateway/telegram/telegram-user.store.ts index ba2e807f..c65afb83 100644 --- a/apps/gateway/telegram/telegram-user.store.ts +++ b/apps/gateway/telegram/telegram-user.store.ts @@ -3,7 +3,7 @@ */ import { Inject, Injectable, Logger } from "@nestjs/common"; -import { v7 as uuidv7 } from "uuid"; +import { generateEncryptedId } from "@multica/utils"; import type { RowDataPacket } from "mysql2/promise"; import { DatabaseService } from "../database/database.service.js"; import type { TelegramUser, TelegramUserCreate } from "./types.js"; @@ -88,7 +88,7 @@ export class TelegramUserStore { } // Create new user with provided or generated device ID - const deviceId = data.deviceId ?? `tg-${uuidv7()}`; + const deviceId = data.deviceId ?? `tg-${generateEncryptedId()}`; await this.db.execute( `INSERT INTO telegram_users ( diff --git a/apps/gateway/telegram/telegram.service.ts b/apps/gateway/telegram/telegram.service.ts index eace14ea..987da5f8 100644 --- a/apps/gateway/telegram/telegram.service.ts +++ b/apps/gateway/telegram/telegram.service.ts @@ -12,6 +12,7 @@ import type { OnModuleInit } from "@nestjs/common"; import { Bot, InputFile, webhookCallback } from "grammy"; import type { Context } from "grammy"; import { v7 as uuidv7 } from "uuid"; +import { generateEncryptedId } from "@multica/utils"; import { parseConnectionCode } from "@multica/store/connection"; import type { ConnectionInfo } from "@multica/store/connection"; import { @@ -233,7 +234,7 @@ export class TelegramService implements OnModuleInit { } // 4. Generate device ID and register virtual device - const deviceId = `tg-${uuidv7()}`; + const deviceId = `tg-${generateEncryptedId()}`; this.registerVirtualDeviceForUser(deviceId, telegramUserId); // 5. Send verify RPC diff --git a/apps/web/app/login/login-form.tsx b/apps/web/app/login/login-form.tsx index e8a016f6..80c94e69 100644 --- a/apps/web/app/login/login-form.tsx +++ b/apps/web/app/login/login-form.tsx @@ -10,7 +10,7 @@ import { MulticaIcon } from '@multica/ui/components/multica-icon' import { LoginAuthType, UserInfo } from '@/lib/interface' import { saveSession, isAuthenticated } from '@/lib/auth' import { userLogin } from '@/service/user' -import { getOrCreateDeviceId, generateDeviceIdHeader } from '@/lib/device' +import { getOrCreateDeviceId } from '@/lib/device' type LoginStep = 'email' | 'code' @@ -115,9 +115,8 @@ export function LoginForm() { const port = nextUrl.searchParams.get('port') const platform = nextUrl.searchParams.get('platform') || 'web' - // Get Device ID and encrypt for Desktop - const rawDeviceId = getOrCreateDeviceId() - const deviceId = await generateDeviceIdHeader(rawDeviceId) + // Get Device ID (already encrypted 40-char format) + const deviceId = await getOrCreateDeviceId() const params = new URLSearchParams({ sid, diff --git a/apps/web/lib/device.ts b/apps/web/lib/device.ts index 299179a0..575cef58 100644 --- a/apps/web/lib/device.ts +++ b/apps/web/lib/device.ts @@ -1,6 +1,6 @@ /** * Device ID management for Multica Web - * Consistent with copilot-search: stores raw UUID, encrypts when transmitting + * Stores encrypted format directly (40 hex chars) */ const DEVICE_ID_KEY = 'MULTICA_DEVICE_ID' @@ -13,30 +13,43 @@ async function sha256(text: string): Promise { return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') } +// Generate encrypted device ID (40 hex chars) +async function generateEncryptedDeviceId(): Promise { + const uuid = crypto.randomUUID() + const firstHash = (await sha256(uuid)).slice(0, 32) + return (await sha256(firstHash)).slice(0, 8) + firstHash +} + +// Validate encrypted ID format (40 hex characters) +function isValidEncryptedId(id: string): boolean { + return typeof id === 'string' && /^[a-f0-9]{40}$/i.test(id) +} + +// Cached promise for async generation +let deviceIdPromise: Promise | null = null + /** - * Get or create Device ID (raw UUID format) - * Stored in localStorage, encrypted only when transmitting + * Get or create Device ID (encrypted 40-char format) + * Stored in localStorage, ready to use directly */ -export function getOrCreateDeviceId(): string { +export async function getOrCreateDeviceId(): Promise { if (typeof window === 'undefined') return '' - let deviceId = localStorage.getItem(DEVICE_ID_KEY) + const existing = localStorage.getItem(DEVICE_ID_KEY) - if (!deviceId) { - deviceId = crypto.randomUUID() - localStorage.setItem(DEVICE_ID_KEY, deviceId) + // If already encrypted format, return as-is + if (existing && isValidEncryptedId(existing)) { + return existing } - return deviceId + // Generate new encrypted ID + if (!deviceIdPromise) { + deviceIdPromise = generateEncryptedDeviceId().then((id) => { + localStorage.setItem(DEVICE_ID_KEY, id) + return id + }) + } + + return deviceIdPromise } -/** - * Generate encrypted Device-Id header value - * Algorithm (consistent with copilot-search): - * 1. sha256(uuid).slice(0, 32) = hashedDeviceId - * 2. sha256(hashedDeviceId).slice(0, 8) + hashedDeviceId = 40 chars - */ -export async function generateDeviceIdHeader(deviceId: string): Promise { - const hashedDeviceId = (await sha256(deviceId)).slice(0, 32) - return (await sha256(hashedDeviceId)).slice(0, 8) + hashedDeviceId -} diff --git a/apps/web/service/request.ts b/apps/web/service/request.ts index 9c97b5c1..3f731870 100644 --- a/apps/web/service/request.ts +++ b/apps/web/service/request.ts @@ -1,15 +1,14 @@ import { API_HOST } from '@/lib/constant'; -import { getOrCreateDeviceId, generateDeviceIdHeader } from '@/lib/device'; +import { getOrCreateDeviceId } from '@/lib/device'; import { getSid } from '@/lib/auth'; // Fetch request wrapper export async function request(url: string, options: RequestInit = {}): Promise { - // Get or generate Device ID, encrypt for header - let deviceIdHeader = ''; + // Get or generate Device ID (already encrypted 40-char format) + let deviceId = ''; let sid: string | null = null; if (typeof window !== 'undefined') { - const deviceId = getOrCreateDeviceId(); - deviceIdHeader = await generateDeviceIdHeader(deviceId); + deviceId = await getOrCreateDeviceId(); sid = getSid(); } @@ -18,7 +17,7 @@ export async function request(url: string, options: RequestInit = { headers: { 'Content-Type': 'application/json', 'os-type': '3', - ...(deviceIdHeader && { 'Device-Id': deviceIdHeader }), + ...(deviceId && { 'Device-Id': deviceId }), ...(sid && { 'Authorization': `Bearer ${sid}` }), ...options.headers, }, diff --git a/packages/core/src/agent/session/storage.test.ts b/packages/core/src/agent/session/storage.test.ts index 6f9498c9..37c88bef 100644 --- a/packages/core/src/agent/session/storage.test.ts +++ b/packages/core/src/agent/session/storage.test.ts @@ -6,6 +6,7 @@ import { resolveBaseDir, resolveSessionDir, resolveSessionPath, + resolveMediaDir, ensureSessionDir, readEntries, appendEntry, @@ -274,4 +275,245 @@ describe("session/storage", () => { expect(readFileSync(filePath, "utf8")).toBe(""); }); }); + + describe("image externalization", () => { + // Generate a large base64 string (>43KB to exceed MIN_EXTERNALIZE_B64_LENGTH) + const largeBinarySize = 50_000; // 50KB binary + const largeBuffer = Buffer.alloc(largeBinarySize, 0x42); // fill with 'B' + const largeBase64 = largeBuffer.toString("base64"); + + // Small base64 that should stay inline + const smallBase64 = Buffer.alloc(100, 0x41).toString("base64"); + + function makeImageEntry(imageData: string, sessionId = "img-session"): SessionEntry { + return { + type: "message", + message: { + role: "user", + content: [ + { type: "text", text: "Read image file [image/png]" }, + { type: "image", data: imageData }, + ], + } as any, + timestamp: Date.now(), + }; + } + + function makeFormatBEntry(imageData: string): SessionEntry { + return { + type: "message", + message: { + role: "user", + content: [ + { type: "image", source: { type: "base64", data: imageData } }, + ], + } as any, + timestamp: Date.now(), + }; + } + + function makeToolResultEntry(imageData: string): SessionEntry { + return { + type: "message", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "test-id", + content: [ + { type: "text", text: "Read image file [image/png]" }, + { type: "image", data: imageData }, + ], + }, + ], + } as any, + timestamp: Date.now(), + }; + } + + it("should externalize Format A image and create media file", async () => { + const sessionId = "ext-format-a"; + const entry = makeImageEntry(largeBase64); + + await appendEntry(sessionId, entry, { baseDir: testBaseDir }); + + // Read raw JSONL — should have $ref, not data + const rawContent = readFileSync(join(testBaseDir, sessionId, "session.jsonl"), "utf8"); + const rawEntry = JSON.parse(rawContent.trim()); + expect(rawEntry.message.content[1].$ref).toMatch(/^media\/[a-f0-9]+\.bin$/); + expect(rawEntry.message.content[1].data).toBeUndefined(); + + // Media file should exist + const mediaDir = resolveMediaDir(sessionId, { baseDir: testBaseDir }); + const files = existsSync(mediaDir) + ? require("node:fs").readdirSync(mediaDir) as string[] + : []; + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/^[a-f0-9]+\.bin$/); + + // Binary content should match original + const binPath = join(mediaDir, files[0]!); + const savedBuffer = readFileSync(binPath); + expect(savedBuffer).toEqual(largeBuffer); + }); + + it("should externalize Format B image (Anthropic source style)", async () => { + const sessionId = "ext-format-b"; + const entry = makeFormatBEntry(largeBase64); + + await appendEntry(sessionId, entry, { baseDir: testBaseDir }); + + const rawContent = readFileSync(join(testBaseDir, sessionId, "session.jsonl"), "utf8"); + const rawEntry = JSON.parse(rawContent.trim()); + expect(rawEntry.message.content[0].source.type).toBe("$ref"); + expect(rawEntry.message.content[0].source.path).toMatch(/^media\/[a-f0-9]+\.bin$/); + }); + + it("should restore externalized images on read (round-trip)", async () => { + const sessionId = "ext-roundtrip"; + const entry = makeImageEntry(largeBase64); + + await appendEntry(sessionId, entry, { baseDir: testBaseDir }); + + const entries = readEntries(sessionId, { baseDir: testBaseDir }); + expect(entries).toHaveLength(1); + const content = (entries[0] as any).message.content; + expect(content[1].type).toBe("image"); + expect(content[1].data).toBe(largeBase64); + expect(content[1].$ref).toBeUndefined(); + }); + + it("should restore Format B images on read", async () => { + const sessionId = "ext-roundtrip-b"; + const entry = makeFormatBEntry(largeBase64); + + await appendEntry(sessionId, entry, { baseDir: testBaseDir }); + + const entries = readEntries(sessionId, { baseDir: testBaseDir }); + expect(entries).toHaveLength(1); + const block = (entries[0] as any).message.content[0]; + expect(block.source.type).toBe("base64"); + expect(block.source.data).toBe(largeBase64); + }); + + it("should handle old sessions with inline base64 (backward compat)", () => { + const sessionId = "old-inline"; + const dir = join(testBaseDir, sessionId); + mkdirSync(dir, { recursive: true }); + + // Write raw JSONL with inline base64 (old format, no $ref) + const entry = makeImageEntry(largeBase64); + writeFileSync(join(dir, "session.jsonl"), `${JSON.stringify(entry)}\n`); + + const entries = readEntries(sessionId, { baseDir: testBaseDir }); + expect(entries).toHaveLength(1); + const content = (entries[0] as any).message.content; + expect(content[1].data).toBe(largeBase64); + }); + + it("should return placeholder for missing media file", () => { + const sessionId = "missing-media"; + const dir = join(testBaseDir, sessionId); + mkdirSync(dir, { recursive: true }); + + // Write JSONL with $ref but no media file + const rawEntry = { + type: "message", + message: { + role: "user", + content: [ + { type: "image", $ref: "media/deadbeef.bin" }, + ], + }, + timestamp: Date.now(), + }; + writeFileSync(join(dir, "session.jsonl"), `${JSON.stringify(rawEntry)}\n`); + + const entries = readEntries(sessionId, { baseDir: testBaseDir }); + expect(entries).toHaveLength(1); + const block = (entries[0] as any).message.content[0]; + expect(block.type).toBe("text"); + expect(block.text).toContain("unavailable"); + }); + + it("should deduplicate same image data", async () => { + const sessionId = "ext-dedup"; + const entry1 = makeImageEntry(largeBase64); + const entry2 = makeImageEntry(largeBase64); + + await appendEntry(sessionId, entry1, { baseDir: testBaseDir }); + await appendEntry(sessionId, entry2, { baseDir: testBaseDir }); + + const mediaDir = resolveMediaDir(sessionId, { baseDir: testBaseDir }); + const files = require("node:fs").readdirSync(mediaDir) as string[]; + expect(files).toHaveLength(1); // same hash = same file + }); + + it("should keep small images inline", async () => { + const sessionId = "ext-small"; + const entry = makeImageEntry(smallBase64); + + await appendEntry(sessionId, entry, { baseDir: testBaseDir }); + + // Read raw JSONL — small image should still have data, not $ref + const rawContent = readFileSync(join(testBaseDir, sessionId, "session.jsonl"), "utf8"); + const rawEntry = JSON.parse(rawContent.trim()); + expect(rawEntry.message.content[1].data).toBe(smallBase64); + expect(rawEntry.message.content[1].$ref).toBeUndefined(); + + // No media dir should be created + const mediaDir = resolveMediaDir(sessionId, { baseDir: testBaseDir }); + expect(existsSync(mediaDir)).toBe(false); + }); + + it("should not affect non-image entries", async () => { + const sessionId = "ext-noimg"; + const entry: SessionEntry = { + type: "message", + message: { role: "assistant", content: "Just text response" } as any, + timestamp: 1000, + }; + + await appendEntry(sessionId, entry, { baseDir: testBaseDir }); + + const rawContent = readFileSync(join(testBaseDir, sessionId, "session.jsonl"), "utf8"); + expect(rawContent.trim()).toBe(JSON.stringify(entry)); + }); + + it("should handle images inside nested tool_result content", async () => { + const sessionId = "ext-tool-result"; + const entry = makeToolResultEntry(largeBase64); + + await appendEntry(sessionId, entry, { baseDir: testBaseDir }); + + // Raw JSONL should have $ref inside tool_result + const rawContent = readFileSync(join(testBaseDir, sessionId, "session.jsonl"), "utf8"); + const rawEntry = JSON.parse(rawContent.trim()); + const toolResult = rawEntry.message.content[0]; + expect(toolResult.content[1].$ref).toMatch(/^media\/[a-f0-9]+\.bin$/); + + // Round-trip should restore + const entries = readEntries(sessionId, { baseDir: testBaseDir }); + const restored = (entries[0] as any).message.content[0].content[1]; + expect(restored.data).toBe(largeBase64); + expect(restored.$ref).toBeUndefined(); + }); + + it("should externalize via writeEntries (compaction path)", async () => { + const sessionId = "ext-write-entries"; + const entry = makeImageEntry(largeBase64); + + await writeEntries(sessionId, [entry], { baseDir: testBaseDir }); + + // Should be externalized + const rawContent = readFileSync(join(testBaseDir, sessionId, "session.jsonl"), "utf8"); + const rawEntry = JSON.parse(rawContent.trim()); + expect(rawEntry.message.content[1].$ref).toMatch(/^media\/[a-f0-9]+\.bin$/); + + // Round-trip + const entries = readEntries(sessionId, { baseDir: testBaseDir }); + expect((entries[0] as any).message.content[1].data).toBe(largeBase64); + }); + }); }); diff --git a/packages/core/src/agent/session/storage.ts b/packages/core/src/agent/session/storage.ts index 75ff9239..51c8245c 100644 --- a/packages/core/src/agent/session/storage.ts +++ b/packages/core/src/agent/session/storage.ts @@ -1,6 +1,7 @@ import { join } from "path"; -import { existsSync, mkdirSync, readFileSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { appendFile, writeFile } from "fs/promises"; +import { createHash } from "node:crypto"; import type { SessionEntry } from "./types.js"; import { DATA_DIR } from "@multica/utils"; import { acquireSessionWriteLock } from "./session-write-lock.js"; @@ -9,6 +10,9 @@ export type SessionStorageOptions = { baseDir?: string | undefined; }; +/** Minimum base64 data length to externalize (32KB decoded ≈ 43KB base64) */ +const MIN_EXTERNALIZE_B64_LENGTH = 43_000; + export function resolveBaseDir(options?: SessionStorageOptions) { return options?.baseDir ?? join(DATA_DIR, "sessions"); } @@ -21,6 +25,10 @@ export function resolveSessionPath(sessionId: string, options?: SessionStorageOp return join(resolveSessionDir(sessionId, options), "session.jsonl"); } +export function resolveMediaDir(sessionId: string, options?: SessionStorageOptions) { + return join(resolveSessionDir(sessionId, options), "media"); +} + export function ensureSessionDir(sessionId: string, options?: SessionStorageOptions) { const dir = resolveSessionDir(sessionId, options); // mkdirSync with recursive is idempotent (no-op if dir exists), @@ -37,6 +45,200 @@ export function ensureSessionDir(sessionId: string, options?: SessionStorageOpti } } +// ─── Image Externalization ────────────────────────────────────────────────── + +function contentHash(base64Data: string): string { + const buffer = Buffer.from(base64Data, "base64"); + return createHash("sha256").update(buffer).digest("hex").slice(0, 32); +} + +function ensureMediaDir(sessionId: string, options?: SessionStorageOptions): void { + const dir = resolveMediaDir(sessionId, options); + try { + mkdirSync(dir, { recursive: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + mkdirSync(dir, { recursive: true }); + } else { + throw err; + } + } +} + +function saveImageBinary( + sessionId: string, + hash: string, + base64Data: string, + options?: SessionStorageOptions, +): void { + ensureMediaDir(sessionId, options); + const filePath = join(resolveMediaDir(sessionId, options), `${hash}.bin`); + if (existsSync(filePath)) return; // dedup + const buffer = Buffer.from(base64Data, "base64"); + writeFileSync(filePath, buffer); +} + +/** + * Replace a single image content block with an external file reference. + * Returns the original block unchanged if it's not an externalizable image. + */ +function externalizeBlock( + block: any, + sessionId: string, + options?: SessionStorageOptions, +): any { + if (!block || typeof block !== "object" || block.type !== "image") return block; + + // Format A: { type: "image", data: "" } + if (typeof block.data === "string" && block.data.length > MIN_EXTERNALIZE_B64_LENGTH) { + const hash = contentHash(block.data); + const relPath = `media/${hash}.bin`; + saveImageBinary(sessionId, hash, block.data, options); + const { data: _removed, ...rest } = block; + return { ...rest, $ref: relPath }; + } + + // Format B: { type: "image", source: { type: "base64", data: "" } } + if ( + block.source && + typeof block.source === "object" && + block.source.type === "base64" && + typeof block.source.data === "string" && + block.source.data.length > MIN_EXTERNALIZE_B64_LENGTH + ) { + const hash = contentHash(block.source.data); + const relPath = `media/${hash}.bin`; + saveImageBinary(sessionId, hash, block.source.data, options); + return { ...block, source: { type: "$ref", path: relPath } }; + } + + return block; +} + +/** + * Restore an externalized image reference back to inline base64 data. + */ +function internalizeBlock( + block: any, + sessionId: string, + options?: SessionStorageOptions, +): any { + if (!block || typeof block !== "object" || block.type !== "image") return block; + + // Format A ref: { type: "image", $ref: "media/.bin" } + if (typeof block.$ref === "string") { + const filePath = join(resolveSessionDir(sessionId, options), block.$ref); + try { + const buffer = readFileSync(filePath); + const data = buffer.toString("base64"); + const { $ref: _removed, ...rest } = block; + return { ...rest, data }; + } catch { + return { type: "text", text: "[Image unavailable: referenced media file not found]" }; + } + } + + // Format B ref: { type: "image", source: { type: "$ref", path: "media/.bin" } } + if (block.source && typeof block.source === "object" && block.source.type === "$ref") { + const filePath = join(resolveSessionDir(sessionId, options), block.source.path); + try { + const buffer = readFileSync(filePath); + const data = buffer.toString("base64"); + return { ...block, source: { type: "base64", data } }; + } catch { + return { type: "text", text: "[Image unavailable: referenced media file not found]" }; + } + } + + return block; +} + +/** + * Walk content blocks (including nested tool_result.content) and apply a transform. + */ +function transformContentBlocks( + content: any[], + transformBlock: (block: any) => any, +): { content: any[]; changed: boolean } { + let changed = false; + const result: any[] = []; + + for (const block of content) { + // Handle nested tool_result content + if (block && typeof block === "object" && block.type === "tool_result" && Array.isArray(block.content)) { + const inner = transformContentBlocks(block.content, transformBlock); + if (inner.changed) { + changed = true; + result.push({ ...block, content: inner.content }); + } else { + result.push(block); + } + continue; + } + + const transformed = transformBlock(block); + if (transformed !== block) changed = true; + result.push(transformed); + } + + return { content: result, changed }; +} + +/** + * Extract base64 image data from a session entry, save as binary files, + * and replace with file references. + */ +function externalizeImages( + entry: SessionEntry, + sessionId: string, + options?: SessionStorageOptions, +): SessionEntry { + if (entry.type !== "message") return entry; + + const message = entry.message as any; + const content = message.content; + if (!Array.isArray(content)) return entry; + + const result = transformContentBlocks(content, (block) => + externalizeBlock(block, sessionId, options), + ); + + if (!result.changed) return entry; + + return { + ...entry, + message: { ...message, content: result.content }, + } as SessionEntry; +} + +/** + * Resolve external file references in a session entry back to inline base64 data. + */ +function internalizeImages( + entry: SessionEntry, + sessionId: string, + options?: SessionStorageOptions, +): SessionEntry { + if (entry.type !== "message") return entry; + + const message = entry.message as any; + const content = message.content; + if (!Array.isArray(content)) return entry; + + const result = transformContentBlocks(content, (block) => + internalizeBlock(block, sessionId, options), + ); + + if (!result.changed) return entry; + + return { + ...entry, + message: { ...message, content: result.content }, + } as SessionEntry; +} + +// ─── Public API ───────────────────────────────────────────────────────────── + export function readEntries(sessionId: string, options?: SessionStorageOptions): SessionEntry[] { const filePath = resolveSessionPath(sessionId, options); if (!existsSync(filePath)) return []; @@ -45,7 +247,8 @@ export function readEntries(sessionId: string, options?: SessionStorageOptions): const entries: SessionEntry[] = []; for (const line of lines) { try { - entries.push(JSON.parse(line) as SessionEntry); + const raw = JSON.parse(line) as SessionEntry; + entries.push(internalizeImages(raw, sessionId, options)); } catch { // Skip malformed lines } @@ -62,7 +265,8 @@ export async function appendEntry( const filePath = resolveSessionPath(sessionId, options); const lock = await acquireSessionWriteLock({ sessionFile: filePath }); try { - await appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf8"); + const externalized = externalizeImages(entry, sessionId, options); + await appendFile(filePath, `${JSON.stringify(externalized)}\n`, "utf8"); } finally { await lock.release(); } @@ -77,7 +281,9 @@ export async function writeEntries( const filePath = resolveSessionPath(sessionId, options); const lock = await acquireSessionWriteLock({ sessionFile: filePath }); try { - const content = entries.map((entry) => JSON.stringify(entry)).join("\n"); + const content = entries + .map((entry) => JSON.stringify(externalizeImages(entry, sessionId, options))) + .join("\n"); await writeFile(filePath, content ? `${content}\n` : "", "utf8"); } finally { await lock.release(); diff --git a/packages/core/src/agent/tools.ts b/packages/core/src/agent/tools.ts index 551c8245..fd9bf10a 100644 --- a/packages/core/src/agent/tools.ts +++ b/packages/core/src/agent/tools.ts @@ -14,6 +14,7 @@ import { createDataTool } from "./tools/data/index.js"; import { createSendFileTool } from "./tools/send-file.js"; import type { SendFileCallback } from "./tools/send-file.js"; import { filterTools } from "./tools/policy.js"; +import { wrapReadToolWithImageResize } from "./tools/image-resize.js"; import { isMulticaError, isRetryableError } from "@multica/utils"; import type { ExecApprovalCallback } from "./tools/exec-approval-types.js"; @@ -106,9 +107,9 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool< const opts: CreateToolsOptions = typeof options === "string" ? { cwd: options } : options; const { cwd, profileDir, isSubagent, sessionId } = opts; - const baseTools = createCodingTools(cwd).filter( - (tool) => tool.name !== "bash", - ) as AgentTool[]; + const baseTools = createCodingTools(cwd) + .filter((tool) => tool.name !== "bash") + .map((tool) => tool.name === "read" ? wrapReadToolWithImageResize(tool) : tool) as AgentTool[]; const execTool = createExecTool(cwd, opts.onExecApprovalNeeded); const processTool = createProcessTool(cwd); diff --git a/packages/core/src/agent/tools/image-resize.test.ts b/packages/core/src/agent/tools/image-resize.test.ts new file mode 100644 index 00000000..a287b671 --- /dev/null +++ b/packages/core/src/agent/tools/image-resize.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { wrapReadToolWithImageResize } from "./image-resize.js"; + +describe("image-resize", () => { + function makeMockReadTool(content: any[]) { + return { + name: "read", + description: "test", + parameters: {} as any, + execute: async () => ({ content }), + }; + } + + it("should pass through non-image content unchanged", async () => { + const tool = makeMockReadTool([ + { type: "text", text: "Hello world" }, + ]); + const wrapped = wrapReadToolWithImageResize(tool as any); + const result = await wrapped.execute({} as any) as any; + expect(result.content).toHaveLength(1); + expect(result.content[0].text).toBe("Hello world"); + }); + + it("should pass through small images unchanged", async () => { + const smallBase64 = Buffer.alloc(100, 0x41).toString("base64"); + const tool = makeMockReadTool([ + { type: "image", data: smallBase64 }, + ]); + const wrapped = wrapReadToolWithImageResize(tool as any); + const result = await wrapped.execute({} as any) as any; + expect(result.content[0].data).toBe(smallBase64); + }); + + it("should pass through results without content arrays", async () => { + const tool = { + name: "read", + description: "test", + parameters: {} as any, + execute: async () => ({ text: "plain" }), + }; + const wrapped = wrapReadToolWithImageResize(tool as any); + const result = await wrapped.execute({} as any) as any; + expect(result.text).toBe("plain"); + }); + + it("should handle execution errors gracefully", async () => { + const tool = { + name: "read", + description: "test", + parameters: {} as any, + execute: async () => { throw new Error("file not found"); }, + }; + const wrapped = wrapReadToolWithImageResize(tool as any); + await expect(wrapped.execute({} as any)).rejects.toThrow("file not found"); + }); +}); diff --git a/packages/core/src/agent/tools/image-resize.ts b/packages/core/src/agent/tools/image-resize.ts new file mode 100644 index 00000000..30032ea8 --- /dev/null +++ b/packages/core/src/agent/tools/image-resize.ts @@ -0,0 +1,211 @@ +/** + * Image resize wrapper for the read tool. + * + * Wraps the read tool from pi-coding-agent to automatically downscale + * oversized images returned in tool results. Uses macOS `sips` for resize + * (no extra dependencies required). + */ + +import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; +import { execFile } from "node:child_process"; +import { writeFile, readFile, mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +/** Max decoded image binary size (1MB) */ +const MAX_IMAGE_BYTES = 1 * 1024 * 1024; + +/** Max image dimension in pixels per side */ +const MAX_IMAGE_DIMENSION_PX = 2000; + +/** JPEG quality for resized output */ +const JPEG_QUALITY = 80; + +type ContentBlock = AgentToolResult["content"][number]; + +function isImageBlock(block: unknown): block is { type: "image"; data: string; [key: string]: unknown } { + return ( + !!block && + typeof block === "object" && + (block as any).type === "image" && + typeof (block as any).data === "string" + ); +} + +/** + * Run sips command and return output buffer. + * Only available on macOS. + */ +function runSips(args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile("/usr/bin/sips", args, { timeout: 20_000, maxBuffer: 1024 * 1024 }, (err, stdout) => { + if (err) reject(err); + else resolve(stdout); + }); + }); +} + +/** + * Get image dimensions via sips. + */ +async function getImageDimensions( + buffer: Buffer, + tmpDir: string, +): Promise<{ width: number; height: number } | null> { + const input = join(tmpDir, "in.img"); + await writeFile(input, buffer); + + try { + const stdout = await runSips(["-g", "pixelWidth", "-g", "pixelHeight", input]); + const w = stdout.match(/pixelWidth:\s*(\d+)/); + const h = stdout.match(/pixelHeight:\s*(\d+)/); + if (w?.[1] && h?.[1]) { + return { width: parseInt(w[1], 10), height: parseInt(h[1], 10) }; + } + } catch { + // sips not available or failed + } + return null; +} + +/** + * Resize image to JPEG via sips. + */ +async function resizeWithSips( + buffer: Buffer, + maxSide: number, + quality: number, + tmpDir: string, +): Promise { + const input = join(tmpDir, "in.img"); + const output = join(tmpDir, "out.jpg"); + await writeFile(input, buffer); + + await runSips([ + "-Z", String(maxSide), + "-s", "format", "jpeg", + "-s", "formatOptions", String(quality), + input, + "--out", output, + ]); + + return readFile(output); +} + +/** + * Check if image needs resize and perform it if necessary. + * Returns the original base64 if no resize needed or if resize fails. + */ +async function maybeResizeImage(base64Data: string): Promise<{ base64: string; mimeType?: string; resized: boolean }> { + const buffer = Buffer.from(base64Data, "base64"); + const overSize = buffer.byteLength > MAX_IMAGE_BYTES; + + // Quick check: if small enough by bytes and we can't check dimensions, pass through + if (!overSize && process.platform !== "darwin") { + return { base64: base64Data, resized: false }; + } + + // On macOS, use sips to check dimensions and resize if needed + if (process.platform === "darwin") { + const tmpDir = await mkdtemp(join(tmpdir(), "multica-img-")); + try { + const dims = await getImageDimensions(buffer, tmpDir); + + // If we can get dimensions and everything is within limits, pass through + if (dims && !overSize && dims.width <= MAX_IMAGE_DIMENSION_PX && dims.height <= MAX_IMAGE_DIMENSION_PX) { + return { base64: base64Data, resized: false }; + } + + // Need resize + const maxDim = dims ? Math.max(dims.width, dims.height) : MAX_IMAGE_DIMENSION_PX; + const targetSide = Math.min(MAX_IMAGE_DIMENSION_PX, maxDim); + const resized = await resizeWithSips(buffer, targetSide, JPEG_QUALITY, tmpDir); + + // If still too large, try progressively smaller sizes + if (resized.byteLength > MAX_IMAGE_BYTES) { + for (const side of [1600, 1200, 800]) { + const smaller = await resizeWithSips(buffer, side, JPEG_QUALITY, tmpDir); + if (smaller.byteLength <= MAX_IMAGE_BYTES) { + return { base64: smaller.toString("base64"), mimeType: "image/jpeg", resized: true }; + } + } + } + + return { base64: resized.toString("base64"), mimeType: "image/jpeg", resized: true }; + } catch { + // sips failed, pass through original + return { base64: base64Data, resized: false }; + } finally { + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + } + + // Non-macOS: pass through (future: add sharp support) + return { base64: base64Data, resized: false }; +} + +/** + * Process tool result content blocks, resizing oversized images. + */ +async function processResultContent(content: ContentBlock[]): Promise { + const result: ContentBlock[] = []; + + for (const block of content) { + if (!isImageBlock(block)) { + result.push(block); + continue; + } + + const decoded = Buffer.from(block.data, "base64"); + // Skip small images entirely + if (decoded.byteLength <= MAX_IMAGE_BYTES) { + result.push(block); + continue; + } + + try { + const resized = await maybeResizeImage(block.data); + if (resized.resized) { + result.push({ ...block, data: resized.base64 } as ContentBlock); + } else { + result.push(block); + } + } catch { + result.push(block); + } + } + + return result; +} + +/** + * Wrap the read tool to automatically resize oversized images in results. + */ +export function wrapReadToolWithImageResize( + tool: AgentTool, +): AgentTool { + const originalExecute = tool.execute; + + return { + ...tool, + execute: async (...args: Parameters) => { + const result = await originalExecute(...args); + + // Only process results with content arrays + const resultAny = result as any; + if (!resultAny?.content || !Array.isArray(resultAny.content)) { + return result; + } + + // Check if there are any image blocks worth processing + const hasLargeImages = resultAny.content.some( + (block: unknown) => + isImageBlock(block) && Buffer.from((block as any).data, "base64").byteLength > MAX_IMAGE_BYTES, + ); + if (!hasLargeImages) return result; + + const processed = await processResultContent(resultAny.content); + return { ...resultAny, content: processed } as typeof result; + }, + }; +} diff --git a/packages/core/src/hub/hub-identity.ts b/packages/core/src/hub/hub-identity.ts index 8c4986cb..b42ff592 100644 --- a/packages/core/src/hub/hub-identity.ts +++ b/packages/core/src/hub/hub-identity.ts @@ -1,22 +1,27 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; -import { v7 as uuidv7 } from "uuid"; -import { DATA_DIR } from "@multica/utils"; +import { DATA_DIR, generateEncryptedId, isValidEncryptedId } from "@multica/utils"; const HUB_ID_FILE = join(DATA_DIR, "hub-id"); /** - * 获取当前 Hub 的 ID。 - * 首次调用时生成 UUIDv7 并持久化到 ~/.super-multica/hub-id, + * 获取当前 Hub 的 ID(加密后的 40 字符格式)。 + * 首次调用时生成加密 ID 并持久化到 ~/.super-multica/hub-id, * 后续调用直接读取。 */ export function getHubId(): string { try { - return readFileSync(HUB_ID_FILE, "utf-8").trim(); + const existing = readFileSync(HUB_ID_FILE, "utf-8").trim(); + if (isValidEncryptedId(existing)) { + return existing; + } } catch { - const id = uuidv7(); - mkdirSync(DATA_DIR, { recursive: true }); - writeFileSync(HUB_ID_FILE, id, "utf-8"); - return id; + // File doesn't exist or read error } + + // Generate new encrypted ID + const id = generateEncryptedId(); + mkdirSync(DATA_DIR, { recursive: true }); + writeFileSync(HUB_ID_FILE, id, "utf-8"); + return id; } diff --git a/packages/hooks/src/use-gateway-connection.ts b/packages/hooks/src/use-gateway-connection.ts index f77e043d..525b512b 100644 --- a/packages/hooks/src/use-gateway-connection.ts +++ b/packages/hooks/src/use-gateway-connection.ts @@ -1,7 +1,6 @@ "use client"; import { useState, useEffect, useCallback, useRef } from "react"; -import { v7 as uuidv7 } from "uuid"; import { GatewayClient, type ConnectionState, @@ -37,13 +36,43 @@ function clearIdentity(): void { localStorage.removeItem(STORAGE_KEY); } -function getDeviceId(): string { - let id = localStorage.getItem(DEVICE_KEY); - if (!id) { - id = uuidv7(); - localStorage.setItem(DEVICE_KEY, id); +// SHA-256 hash (Web Crypto API) +async function sha256(text: string): Promise { + const buffer = new TextEncoder().encode(text); + const hashBuffer = await crypto.subtle.digest("SHA-256", buffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +// Generate encrypted device ID (40 hex chars, consistent with copilot-search) +async function generateEncryptedDeviceId(): Promise { + const uuid = crypto.randomUUID(); + const firstHash = (await sha256(uuid)).slice(0, 32); + return (await sha256(firstHash)).slice(0, 8) + firstHash; +} + +// Validate encrypted ID format (40 hex characters) +function isValidEncryptedId(id: string): boolean { + return typeof id === "string" && /^[a-f0-9]{40}$/i.test(id); +} + +// Cached promise for device ID generation +let deviceIdPromise: Promise | null = null; + +async function getDeviceId(): Promise { + const existing = localStorage.getItem(DEVICE_KEY); + // If already encrypted format, return as-is + if (existing && isValidEncryptedId(existing)) { + return existing; } - return id; + // Generate new encrypted ID (or migrate old UUID) + if (!deviceIdPromise) { + deviceIdPromise = generateEncryptedDeviceId().then((id) => { + localStorage.setItem(DEVICE_KEY, id); + return id; + }); + } + return deviceIdPromise; } export type PageState = "loading" | "not-connected" | "connecting" | "connected"; @@ -72,12 +101,12 @@ export function useGatewayConnection(): UseGatewayConnectionReturn { const connectToGateway = useCallback( (id: ConnectionIdentity, token?: string) => { - const doConnect = () => { + const doConnect = async () => { disconnectingRef.current = false; setPageState("connecting"); setError(null); - const deviceId = getDeviceId(); + const deviceId = await getDeviceId(); const client = new GatewayClient({ url: id.gateway, diff --git a/packages/utils/src/device-id.ts b/packages/utils/src/device-id.ts new file mode 100644 index 00000000..17161f51 --- /dev/null +++ b/packages/utils/src/device-id.ts @@ -0,0 +1,50 @@ +/** + * Encrypted Device/Hub ID generation utilities + * + * All device identifiers (Device ID, Hub ID, etc.) use the same encryption format: + * 1. Generate UUID + * 2. sha256(uuid).slice(0, 32) = firstHash + * 3. sha256(firstHash).slice(0, 8) + firstHash = 40 hex chars + * + * This is consistent with copilot-search/devv-sdk. + */ + +import { createHash } from "node:crypto"; +import { v7 as uuidv7 } from "uuid"; + +/** + * SHA-256 hash function (Node.js) + */ +function sha256(text: string): string { + return createHash("sha256").update(text, "utf8").digest("hex"); +} + +/** + * Generate an encrypted device/hub ID (40 hex characters) + * + * Algorithm: + * 1. Generate UUIDv7 + * 2. sha256(uuid).slice(0, 32) = firstHash + * 3. sha256(firstHash).slice(0, 8) + firstHash = 40 chars + */ +export function generateEncryptedId(): string { + const uuid = uuidv7(); + const firstHash = sha256(uuid).slice(0, 32); + return sha256(firstHash).slice(0, 8) + firstHash; +} + +/** + * Validate encrypted ID format (40 hex characters) + */ +export function isValidEncryptedId(id: string): boolean { + return typeof id === "string" && /^[a-f0-9]{40}$/i.test(id); +} + +/** + * Encrypt a raw UUID to the 40-char format + * Used when migrating old UUIDs to encrypted format + */ +export function encryptRawId(rawId: string): string { + const firstHash = sha256(rawId).slice(0, 32); + return sha256(firstHash).slice(0, 8) + firstHash; +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index cd47d49f..09feed32 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -3,3 +3,4 @@ export * from "./paths.js"; export * from "./errors.js"; export * from "./retry.js"; export * from "./cancellation.js"; +export * from "./device-id.js"; diff --git a/turbo.json b/turbo.json index 26442b3f..da43a57e 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,7 @@ { "$schema": "https://turbo.build/schema.json", "globalDependencies": ["tsconfig.json", "tsconfig.base.json"], + "globalEnv": ["API_URL"], "tasks": { "build": { "dependsOn": ["^build"],