Skip to content
Merged
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
81 changes: 79 additions & 2 deletions bun.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
"smol-toml": "^1.4.2"
},
"devDependencies": {
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/ui-utils": "^1.2.11",
"@eslint/js": "^9.35.0",
"@modelcontextprotocol/sdk": "1.20.0",
"@stylistic/eslint-plugin": "^5.4.0",
Expand All @@ -69,6 +71,7 @@
"@types/sinon": "^17.0.4",
"@typescript-eslint/eslint-plugin": "^8.43.0",
"@typescript-eslint/parser": "^8.43.0",
"ai": "^5.0.102",
"async-mutex": "^0.5.0",
"chrome-devtools-frontend": "1.0.1524741",
"commander": "^14.0.1",
Expand Down
6 changes: 5 additions & 1 deletion packages/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,22 @@
"@ai-sdk/google": "^2.0.43",
"@ai-sdk/openai": "^2.0.72",
"@ai-sdk/openai-compatible": "^1.0.27",
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/ui-utils": "^1.2.11",
"@anthropic-ai/claude-agent-sdk": "^0.1.11",
"@browseros/common": "workspace:*",
"@browseros/server": "workspace:*",
"@browseros/tools": "workspace:*",
"@google/gemini-cli-core": "^0.16.0",
"@hono/node-server": "^1.19.6",
"@openrouter/ai-sdk-provider": "~1.2.5",
"ai": "^5.0.101",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^4.0.14"
},
"optionalDependencies": {
"chrome-devtools-mcp": "latest"
Expand Down
2 changes: 0 additions & 2 deletions packages/agent/src/agent/GeminiAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,6 @@ export class GeminiAgent {
});

await geminiConfig.initialize();

console.log('resolvedConfig', resolvedConfig);
const contentGenerator = new VercelAIContentGenerator(resolvedConfig);

(geminiConfig as unknown as { contentGenerator: VercelAIContentGenerator }).contentGenerator = contentGenerator;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export class VercelAIContentGenerator implements ContentGenerator {
tools,
temperature: request.config?.temperature,
topP: request.config?.topP,
abortSignal: request.config?.abortSignal,
});

return this.responseStrategy.streamToGemini(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,25 @@ export class ResponseConversionStrategy {
usage = this.estimateUsage(textAccumulator);
}

// Emit finish stream part to Hono SSE for useChat compatibility
if (honoStream) {
try {
// Emit finish_message part with finishReason and usage
// Format: e:{"finishReason":"stop","usage":{"promptTokens":10,"completionTokens":5}}
// Map to LanguageModelV1FinishReason: 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown'
const mappedFinishReason = this.mapToDataStreamFinishReason(finishReason);
await honoStream.write(formatDataStreamPart('finish_message', {
finishReason: mappedFinishReason,
usage: usage ? {
promptTokens: usage.promptTokens ?? 0,
completionTokens: usage.completionTokens ?? 0,
} : undefined,
}));
} catch {
// Failed to write finish part
}
}

// Yield final response with tool calls and metadata
if (toolCallsMap.size > 0 || finishReason || usage) {
const parts: Part[] = [];
Expand Down Expand Up @@ -281,6 +300,19 @@ export class ResponseConversionStrategy {
}
}

/**
* Map Vercel finish reasons to data stream protocol finish reasons
* LanguageModelV1FinishReason: 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown'
* Mostly passthrough except 'max-tokens' → 'length'
*/
private mapToDataStreamFinishReason(
reason: VercelFinishReason | undefined,
): 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown' {
if (!reason) return 'stop';
if (reason === 'max-tokens') return 'length';
return reason;
}

/**
* Create empty response for error cases
*/
Expand Down
64 changes: 64 additions & 0 deletions packages/agent/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
export class HttpAgentError extends Error {
constructor(
message: string,
public statusCode: number = 500,
public code?: string,
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}

toJSON() {
return {
error: {
name: this.name,
message: this.message,
code: this.code,
statusCode: this.statusCode,
},
};
}
}

export class ValidationError extends HttpAgentError {
constructor(message: string, public details?: unknown) {
super(message, 400, 'VALIDATION_ERROR');
}

override toJSON() {
return {
error: {
name: this.name,
message: this.message,
code: this.code,
statusCode: this.statusCode,
details: this.details,
},
};
}
}

