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
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ services:
# DEBUG: pw:api
# PWDEBUG: 1
CHROMIUM_FLAGS: '--disable-gpu --no-sandbox --headless=new'
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
extra_hosts:
- "host.docker.internal:host-gateway"
security_opt:
- seccomp=unconfined
shm_size: '2gb'
Expand Down
7 changes: 4 additions & 3 deletions server/src/api/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { addGoogleSheetUpdateTask, processGoogleSheetUpdates } from "../workflow
import { addAirtableUpdateTask, processAirtableUpdates } from "../workflow-management/integrations/airtable";
import { sendWebhook } from "../routes/webhook";
import { convertPageToHTML, convertPageToLinks, convertPageToMarkdown, convertPageToScreenshot, convertPageToText } from '../markdownify/scrape';
import { safeDecrypt } from '../utils/auth';
import { executeBrowserAgent } from '../sdk/browserAgent';
import { OutputFormats } from '../constants/output-formats';
import { processRobotOutputFormats } from '../utils/output-post-processor';
Expand Down Expand Up @@ -898,7 +899,7 @@ async function executeRun(id: string, userId: string) {
const llmConfig = {
provider: ((recording.recording_meta as any).promptLlmProvider || 'ollama') as 'anthropic' | 'openai' | 'ollama',
model: (recording.recording_meta as any).promptLlmModel as string | undefined,
apiKey: (recording.recording_meta as any).promptLlmApiKey as string | undefined,
apiKey: (recording.recording_meta as any).promptLlmApiKey ? safeDecrypt((recording.recording_meta as any).promptLlmApiKey) : undefined,
baseUrl: (recording.recording_meta as any).promptLlmBaseUrl as string | undefined,
};
const summaryText = await summarizeMarkdown(markdown, llmConfig);
Expand Down Expand Up @@ -944,7 +945,7 @@ async function executeRun(id: string, userId: string) {
const llmConfig = {
provider: ((recording.recording_meta as any).promptLlmProvider || 'ollama') as 'anthropic' | 'openai' | 'ollama',
model: (recording.recording_meta as any).promptLlmModel as string | undefined,
apiKey: (recording.recording_meta as any).promptLlmApiKey as string | undefined,
apiKey: (recording.recording_meta as any).promptLlmApiKey ? safeDecrypt((recording.recording_meta as any).promptLlmApiKey) : undefined,
baseUrl: (recording.recording_meta as any).promptLlmBaseUrl as string | undefined,
};
logger.log('info', `Running smart query for API scrape run ${plainRun.runId}`);
Expand Down Expand Up @@ -1139,7 +1140,7 @@ async function executeRun(id: string, userId: string) {
const llmConfig = {
provider: ((recording.recording_meta as any).promptLlmProvider || 'ollama') as 'anthropic' | 'openai' | 'ollama',
model: (recording.recording_meta as any).promptLlmModel as string | undefined,
apiKey: (recording.recording_meta as any).promptLlmApiKey as string | undefined,
apiKey: (recording.recording_meta as any).promptLlmApiKey ? safeDecrypt((recording.recording_meta as any).promptLlmApiKey) : undefined,
baseUrl: (recording.recording_meta as any).promptLlmBaseUrl as string | undefined,
};
try {
Expand Down
3 changes: 2 additions & 1 deletion server/src/api/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { capture } from "../utils/analytics";
import { handleRunRecording } from "./record";
import { WorkflowEnricher } from "../sdk/workflowEnricher";
import { cancelScheduledWorkflow, scheduleWorkflow } from '../storage/schedule';
import { encrypt } from '../utils/auth';
import { computeNextRun } from "../utils/schedule";
import moment from 'moment-timezone';
import {
Expand Down Expand Up @@ -286,7 +287,7 @@ router.post("/sdk/robots", requireAPIKey, async (req: AuthenticatedRequest, res:
...(promptInstructionsForMeta ? { promptInstructions: promptInstructionsForMeta } : {}),
...((workflowFile.meta as any).promptLlmProvider ? { promptLlmProvider: (workflowFile.meta as any).promptLlmProvider } : {}),
...((workflowFile.meta as any).promptLlmModel ? { promptLlmModel: (workflowFile.meta as any).promptLlmModel } : {}),
...((workflowFile.meta as any).promptLlmApiKey ? { promptLlmApiKey: (workflowFile.meta as any).promptLlmApiKey } : {}),
...((workflowFile.meta as any).promptLlmApiKey ? { promptLlmApiKey: encrypt((workflowFile.meta as any).promptLlmApiKey) } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail fast when encryption is not configured.

Line 290 persists a key encrypted by encrypt(), but that helper silently generates a random fallback key when ENCRYPTION_KEY is missing/invalid. That makes the stored prompt key unrecoverable on later decrypts or after restart; reject the request or make the helper throw before persisting ciphertext.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/sdk.ts` at line 290, The promptLlmApiKey persistence path in
sdk.ts currently calls encrypt() even when encryption is not properly
configured, which can create unrecoverable ciphertext due to the helper’s
fallback key behavior. Update the workflow save flow around the promptLlmApiKey
handling to fail fast before writing, either by making encrypt() throw on
missing/invalid ENCRYPTION_KEY or by checking configuration in the save path and
rejecting the request. Ensure the logic that builds the persisted meta object
only stores encrypted data when a real, stable encryption key is available.

...((workflowFile.meta as any).promptLlmBaseUrl ? { promptLlmBaseUrl: (workflowFile.meta as any).promptLlmBaseUrl } : {}),
};

Expand Down
96 changes: 80 additions & 16 deletions server/src/routes/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ import { createDocumentParseRobotRecord } from '../utils/document/createDocument

export const router = Router();

const sanitizeRobotMeta = (robot: any): any => {
const plain = typeof robot?.toJSON === 'function' ? robot.toJSON() : { ...robot };
if (plain.recording_meta?.promptLlmApiKey) {
delete plain.recording_meta.promptLlmApiKey;
}
return plain;
};

const pdfUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_FILE_SIZE_BYTES },
Expand Down Expand Up @@ -179,7 +187,14 @@ router.get('/recordings', requireSignIn, async (req: AuthenticatedRequest, res)
return res.status(401).send({ error: 'Unauthorized' });
}
const data = await Robot.findAll({ where: { userId: req.user.id } });
return res.send(data);
const sanitized = data.map(robot => {
const plain = robot.toJSON() as any;
if (plain.recording_meta?.promptLlmApiKey) {
delete plain.recording_meta.promptLlmApiKey;
}
return plain;
});
return res.send(sanitized);
} catch (e) {
logger.log('info', 'Error while reading robots');
return res.send(null);
Expand All @@ -206,6 +221,12 @@ router.get('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r
);
}

if (data?.recording_meta) {
const meta = { ...(data.recording_meta as any) };
delete meta.promptLlmApiKey;
data.recording_meta = meta;
}

return res.send(data);
} catch (e) {
logger.log('info', 'Error while reading robots');
Expand Down Expand Up @@ -373,10 +394,11 @@ function handleWorkflowActions(workflow: any[], credentials: Credentials) {
router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, res) => {
try {
const { id } = req.params;
const { name, limits, credentials, targetUrl, workflow: incomingWorkflow, formats } = req.body;
const { name, limits, credentials, targetUrl, workflow: incomingWorkflow, formats, promptLlmProvider, promptLlmModel, promptLlmApiKey, promptLlmBaseUrl } = req.body;

if (!name && !limits && !credentials && !targetUrl && !incomingWorkflow && formats === undefined) {
return res.status(400).json({ error: 'Either "name", "limits", "credentials", "target_url", "workflow" or "formats" must be provided.' });
const hasLlmUpdate = 'promptLlmProvider' in req.body || 'promptLlmModel' in req.body || 'promptLlmApiKey' in req.body || 'promptLlmBaseUrl' in req.body;
if (!name && !limits && !credentials && !targetUrl && !incomingWorkflow && formats === undefined && !hasLlmUpdate) {
return res.status(400).json({ error: 'Either "name", "limits", "credentials", "target_url", "workflow", "formats" or LLM config must be provided.' });
}

const robot = await Robot.findOne({ where: { 'recording_meta.id': id, userId: req.user!.id } });
Expand Down Expand Up @@ -523,10 +545,29 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r
}
}

const effectiveFormats = normalizedFormats ?? (robot.recording_meta?.formats || []);
const effectiveProvider = promptLlmProvider ?? robot.recording_meta?.promptLlmProvider;
const robotType = robot.recording_meta?.type;
if (
(robotType === 'crawl' || robotType === 'search' || robotType === 'scrape') &&
effectiveFormats.includes('summary' as OutputFormats) &&
effectiveProvider && effectiveProvider !== 'ollama' &&
!promptLlmApiKey &&
!(robot.recording_meta as any).promptLlmApiKey
) {
return res.status(400).json({ error: 'An API key is required when using a non-Ollama LLM provider for summary output.' });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

let updatedMeta = { ...robot.recording_meta };
if (trimmedName) updatedMeta.name = trimmedName;
if (targetUrl) updatedMeta.url = normalizeRobotUrl(targetUrl);
if (normalizedFormats !== undefined) updatedMeta.formats = normalizedFormats;
if (promptLlmProvider !== undefined) updatedMeta.promptLlmProvider = promptLlmProvider || undefined;
if (promptLlmModel !== undefined) updatedMeta.promptLlmModel = promptLlmModel || undefined;
if ('promptLlmApiKey' in req.body) {
updatedMeta.promptLlmApiKey = promptLlmApiKey ? encrypt(promptLlmApiKey) : undefined;
}
if (promptLlmBaseUrl !== undefined) updatedMeta.promptLlmBaseUrl = promptLlmBaseUrl || undefined;

const updates: any = {
recording: { ...robot.recording, workflow: normalizeWorkflowUrls(workflow) },
Expand Down Expand Up @@ -599,6 +640,10 @@ router.post('/recordings/scrape', requireSignIn, async (req: AuthenticatedReques
return res.status(409).json({ error: `A robot with the name "${robotName}" already exists.` });
}

if (finalFormats.includes('summary' as OutputFormats) && promptLlmProvider && promptLlmProvider !== 'ollama' && !promptLlmApiKey) {
return res.status(400).json({ error: 'An API key is required when using a non-Ollama LLM provider for summary output.' });
}

if (scrapeFormats.length === 0 && formats !== undefined) {
return res.status(400).json({ error: 'At least one output format must be selected.' });
}
Expand All @@ -622,7 +667,7 @@ router.post('/recordings/scrape', requireSignIn, async (req: AuthenticatedReques
...(promptInstructions ? { promptInstructions: String(promptInstructions).substring(0, 1000) } : {}),
...(promptLlmProvider ? { promptLlmProvider } : {}),
...(promptLlmModel ? { promptLlmModel } : {}),
...(promptLlmApiKey ? { promptLlmApiKey } : {}),
...(promptLlmApiKey ? { promptLlmApiKey: encrypt(promptLlmApiKey) } : {}),
...(promptLlmBaseUrl ? { promptLlmBaseUrl } : {}),
},
recording: { workflow: [] },
Expand All @@ -635,17 +680,18 @@ router.post('/recordings/scrape', requireSignIn, async (req: AuthenticatedReques
});

logger.log('info', `Markdown robot created with id: ${newRobot.id}`);
const sanitizedScrape = sanitizeRobotMeta(newRobot);
capture(
'maxun-oss-robot-created',
{
robot_meta: newRobot.recording_meta,
recording: newRobot.recording,
robot_meta: sanitizedScrape.recording_meta,
recording: sanitizedScrape.recording,
}
)

return res.status(201).json({
message: 'Markdown robot created successfully.',
robot: newRobot,
robot: sanitizedScrape,
});
} catch (error: any) {
if (error.name === 'SequelizeUniqueConstraintError' || error.parent?.code === '23505') {
Expand Down Expand Up @@ -1823,7 +1869,7 @@ export async function recoverStuckRunningRuns() {
*/
router.post('/recordings/crawl', requireSignIn, async (req: AuthenticatedRequest, res) => {
try {
const { url, name, crawlConfig, formats } = req.body;
const { url, name, crawlConfig, formats, promptLlmProvider, promptLlmModel, promptLlmApiKey, promptLlmBaseUrl } = req.body;

if (!url || !crawlConfig) {
return res.status(400).json({ error: 'URL and crawl configuration are required.' });
Expand Down Expand Up @@ -1861,6 +1907,10 @@ router.post('/recordings/crawl', requireSignIn, async (req: AuthenticatedRequest
? requestedFormats
: [...DEFAULT_OUTPUT_FORMATS];

if (crawlFormats.includes('summary' as OutputFormats) && promptLlmProvider && promptLlmProvider !== 'ollama' && !promptLlmApiKey) {
return res.status(400).json({ error: 'An API key is required when using a non-Ollama LLM provider for summary output.' });
}

const currentTimestamp = new Date().toLocaleString('en-US');
const robotId = uuid();

Expand All @@ -1877,6 +1927,10 @@ router.post('/recordings/crawl', requireSignIn, async (req: AuthenticatedRequest
type: 'crawl',
url: normalizedUrl,
formats: crawlFormats,
...(promptLlmProvider ? { promptLlmProvider } : {}),
...(promptLlmModel ? { promptLlmModel } : {}),
...(promptLlmApiKey ? { promptLlmApiKey: encrypt(promptLlmApiKey) } : {}),
...(promptLlmBaseUrl ? { promptLlmBaseUrl } : {}),
},
recording: {
workflow: [
Expand Down Expand Up @@ -1922,20 +1976,21 @@ router.post('/recordings/crawl', requireSignIn, async (req: AuthenticatedRequest
});

logger.log('info', `Crawl robot created with id: ${newRobot.id}`);
const sanitizedCrawl = sanitizeRobotMeta(newRobot);
capture('maxun-oss-robot-created', {
userId: req.user.id.toString(),
robotId: robotId,
robotName: robotName,
url: normalizedUrl,
robotType: 'crawl',
crawlConfig: crawlConfig,
robot_meta: newRobot.recording_meta,
recording: newRobot.recording,
robot_meta: sanitizedCrawl.recording_meta,
recording: sanitizedCrawl.recording,
});

return res.status(201).json({
message: 'Crawl robot created successfully.',
robot: newRobot,
robot: sanitizedCrawl,
});
} catch (error: any) {
if (error.name === 'SequelizeUniqueConstraintError' || error.parent?.code === '23505') {
Expand All @@ -1958,7 +2013,7 @@ router.post('/recordings/crawl', requireSignIn, async (req: AuthenticatedRequest
*/
router.post('/recordings/search', requireSignIn, async (req: AuthenticatedRequest, res) => {
try {
const { searchConfig, name, formats } = req.body;
const { searchConfig, name, formats, promptLlmProvider, promptLlmModel, promptLlmApiKey, promptLlmBaseUrl } = req.body;

if (!searchConfig || !searchConfig.query) {
return res.status(400).json({ error: 'Search configuration with query is required.' });
Expand Down Expand Up @@ -1996,6 +2051,10 @@ router.post('/recordings/search', requireSignIn, async (req: AuthenticatedReques
searchFormats = requestedFormats.length > 0 ? requestedFormats : [...DEFAULT_OUTPUT_FORMATS];
}

if (searchFormats.includes('summary' as OutputFormats) && promptLlmProvider && promptLlmProvider !== 'ollama' && !promptLlmApiKey) {
return res.status(400).json({ error: 'An API key is required when using a non-Ollama LLM provider for summary output.' });
}

const currentTimestamp = new Date().toLocaleString('en-US');
const robotId = uuid();

Expand All @@ -2011,6 +2070,10 @@ router.post('/recordings/search', requireSignIn, async (req: AuthenticatedReques
params: [],
type: 'search',
formats: searchFormats,
...(promptLlmProvider ? { promptLlmProvider } : {}),
...(promptLlmModel ? { promptLlmModel } : {}),
...(promptLlmApiKey ? { promptLlmApiKey: encrypt(promptLlmApiKey) } : {}),
...(promptLlmBaseUrl ? { promptLlmBaseUrl } : {}),
},
recording: {
workflow: [
Expand Down Expand Up @@ -2040,6 +2103,7 @@ router.post('/recordings/search', requireSignIn, async (req: AuthenticatedReques
});

logger.log('info', `Search robot created with id: ${newRobot.id}`);
const sanitizedSearch = sanitizeRobotMeta(newRobot);
capture('maxun-oss-robot-created', {
userId: req.user.id.toString(),
robotId: robotId,
Expand All @@ -2048,13 +2112,13 @@ router.post('/recordings/search', requireSignIn, async (req: AuthenticatedReques
searchQuery: searchConfig.query,
searchProvider: searchConfig.provider || 'duckduckgo',
searchLimit: searchConfig.limit || 10,
robot_meta: newRobot.recording_meta,
recording: newRobot.recording,
robot_meta: sanitizedSearch.recording_meta,
recording: sanitizedSearch.recording,
});

return res.status(201).json({
message: 'Search robot created successfully.',
robot: newRobot,
robot: sanitizedSearch,
});
} catch (error: any) {
if (error.name === 'SequelizeUniqueConstraintError' || error.parent?.code === '23505') {
Expand Down
7 changes: 4 additions & 3 deletions server/src/task-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import Robot from './models/Robot';
import { browserPool } from './server';
import { Page } from 'playwright-core';
import { capture } from './utils/analytics';
import { safeDecrypt } from './utils/auth';
import { addGoogleSheetUpdateTask, processGoogleSheetUpdates } from './workflow-management/integrations/gsheet';
import { addAirtableUpdateTask, processAirtableUpdates } from './workflow-management/integrations/airtable';
import { io as serverIo } from './server';
Expand Down Expand Up @@ -289,7 +290,7 @@ async function processRunExecution(data: ExecuteRunData): Promise<void> {
const llmConfig = {
provider: ((recording.recording_meta as any).promptLlmProvider || 'ollama') as 'anthropic' | 'openai' | 'ollama',
model: (recording.recording_meta as any).promptLlmModel as string | undefined,
apiKey: (recording.recording_meta as any).promptLlmApiKey as string | undefined,
apiKey: (recording.recording_meta as any).promptLlmApiKey ? safeDecrypt((recording.recording_meta as any).promptLlmApiKey) : undefined,
baseUrl: (recording.recording_meta as any).promptLlmBaseUrl as string | undefined,
};
const summaryText = await summarizeMarkdown(markdown, llmConfig);
Expand Down Expand Up @@ -320,7 +321,7 @@ async function processRunExecution(data: ExecuteRunData): Promise<void> {
const llmConfig = {
provider: ((recording.recording_meta as any).promptLlmProvider || 'ollama') as 'anthropic' | 'openai' | 'ollama',
model: (recording.recording_meta as any).promptLlmModel as string | undefined,
apiKey: (recording.recording_meta as any).promptLlmApiKey as string | undefined,
apiKey: (recording.recording_meta as any).promptLlmApiKey ? safeDecrypt((recording.recording_meta as any).promptLlmApiKey) : undefined,
baseUrl: (recording.recording_meta as any).promptLlmBaseUrl as string | undefined,
};
await run.update({ log: 'Running smart query...' });
Expand Down Expand Up @@ -442,7 +443,7 @@ async function processRunExecution(data: ExecuteRunData): Promise<void> {
llmConfig: {
provider: ((recording.recording_meta as any).promptLlmProvider || 'ollama') as 'anthropic' | 'openai' | 'ollama',
model: (recording.recording_meta as any).promptLlmModel as string | undefined,
apiKey: (recording.recording_meta as any).promptLlmApiKey as string | undefined,
apiKey: (recording.recording_meta as any).promptLlmApiKey ? safeDecrypt((recording.recording_meta as any).promptLlmApiKey) : undefined,
baseUrl: (recording.recording_meta as any).promptLlmBaseUrl as string | undefined,
},
});
Expand Down
Loading