import http from 'node:http'; import { isIP } from 'node:net'; import { readFile, stat } from 'node:fs/promises'; import { extname, join, normalize } from 'node:path'; import { fileURLToPath } from 'node:url'; import { analyzePhoto } from './src/vision-service.js'; import { probeVisionProvider, resolveVisionConfig } from './src/vision-config.js'; import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig } from './src/hermes-agent-service.js'; const root=fileURLToPath(new URL('.',import.meta.url)); const port=Number(process.env.PORT||4173); const host=process.env.HOST||'0.0.0.0'; if(!isIP(host))throw new Error('HOST must be an IPv4 or IPv6 address.'); function resolveBasePath(value) { const raw=String(value||''); if(!raw||raw==='/')return ''; if(raw.length>128||!raw.startsWith('/')||/[?#\\]/.test(raw)||/%(?:2f|5c)/i.test(raw))throw new Error('TIMMY_BASE_PATH must be a safe absolute URL path.'); let normalized=raw.endsWith('/')?raw.slice(0,-1):raw; if(normalized.includes('//'))throw new Error('TIMMY_BASE_PATH must not contain empty path segments.'); let decoded; try{decoded=decodeURIComponent(normalized)}catch{throw new Error('TIMMY_BASE_PATH contains malformed encoding.')} if(decoded.split('/').some(segment=>segment==='.'||segment==='..'))throw new Error('TIMMY_BASE_PATH must not contain traversal segments.'); return normalized; } const basePath=resolveBasePath(process.env.TIMMY_BASE_PATH); const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json; charset=utf-8','.webmanifest':'application/manifest+json','.svg':'image/svg+xml'}; const visionConfig=resolveVisionConfig(process.env); const agentConfig=resolveHermesAgentConfig(process.env); const agentService=createHermesAgentService({config:agentConfig}); const release=/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(process.env.TIMMY_RELEASE_TAG||'')?process.env.TIMMY_RELEASE_TAG:'development'; const commit=/^[0-9a-f]{12,40}$/.test(process.env.TIMMY_RELEASE_COMMIT||'')?process.env.TIMMY_RELEASE_COMMIT:'000000000000'; const publicBasePath=basePath||'/'; const appRoot=basePath?`${basePath}/`:'/'; const stagingLabel=process.env.TIMMY_STAGING_LABEL?`Staging · ${release} · ${commit.slice(0,12)}`:''; function sendJson(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store','x-content-type-options':'nosniff'});res.end(JSON.stringify(value));} function readJson(req,maxBytes=6*1024*1024){return new Promise((resolve,reject)=>{let size=0,tooLarge=false;const chunks=[];req.on('data',chunk=>{size+=chunk.length;if(size>maxBytes){tooLarge=true;return}chunks.push(chunk)});req.on('end',()=>{if(tooLarge)return reject(new AgentGatewayError(413,'Request is too large.'));try{resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))}catch{return reject(new AgentGatewayError(400,'Invalid JSON request.'))}});req.on('error',reject)})} function cookie(req,name){for(const part of String(req.headers.cookie||'').split(';')){const [key,...value]=part.trim().split('=');if(key===name)return decodeURIComponent(value.join('='))}return ''} function requestOrigin(req){return String(req.headers.origin||'').replace(/\/$/,'')} function rejectCrossSite(req){const site=String(req.headers['sec-fetch-site']||'');if(site&&site!=='same-origin')throw new AgentGatewayError(403,'Cross-site agent requests are not allowed.')} function agentCookie(token){const secure=agentConfig.publicOrigin.startsWith('https://')?'; Secure':'';return `timmy_agent=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=${appRoot}; Max-Age=86400${secure}`} function sendAgentError(res,error){const status=error instanceof AgentGatewayError?error.status:503;const message=error instanceof AgentGatewayError?error.message:'Hermes is temporarily unavailable. Your local journal still works.';return sendJson(res,status,{error:message})} http.createServer(async(req,res)=>{ try{ const url=new URL(req.url,'http://localhost'); if(basePath&&url.pathname!==basePath&&!url.pathname.startsWith(`${basePath}/`)){res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});return res.end('Not found')} if(basePath&&url.pathname===basePath){res.writeHead(308,{location:appRoot});return res.end()} const appPath=basePath?(url.pathname.slice(basePath.length)||'/'):url.pathname; if(appPath==='/api/healthz'&&req.method==='GET')return sendJson(res,200,{ok:true,release,commit,visionEnabled:visionConfig.enabled,agentEnabled:agentConfig.enabled}); if(appPath==='/api/vision-status'&&req.method==='GET'){ const provider=await probeVisionProvider(visionConfig); const privacy=visionConfig.profile==='selfhost'?'The compressed image is processed by Timmy’s self-hosted model and is not forwarded to a third-party model provider.':'A compressed copy is sent to the configured AI provider only when you explicitly request analysis.'; return sendJson(res,200,{...visionConfig.publicStatus(),providerReady:provider.ready,modelSeen:provider.modelSeen,privacy}); } if(appPath==='/api/analyze'&&req.method==='POST'){ if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'}); const payload=await readJson(req); try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))} catch(error){const safe=/consent|JPEG|PNG|WebP|empty|too large/i.test(error.message);return sendJson(res,safe?400:503,{error:error.message})} } if(appPath==='/api/agent/status'&&req.method==='GET')return sendJson(res,200,agentService.status(cookie(req,'timmy_agent'))); if(appPath==='/api/agent/unlock'&&req.method==='POST'){ try{rejectCrossSite(req);const payload=await readJson(req,4096);const result=await agentService.unlock({origin:requestOrigin(req),accessCode:String(payload?.accessCode||'')});res.setHeader('set-cookie',agentCookie(result.cookieToken));return sendJson(res,200,result.public)}catch(error){return sendAgentError(res,error)} } if(appPath==='/api/agent/chat'&&req.method==='POST'){ try{rejectCrossSite(req);const payload=await readJson(req,128*1024);return sendJson(res,200,await agentService.chat({origin:requestOrigin(req),cookieToken:cookie(req,'timmy_agent'),payload}))}catch(error){return sendAgentError(res,error)} } if(appPath.startsWith('/api/'))return sendJson(res,404,{error:'Not found'}); if(req.method!=='GET'&&req.method!=='HEAD'){res.writeHead(405,{'allow':'GET, HEAD'});return res.end()} const pathname=decodeURIComponent(appPath); let path=normalize(join(root,pathname==='/'?'index.html':pathname)); if(!path.startsWith(root))throw new Error('bad path'); const info=await stat(path);if(info.isDirectory())path=join(path,'index.html'); let body=await readFile(path); if(appPath==='/'||appPath==='/index.html'){ const browserConfig=JSON.stringify({basePath:publicBasePath,stagingLabel}).replace(/',`\n `).replace('\n