export class SessionNotFoundError extends HttpAgentError {
constructor(public conversationId: string) {
super(`Session "${conversationId}" not found.`, 404, 'SESSION_NOT_FOUND');
}
}

export class AgentExecutionError extends HttpAgentError {
constructor(message: string, public originalError?: Error) {
super(message, 500, 'AGENT_EXECUTION_ERROR');
}

override toJSON() {
return {
error: {
name: this.name,
message: this.message,
code: this.code,
statusCode: this.statusCode,
originalError: this.originalError?.message,
},
};
}
}
175 changes: 175 additions & 0 deletions packages/agent/src/http/HttpServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { stream } from 'hono/streaming';
import { serve } from '@hono/node-server';
import { formatDataStreamPart } from '@ai-sdk/ui-utils';
import { logger } from '@browseros/common';
import type { Context, Next } from 'hono';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import type { z } from 'zod';

import { SessionManager } from '../session/SessionManager.js';
import { HttpAgentError, ValidationError, AgentExecutionError } from '../errors.js';
import { ChatRequestSchema, HttpServerConfigSchema } from './types.js';
import type { HttpServerConfig, ValidatedHttpServerConfig, ChatRequest } from './types.js';

type AppVariables = {
validatedBody: unknown;
};

const DEFAULT_MCP_SERVER_URL = 'http://127.0.0.1:9150/mcp';
const DEFAULT_TEMP_DIR = '/tmp';

function validateRequest<T>(schema: z.ZodType<T>) {
return async (c: Context<{ Variables: AppVariables }>, next: Next) => {
try {
const body = await c.req.json();
const validated = schema.parse(body);
c.set('validatedBody', validated);
await next();
} catch (err) {
if (err && typeof err === 'object' && 'issues' in err) {
const zodError = err as { issues: unknown };
logger.warn('Request validation failed', { issues: zodError.issues });
throw new ValidationError('Request validation failed', zodError.issues);
}
throw err;
}
};
}

export function createHttpServer(config: HttpServerConfig) {
const validatedConfig: ValidatedHttpServerConfig = HttpServerConfigSchema.parse(config);
const mcpServerUrl = validatedConfig.mcpServerUrl || process.env.MCP_SERVER_URL || DEFAULT_MCP_SERVER_URL;

const app = new Hono<{ Variables: AppVariables }>();
const sessionManager = new SessionManager();

app.use(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@greptileai review the security vulnerability aspect and suggest any fixes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the security aspects of this HTTP server implementation, I can identify several potential vulnerabilities and areas for improvement:

Security Vulnerabilities Identified:

  1. Overly Permissive CORS Configuration: The CORS setup accepts any origins from config without validation
  2. Missing Rate Limiting: No protection against abuse or DoS attacks
  3. Sensitive Data Exposure: API keys and credentials are logged and passed around without proper sanitization
  4. No Authentication/Authorization: The /chat endpoint is completely open
  5. Missing Input Validation: Beyond schema validation, no additional security checks
  6. Error Information Disclosure: Stack traces and detailed errors could leak sensitive information
  7. Missing Security Headers: No security-related HTTP headers

Suggested Fixes:

For the CORS configuration specifically (since that's the line your comment is on), here's a more secure approach:

Suggested change
app.use(
app.use(
'/*',
cors({
origin: (origin) => {
// Validate origins against a whitelist
const allowedOrigins = validatedConfig.corsOrigins || [];
if (!origin) return true; // Allow requests with no origin (e.g., mobile apps)
return allowedOrigins.some(allowed => {
if (allowed === '*') return true;
if (typeof allowed === 'string') return allowed === origin;
if (allowed instanceof RegExp) return allowed.test(origin);
return false;
});
},
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: false, // Explicitly disable credentials unless needed
maxAge: 86400, // Cache preflight requests for 24 hours
}),
);

Additional Security Recommendations:

  1. Add Rate Limiting: Implement rate limiting middleware to prevent abuse
  2. Sanitize Logging: Remove sensitive data from logs (API keys, credentials)
  3. Add Authentication: Consider implementing API key authentication or JWT tokens
  4. Security Headers: Add security headers like X-Frame-Options, X-Content-Type-Options, etc.
  5. Input Sanitization: Add additional validation beyond schema parsing
  6. Error Handling: Sanitize error responses to prevent information disclosure

Would you like me to provide implementations for any of these additional security measures?

'/*',
cors({
origin: (origin) => origin || '*',
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true,
}),
);

app.onError((err, c) => {
const error = err as Error;

if (error instanceof HttpAgentError) {
logger.warn('HTTP Agent Error', {
name: error.name,
message: error.message,
code: error.code,
statusCode: error.statusCode,
});
return c.json(error.toJSON(), error.statusCode as ContentfulStatusCode);
}

logger.error('Unhandled Error', {
message: error.message,
stack: error.stack,
});

return c.json(
{
error: {
name: 'InternalServerError',
message: error.message || 'An unexpected error occurred',
code: 'INTERNAL_SERVER_ERROR',
statusCode: 500,
},
},
500,
);
});

app.get('/health', (c) => c.json({ status: 'ok' }));

app.post('/chat', validateRequest(ChatRequestSchema), async (c) => {
const request = c.get('validatedBody') as ChatRequest;

logger.info('Chat request received', {
conversationId: request.conversationId,
provider: request.provider,
model: request.model,
});

c.header('Content-Type', 'text/plain; charset=utf-8');
c.header('X-Vercel-AI-Data-Stream', 'v1');
c.header('Cache-Control', 'no-cache');
c.header('Connection', 'keep-alive');

// Get abort signal from the raw request - fires when client disconnects
const abortSignal = c.req.raw.signal;

return stream(c, async (honoStream) => {
try {
const agent = await sessionManager.getOrCreate({
conversationId: request.conversationId,
provider: request.provider,
model: request.model,
apiKey: request.apiKey,
baseUrl: request.baseUrl,
// Azure-specific
resourceName: request.resourceName,
// AWS Bedrock-specific
region: request.region,
accessKeyId: request.accessKeyId,
secretAccessKey: request.secretAccessKey,
sessionToken: request.sessionToken,
// Agent-specific
tempDir: validatedConfig.tempDir || DEFAULT_TEMP_DIR,
mcpServerUrl,
});

await agent.execute(request.message, honoStream, abortSignal);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Agent execution failed';
logger.error('Agent execution error', {
conversationId: request.conversationId,
error: errorMessage,
});
await honoStream.write(formatDataStreamPart('error', errorMessage));
throw new AgentExecutionError('Agent execution failed', error instanceof Error ? error : undefined);
}
});
});

app.delete('/chat/:conversationId', (c) => {
const conversationId = c.req.param('conversationId');
const deleted = sessionManager.delete(conversationId);

if (deleted) {
return c.json({
success: true,
message: `Session ${conversationId} deleted`,
sessionCount: sessionManager.count(),
});
}

return c.json({
success: false,
message: `Session ${conversationId} not found`,
}, 404);
});

const server = serve({
fetch: app.fetch,
port: validatedConfig.port,
hostname: validatedConfig.host,
});

logger.info('HTTP Agent Server started', {
port: validatedConfig.port,
host: validatedConfig.host,
});

return {
app,
server,
config: validatedConfig,
};
}
3 changes: 3 additions & 0 deletions packages/agent/src/http/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { createHttpServer } from './HttpServer.js';
export { HttpServerConfigSchema, ChatRequestSchema } from './types.js';
export type { HttpServerConfig, ValidatedHttpServerConfig, ChatRequest } from './types.js';
30 changes: 30 additions & 0 deletions packages/agent/src/http/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { z } from 'zod';
import { VercelAIConfigSchema } from '../agent/gemini-vercel-sdk-adapter/types.js';

/**
* Chat request schema extends VercelAIConfig with request-specific fields
*/
export const ChatRequestSchema = VercelAIConfigSchema.extend({
conversationId: z.string().uuid(),
message: z.string().min(1, 'Message cannot be empty'),
});

export type ChatRequest = z.infer<typeof ChatRequestSchema>;

export interface HttpServerConfig {
port: number;
host?: string;
corsOrigins?: string[];
tempDir?: string;
mcpServerUrl?: string;
}

export const HttpServerConfigSchema = z.object({
port: z.number().int().positive(),
host: z.string().optional().default('0.0.0.0'),
corsOrigins: z.array(z.string()).optional().default(['*']),
tempDir: z.string().optional().default('/tmp'),
mcpServerUrl: z.string().optional(),
});

export type ValidatedHttpServerConfig = z.infer<typeof HttpServerConfigSchema>;
Loading