From c615be5365e92f111d518ed792691dab4d3a3857 Mon Sep 17 00:00:00 2001 From: Interchouette <484423+Interchouette@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:01:01 +0200 Subject: [PATCH 1/3] feat: optional evaluate screenshots and capped batch Make target. Allow engines/runBatch to skip screenshot capture, and add make run-batch-capped (systemd MemoryMax) so large CSV batches do not OOM the host desktop. Co-authored-by: Cursor --- Makefile | 11 +- libs/util/puppeteer/src/lib/browser-engine.ts | 12 +- .../puppeteer/src/lib/playwright.engine.ts | 124 +++++++++-------- .../puppeteer/src/lib/puppeteer.engine.ts | 128 +++++++++++------- libs/util/puppeteer/src/lib/run-evaluate.ts | 39 ++++-- 5 files changed, 198 insertions(+), 116 deletions(-) diff --git a/Makefile b/Makefile index 204106b..3656804 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: help \ - build build-release check check-lib install clean run run-mcp run-mcp-http \ + build build-release check check-lib install clean run run-batch-capped run-mcp run-mcp-http \ docker-build docker-build-no-cache docker-build-dev \ docker-push-dev docker-push-dev-hub docker-push-dev-ghcr-personal docker-push-dev-ghcr-itc \ docker-push-release docker-push-release-hub \ @@ -41,6 +41,8 @@ help: @echo " make install cargo install --path evaluator --features apps" @echo " make run ARGS='…' cargo run --bin evaluator --features apps -- …" @echo " e.g. make run ARGS='evaluate --url http://127.0.0.1:8765/demo1shop.html --fn window.eval'" + @echo " make run-batch-capped same as run, wrapped in systemd --scope MemoryMax=$(MEMORY_MAX)" + @echo " e.g. make run-batch-capped ARGS='batch -p archive/test.csv -f window.eval'" @echo " make run-mcp cargo run --bin evaluator-mcp (stdio)" @echo " make run-mcp-http cargo run --bin evaluator-mcp -- --http" @echo " (needs: npm run build + Chromium / PUPPETEER_EXECUTABLE_PATH)" @@ -82,6 +84,13 @@ clean: run: cd evaluator && $(CARGO) run $(CARGO_FLAGS) --bin $(CLI_BIN) -- $(if $(strip $(ARGS)),$(ARGS),--help) +# Cap batch RAM so the OOM killer hits the job scope, not Cursor/desktop. +MEMORY_MAX ?= 4G +run-batch-capped: + @test -n "$(strip $(ARGS))" || (echo 'usage: make run-batch-capped ARGS="batch -p …"' >&2; exit 2) + systemd-run --user --scope -p MemoryMax=$(MEMORY_MAX) --quiet -- \ + $(MAKE) run ARGS='$(ARGS)' + run-mcp: cd evaluator && $(CARGO) run $(CARGO_FLAGS) --bin evaluator-mcp -- $(ARGS) diff --git a/libs/util/puppeteer/src/lib/browser-engine.ts b/libs/util/puppeteer/src/lib/browser-engine.ts index c020b35..b53dff3 100644 --- a/libs/util/puppeteer/src/lib/browser-engine.ts +++ b/libs/util/puppeteer/src/lib/browser-engine.ts @@ -2,8 +2,18 @@ import type { Message } from '@evaluator/shared-types'; import type { Observable } from 'rxjs'; import type { ConsoleHit } from './console-hit'; +export type GotoOpts = { + /** Default true. Batch must pass false — PNG base64 retains hundreds of MB. */ + screenshot?: boolean; + /** + * Default `networkidle` (single evaluate / UI). Batch should use `load` — + * storefronts never go idle and keep Chromium thrashing for minutes. + */ + waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit'; +}; + export interface EvaluateSession { - goto(message: Message): Promise; + goto(message: Message, opts?: GotoOpts): Promise; close(): Promise; readonly results: Observable; } diff --git a/libs/util/puppeteer/src/lib/playwright.engine.ts b/libs/util/puppeteer/src/lib/playwright.engine.ts index 3dc47c3..759784a 100644 --- a/libs/util/puppeteer/src/lib/playwright.engine.ts +++ b/libs/util/puppeteer/src/lib/playwright.engine.ts @@ -35,59 +35,69 @@ export class PlaywrightEngine implements EvaluateSession { }); } - async goto(message: Message): Promise { + async goto( + message: Message, + opts?: { screenshot?: boolean; waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit' } + ): Promise { const page = await this.getNewPage(message); if (!page) { return; } - this.setListener(page); - let aborted = false; - let url = ''; - this.ws?.send(JSON.stringify('request')); - console.error(getHostname(message.url.trim())); - const hostname = getHostname(message.url.trim()); - await page.route('**/*', async (route) => { - const req = route.request(); - if ( - req.isNavigationRequest() && - req.frame() === page.mainFrame() && - !req.url().includes(hostname) - ) { - aborted = true; - url = req.url(); - console.error(req.url(), message.url); - this.ws?.send(JSON.stringify('aborted before redirection to ' + req.url())); - await route.abort(); - } else { - await route.continue(); - } - }); - this.ws?.send(JSON.stringify('set request interception')); - this.ws?.send(JSON.stringify('server message.url ' + message.url.trim())); - console.error('server message.url', message.url.trim()); - let error = false; - await page - .goto(message.url.trim(), { - timeout: EVALUATE_TIMEOUT_MS, - waitUntil: 'networkidle', - }) - .catch((err: Error) => { - this.ws?.send(JSON.stringify('error ' + err.toString())); - console.error(message.url, url, err); - error = true; + const takeScreenshot = opts?.screenshot !== false; + const waitUntil = opts?.waitUntil ?? 'networkidle'; + try { + this.setListener(page); + let aborted = false; + let url = ''; + this.ws?.send(JSON.stringify('request')); + console.error(getHostname(message.url.trim())); + const hostname = getHostname(message.url.trim()); + await page.route('**/*', async (route) => { + const req = route.request(); + if ( + req.isNavigationRequest() && + req.frame() === page.mainFrame() && + !req.url().includes(hostname) + ) { + aborted = true; + url = req.url(); + console.error(req.url(), message.url); + this.ws?.send(JSON.stringify('aborted before redirection to ' + req.url())); + await route.abort(); + } else { + await route.continue(); + } }); - if (!aborted && !error) { - this.ws?.send(JSON.stringify('server tries screenshot')); - console.error('server tries screenshot', message.url.trim()); - const buffer = await page.screenshot({ type: 'png' }); - const base64 = buffer.toString('base64'); - this.ws?.send(JSON.stringify('screenshot done')); - console.error('server screenshot'); - if (base64) { - return JSON.stringify(`data:image/png;base64,${base64}`); + this.ws?.send(JSON.stringify('set request interception')); + this.ws?.send(JSON.stringify('server message.url ' + message.url.trim())); + console.error('server message.url', message.url.trim()); + let error = false; + await page + .goto(message.url.trim(), { + timeout: EVALUATE_TIMEOUT_MS, + waitUntil, + }) + .catch((err: Error) => { + this.ws?.send(JSON.stringify('error ' + err.toString())); + console.error(message.url, url, err); + error = true; + }); + if (!aborted && !error && takeScreenshot) { + this.ws?.send(JSON.stringify('server tries screenshot')); + console.error('server tries screenshot', message.url.trim()); + const buffer = await page.screenshot({ type: 'png' }); + const base64 = buffer.toString('base64'); + this.ws?.send(JSON.stringify('screenshot done')); + console.error('server screenshot'); + if (base64) { + return JSON.stringify(`data:image/png;base64,${base64}`); + } } + return; + } finally { + // One context per site — must close or batch leaks Chromium contexts until OOM. + await page.context().close().catch(() => undefined); } - return; } private async getNewPage(message: Message) { @@ -100,14 +110,20 @@ export class PlaywrightEngine implements EvaluateSession { return; } this.ws?.send(JSON.stringify('get browser')); - const context = await browser.newContext({ userAgent: EVALUATE_USER_AGENT }); - const page = await context.newPage(); - this.ws?.send(JSON.stringify('new page done')); - const tpl = buildEvalTemplate(message); - await this.sqliteService.insert(message); - this.ws?.send(JSON.stringify('evaluate Document')); - await page.addInitScript(tpl); - return page; + try { + const context = await browser.newContext({ userAgent: EVALUATE_USER_AGENT }); + const page = await context.newPage(); + this.ws?.send(JSON.stringify('new page done')); + const tpl = buildEvalTemplate(message); + await this.sqliteService.insert(message); + this.ws?.send(JSON.stringify('evaluate Document')); + await page.addInitScript(tpl); + return page; + } catch (err) { + console.error('getNewPage failed', err); + this.ws?.send(JSON.stringify('getNewPage err ' + String(err))); + return; + } } async close() { diff --git a/libs/util/puppeteer/src/lib/puppeteer.engine.ts b/libs/util/puppeteer/src/lib/puppeteer.engine.ts index d2ce65c..3eb38b3 100644 --- a/libs/util/puppeteer/src/lib/puppeteer.engine.ts +++ b/libs/util/puppeteer/src/lib/puppeteer.engine.ts @@ -35,57 +35,75 @@ export class PuppeteerEngine implements EvaluateSession { }); } - async goto(message: Message): Promise { + async goto( + message: Message, + opts?: { screenshot?: boolean; waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit' } + ): Promise { const page = await this.getNewPage(message); if (!page) { return; } - this.setListener(page); - let aborted = false; - let url = ''; - this.ws?.send(JSON.stringify('request')); - console.error(getHostname(message.url.trim())); - page.on('request', (req) => { - if ( - req.isNavigationRequest() && - req.frame() === page.mainFrame() && - !req.url().includes(getHostname(message.url.trim())) - ) { - aborted = true; - url = req.url(); - console.error(req.url(), message.url); - this.ws?.send(JSON.stringify('aborted before redirection to ' + req.url())); - req.abort('aborted'); - } else { - req.continue(); - } - }); - this.ws?.send(JSON.stringify('set request interception')); - await page.setRequestInterception(true); - this.ws?.send(JSON.stringify('server message.url ' + message.url.trim())); - console.error('server message.url', message.url.trim()); - let error = false; - await page - .goto(message.url.trim(), { - timeout: EVALUATE_TIMEOUT_MS, - waitUntil: ['domcontentloaded', 'networkidle0'], - }) - .catch((err) => { - this.ws?.send(JSON.stringify('error ' + err.toString())); - console.error(message.url, url, err); - error = true; + const takeScreenshot = opts?.screenshot !== false; + // Default matches historical UI/evaluate behavior (domcontentloaded + networkidle0). + // Puppeteer has no 'commit'; map it to 'domcontentloaded'. + const waitUntil: Array<'load' | 'domcontentloaded' | 'networkidle0'> = + opts?.waitUntil === 'load' + ? ['load'] + : opts?.waitUntil === 'domcontentloaded' || opts?.waitUntil === 'commit' + ? ['domcontentloaded'] + : opts?.waitUntil === 'networkidle' + ? ['domcontentloaded', 'networkidle0'] + : ['domcontentloaded', 'networkidle0']; + try { + this.setListener(page); + let aborted = false; + let url = ''; + this.ws?.send(JSON.stringify('request')); + console.error(getHostname(message.url.trim())); + page.on('request', (req) => { + if ( + req.isNavigationRequest() && + req.frame() === page.mainFrame() && + !req.url().includes(getHostname(message.url.trim())) + ) { + aborted = true; + url = req.url(); + console.error(req.url(), message.url); + this.ws?.send(JSON.stringify('aborted before redirection to ' + req.url())); + req.abort('aborted'); + } else { + req.continue(); + } }); - if (!aborted && !error) { - this.ws?.send(JSON.stringify('server tries screenshot')); - console.error('server tries screenshot', message.url.trim()); - const base64 = (await page.screenshot({ encoding: 'base64' })) as string; - this.ws?.send(JSON.stringify('screenshot done')); - console.error('server screenshot'); - if (base64) { - return JSON.stringify(`data:image/png;base64,${base64}`); + this.ws?.send(JSON.stringify('set request interception')); + await page.setRequestInterception(true); + this.ws?.send(JSON.stringify('server message.url ' + message.url.trim())); + console.error('server message.url', message.url.trim()); + let error = false; + await page + .goto(message.url.trim(), { + timeout: EVALUATE_TIMEOUT_MS, + waitUntil, + }) + .catch((err) => { + this.ws?.send(JSON.stringify('error ' + err.toString())); + console.error(message.url, url, err); + error = true; + }); + if (!aborted && !error && takeScreenshot) { + this.ws?.send(JSON.stringify('server tries screenshot')); + console.error('server tries screenshot', message.url.trim()); + const base64 = (await page.screenshot({ encoding: 'base64' })) as string; + this.ws?.send(JSON.stringify('screenshot done')); + console.error('server screenshot'); + if (base64) { + return JSON.stringify(`data:image/png;base64,${base64}`); + } } + return; + } finally { + await page.close().catch(() => undefined); } - return; } private async getNewPage(message: Message) { @@ -98,14 +116,20 @@ export class PuppeteerEngine implements EvaluateSession { return browser; } this.ws?.send(JSON.stringify('get browser')); - const page = await browser.newPage(); - this.ws?.send(JSON.stringify('new page done')); - const tpl = buildEvalTemplate(message); - await this.sqliteService.insert(message); - this.ws?.send(JSON.stringify('evaluate Document')); - await page.evaluateOnNewDocument(tpl); - await page.setUserAgent(EVALUATE_USER_AGENT); - return page; + try { + const page = await browser.newPage(); + this.ws?.send(JSON.stringify('new page done')); + const tpl = buildEvalTemplate(message); + await this.sqliteService.insert(message); + this.ws?.send(JSON.stringify('evaluate Document')); + await page.evaluateOnNewDocument(tpl); + await page.setUserAgent(EVALUATE_USER_AGENT); + return page; + } catch (err) { + console.error('getNewPage failed', err); + this.ws?.send(JSON.stringify('getNewPage err ' + String(err))); + return; + } } async close() { diff --git a/libs/util/puppeteer/src/lib/run-evaluate.ts b/libs/util/puppeteer/src/lib/run-evaluate.ts index 88fc843..9a3c191 100644 --- a/libs/util/puppeteer/src/lib/run-evaluate.ts +++ b/libs/util/puppeteer/src/lib/run-evaluate.ts @@ -29,6 +29,11 @@ export type RunBatchOpts = { fn?: string; /** Reserved; batch uses one browser and processes sites sequentially. */ concurrency?: number; + /** + * Default false. Screenshots are huge base64 strings; enabling them in batch + * will OOM the host when combined with context leaks. + */ + screenshot?: boolean; onSite?: (site: string, outcome: RunEvaluateOutcome) => void; }; @@ -108,12 +113,14 @@ export async function runEvaluate(opts: RunEvaluateOpts): Promise { +export async function runBatch(opts: RunBatchOpts): Promise { const urls = opts.urls.map((u) => u.trim()).filter(Boolean); if (urls.length === 0) { - return []; + return 0; } for (const url of urls) { if (!isValidHttpUrl(url)) { @@ -125,7 +132,8 @@ export async function runBatch(opts: RunBatchOpts): Promise Date: Wed, 5 Aug 2026 18:17:01 +0200 Subject: [PATCH 2/3] fix: use runBatch count return in batch CLI. runBatch returns a number of sites processed, not an outcomes array. Co-authored-by: Cursor --- apps/evaluator/server.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/evaluator/server.ts b/apps/evaluator/server.ts index 14985e9..07bb482 100644 --- a/apps/evaluator/server.ts +++ b/apps/evaluator/server.ts @@ -219,20 +219,21 @@ async function runBatchCli(argv: string[]): Promise { process.exit(2); } void concurrency; // one browser, sequential (plan) - const outcomes = await runBatch({ + // Never take/emit screenshots in batch — base64 PNGs OOM the host. + const count = await runBatch({ urls, fn, + screenshot: false, onSite: (site, outcome) => { console.log( JSON.stringify({ url: site, results: outcome.results, - screenshot: outcome.screenshot, }) ); }, }); - console.error(`[evaluate] batch done count=${outcomes.length}`); + console.error(`[evaluate] batch done count=${count}`); } async function main(): Promise { From 1a2e16fd1d8dd86b85421ca23a2e927ef57a66a5 Mon Sep 17 00:00:00 2001 From: Interchouette <484423+Interchouette@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:01:01 +0200 Subject: [PATCH 3/3] chore: align docs/Hub layout with sibling ITC repos; SQLite under db/. Move README and CHANGELOG into docs/, drop public docker/DOCKERHUB.md (Hub sync via private .cursor/scripts), stop tracking .cursor in .gitignore, and default SQLITE_PATH to db/database.db. Co-authored-by: Cursor --- .dockerignore | 2 +- .github/workflows/docker-build-push-dev.yml | 9 -- .github/workflows/release.yml | 9 -- .gitignore | 4 +- apps/evaluator/server.ts | 5 +- docker/DOCKERHUB.md | 106 ------------------ docker/Dockerfile | 8 +- docker/docker-compose.yml | 6 +- docs/CHANGELOG.md | 44 ++++++++ README.md => docs/README.md | 31 +++-- evaluator.code-workspace | 8 -- evaluator/src/mcp/server.rs | 8 +- .../functions/src/lib/util-functions.token.ts | 6 +- libs/util/sqlite/src/lib/sqlite.service.ts | 2 +- 14 files changed, 86 insertions(+), 162 deletions(-) delete mode 100644 docker/DOCKERHUB.md create mode 100644 docs/CHANGELOG.md rename README.md => docs/README.md (86%) delete mode 100644 evaluator.code-workspace diff --git a/.dockerignore b/.dockerignore index b76283f..192f43b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,6 +19,7 @@ dist tmp out-tsc coverage +db/ database.db .env .env.* @@ -30,7 +31,6 @@ evaluator/target # Local tooling / docs not needed in image *.md -!README.md Makefile .dockerignore docker/*.md diff --git a/.github/workflows/docker-build-push-dev.yml b/.github/workflows/docker-build-push-dev.yml index 736ada4..d38ad72 100644 --- a/.github/workflows/docker-build-push-dev.yml +++ b/.github/workflows/docker-build-push-dev.yml @@ -54,15 +54,6 @@ jobs: echo echo "Render deploy requested" - - name: Docker Hub description - uses: peter-evans/dockerhub-description@v5 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - repository: interchouette/evaluator - short-description: evaluator.interchouette.net — JS eval (Playwright / Puppeteer), web + CLI + MCP - readme-filepath: docker/DOCKERHUB.md - - name: Log in to GHCR uses: docker/login-action@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 423fdcb..0fc3ec4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,15 +110,6 @@ jobs: echo echo "Render deploy requested" - - name: Docker Hub description - uses: peter-evans/dockerhub-description@v5 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - repository: interchouette/evaluator - short-description: evaluator.interchouette.net — JS eval (Playwright / Puppeteer), web + CLI + MCP - readme-filepath: docker/DOCKERHUB.md - - name: Log in to GHCR uses: docker/login-action@v4 with: diff --git a/.gitignore b/.gitignore index a0da628..f2a63b8 100644 --- a/.gitignore +++ b/.gitignore @@ -47,12 +47,10 @@ testem.log Thumbs.db # runtime / local data +/db/ database.db -/data/ */target/ -.github_credentials -.cursor # local env (never commit) .env diff --git a/apps/evaluator/server.ts b/apps/evaluator/server.ts index 07bb482..5816b41 100644 --- a/apps/evaluator/server.ts +++ b/apps/evaluator/server.ts @@ -91,7 +91,10 @@ export function createApp(): express.Express { app.use('/db', auth); app.get('/db/database.db', (_req: Request, res: Response) => { - res.sendFile(join(process.cwd(), 'database.db'), { + const sqlitePath = + process.env['SQLITE_PATH']?.trim() || + join(process.cwd(), 'db', 'database.db'); + res.sendFile(sqlitePath, { headers: { 'Cache-Control': 'no-cache' }, }); }); diff --git a/docker/DOCKERHUB.md b/docker/DOCKERHUB.md deleted file mode 100644 index f31bf88..0000000 --- a/docker/DOCKERHUB.md +++ /dev/null @@ -1,106 +0,0 @@ -# evaluator - -Evaluate JavaScript on live pages with **Playwright** (default) or **Puppeteer** (`USE_PUPPETEER=1`), on distro Chromium. - -**Live:** [https://evaluator.interchouette.net](https://evaluator.interchouette.net) - -**One image:** web UI + Nest API + Rust CLI + MCP (stdio / Streamable HTTP). - -```bash -docker pull interchouette/evaluator:dev -docker run --rm -p 4000:4000 -p 9790:9790 interchouette/evaluator:dev -``` - -Default: **web on :4000** + **MCP HTTP on :9790**. Opt out with `ENABLE_MCP=0`. - -Prefer **MCP HTTP** (`http://localhost:9790/mcp` locally, or `https://mcp:mcp@host/mcp` via the web gateway). Stdio pays a Docker spawn cost on cold start. - -Open http://localhost:4000/ (or production [evaluator.interchouette.net](https://evaluator.interchouette.net)) - -``` -https://evaluator.interchouette.net/evaluate/?url=https://www.w3schools.com/jsref/tryit.asp?filename=tryjsref_eval&function=window.eval -``` - -## Modes - -| Invocation | Behavior | -| --- | --- | -| *(default / `web`)* | Web UI + API on `:4000`; MCP HTTP sidecar on `:9790` if `ENABLE_MCP=1` | -| `mcp` / `mcp --http` | MCP only (stdio or Streamable HTTP) | -| `evaluator` / `cli` (no subcommand) | **Interactive** Rust prompt until `quit` / EOF; MCP sidecar if `ENABLE_MCP=1` | -| `evaluator evaluate --url …` | **One-shot** GET `/evaluate` → print body → exit (no MCP) | -| `evaluator batch -p file.csv …` | CSV batch (flat `-p` without `batch` also works) → exit (no MCP) | - -```bash -IMAGE=interchouette/evaluator:dev - -# Web + MCP HTTP (default; Playwright engine) -docker run -d -p 4000:4000 -p 9790:9790 "$IMAGE" - -# Web only (no MCP sidecar) -docker run -d -p 4000:4000 -e ENABLE_MCP=0 "$IMAGE" - -# Web + Puppeteer engine + MCP -docker run -d -p 4000:4000 -p 9790:9790 -e USE_PUPPETEER=1 "$IMAGE" - -# MCP stdio (Rust evaluator-mcp; no web required) -docker run --rm -i --network host "$IMAGE" mcp - -# MCP HTTP only -docker run --rm -p 9790:9790 "$IMAGE" mcp --http - -# One-shot evaluate (spawns Node evaluate inside the image) -docker run --rm --network host \ - "$IMAGE" evaluator evaluate --url https://example.com --fn window.eval - -# CSV batch -docker run --rm --network host \ - -v "$PWD/evaluator:/data:ro" \ - "$IMAGE" evaluator batch -p /data/archive/test.csv -f window.eval -n 1 - -# Interactive CLI (-it required) -docker run -it --rm --network host \ - "$IMAGE" evaluator -``` - -| Env | Default | Meaning | -| --- | --- | --- | -| `ENABLE_MCP` | `1` | Start MCP HTTP beside web / interactive CLI (`0` = off) | -| `ENABLE_MCP_PROXY` | `1` | Express proxies `/mcp` → sidecar `:9790` (basic auth) | -| `MCP_USER` / `MCP_PWD` | `mcp` / `mcp` | Basic auth for gateway `/mcp` (not `DB_*`) | -| `USE_PUPPETEER` | `0` | `1` / `true` → Puppeteer; else Playwright | -| `EVALUATOR_NODE_ENTRY` | `/app/dist/evaluator/server/server.js` | Shared Node entry (serve/evaluate/batch) | -| `EVALUATOR_MCP_ADDR` | `0.0.0.0:9790` | MCP HTTP bind | -| `FUNCTIONS_PATH` | `/app/data/functions.json` | Catalog for `list_functions` | -| `PUPPETEER_EXECUTABLE_PATH` | `/usr/bin/chromium` | Chromium binary for both engines | - -## MCP over HTTPS (Render / single public port) - -Platforms like Render expose only `PORT` (web). The sidecar still listens on `:9790` **inside** the container; it is **not** `https://host:9790/mcp`. - -Use the Express proxy instead: - -```text -https://mcp:mcp@evaluator.interchouette.net/mcp -``` - -Boat defaults `MCP_USER`/`MCP_PWD` = `mcp`/`mcp` are **public by design** for the shared demo (401 without creds; README documents the pair). Override only for a **private** deploy and share creds out-of-band. Do not reuse `DB_USER`/`DB_PWD`. - -Render checklist: `ENABLE_MCP=1`, leave `MCP_*` at defaults on the public demo. - -AI clients: Streamable HTTP at `http://localhost:9790/mcp` (local sidecar, no auth) or `https://mcp:mcp@/mcp` (proxied). Stdio: spawn `… mcp`. Tools: `evaluate`, `list_functions`, `batch` (Rust `evaluator-mcp` → Node evaluate/batch). - -## Tags - -| Tag | Meaning | -| --- | --- | -| `dev` | Latest CI build | -| `latest` | Same image as current `:dev` (also updated on release) | -| `X.Y.Z` | Versioned release | - -## Also on GHCR - -- `ghcr.io/interchouette-itc/evaluator` -- `ghcr.io/groussac/evaluator` (optional mirror) - -Source: https://github.com/Interchouette-ITC/evaluator diff --git a/docker/Dockerfile b/docker/Dockerfile index 803fccf..e47bbd5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -51,8 +51,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ ca-certificates \ && groupadd --system --gid 1001 app \ && useradd --system --uid 1001 --gid app --create-home --home-dir /home/app app \ - && mkdir -p /app/data \ - && chown -R app:app /app/data + && mkdir -p /app/db \ + && chown -R app:app /app/db ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \ PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \ @@ -60,8 +60,8 @@ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \ USE_PUPPETEER=0 \ PORT=4000 \ NODE_ENV=production \ - SQLITE_PATH=/app/data/database.db \ - FUNCTIONS_PATH=/app/data/functions.json \ + SQLITE_PATH=/app/db/database.db \ + FUNCTIONS_PATH=/app/db/functions.json \ ENABLE_MCP=1 \ EVALUATOR_MCP_ADDR=0.0.0.0:9790 \ EVALUATOR_NODE_ENTRY=/app/dist/evaluator/server/server.js diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 4de9f3f..e965654 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -17,10 +17,10 @@ services: - PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 - USE_PUPPETEER=0 - - SQLITE_PATH=/app/data/database.db - - FUNCTIONS_PATH=/app/data/functions.json + - SQLITE_PATH=/app/db/database.db + - FUNCTIONS_PATH=/app/db/functions.json volumes: - - evaluator-data:/app/data + - evaluator-data:/app/db - ../evaluator:/data:ro volumes: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..d198e0a --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed + +- Docs live under [`docs/`](.) (`README.md`, this changelog); Docker Hub Overview maintained privately in `.cursor/scripts/DOCKERHUB.md` +- Runtime SQLite default path is `db/database.db` (Docker: `/app/db/database.db`) + +## [1.0.0] - 2026-08 + +First Interchouette / ITC packaging line for the modernized monorepo (`package.json` / `evaluator/Cargo.toml` `1.0.0`). + +### Added + +- All-in-one Docker image `interchouette/evaluator` (Angular SPA + Nest API + Node serve/evaluate/batch + Chromium + Rust CLI + Rust MCP) +- Rust CLI (`evaluator`): interactive prompt, one-shot `evaluate`, CSV `batch` +- Rust MCP (`evaluator-mcp`, mcpkit): tools `evaluate`, `list_functions`, `batch` (stdio / Streamable HTTP) +- Express `/mcp` proxy with dedicated basic auth (`MCP_USER` / `MCP_PWD`); public demo boat defaults +- Playwright as default evaluate engine; `USE_PUPPETEER=1` to opt into Puppeteer +- Magecart / Grelos teaching fixtures (`demo1`–`demo3`) and product notes +- Hooked-function payload filters: keywords, regex, Willem frontend rules; `--excerpt` snippets +- Host Make targets for Rust CLI/MCP (`make build` / `run` / `install` / `run-mcp` / `run-mcp-http`) +- CI: `ci.yml`, manual `docker-build-push-dev.yml`, GitHub Release `release.yml` (Hub + GHCR; attach CLI/MCP binaries; optional Render deploy hook) +- GPL-3.0-or-later `LICENSE` + +### Changed + +- Default MCP HTTP port **9790** (was 9788, earlier 8788) +- Dropped separate `evaluator-base` / `evaluator-tools` images; one monolith image +- Node 24+ / Actions majors refreshed; Docker Rust builder 1.88; Node image `node:26-trixie-slim` +- Live deployment pointed at [evaluator.interchouette.net](https://evaluator.interchouette.net) + +### Removed + +- Public repo `docker/DOCKERHUB.md` (Hub description synced from private `.cursor/scripts`) + +[Unreleased]: https://github.com/Interchouette-ITC/evaluator/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/Interchouette-ITC/evaluator/releases/tag/v1.0.0 diff --git a/README.md b/docs/README.md similarity index 86% rename from README.md rename to docs/README.md index 0c46031..45c620e 100644 --- a/README.md +++ b/docs/README.md @@ -26,6 +26,17 @@ compromised / demo page **Live:** [https://evaluator.interchouette.net](https://evaluator.interchouette.net) + +## Docs + +| Doc | Description | +| --- | --- | +| [`CHANGELOG.md`](CHANGELOG.md) | Release notes | +| [`evaluator/MAGECART.md`](../evaluator/MAGECART.md) | Magecart / Grelos product notes | +| [`evaluator/README.md`](../evaluator/README.md) | Rust CLI details | +| [`docker/`](../docker/) | Dockerfile / Compose (Hub overview lives in private `.cursor/scripts/DOCKERHUB.md`) | + + Typically helps with deobfuscating patterns like [this Stack Overflow case](https://stackoverflow.com/questions/32977908/how-can-i-deobfuscate-this-javascript) using `String.fromCharCode`, `window.eval`, or other functions like `JSON.stringify`. --- @@ -34,14 +45,14 @@ Typically helps with deobfuscating patterns like [this Stack Overflow case](http Static scanners (Willem’s rules, VT + YARA) see files on disk. Skimmers often stay opaque until the browser runs them. **Evaluator is the complementary runtime tool:** after the packer calls `eval`, you see `grelos_v`, `checkout`, gate URLs, and friends in the hook output. -Full product notes (warnings, canary vs demos, batch): **[`evaluator/MAGECART.md`](evaluator/MAGECART.md)** — start there for Magecart / Grelos context. +Full product notes (warnings, canary vs demos, batch): **[`evaluator/MAGECART.md`](../evaluator/MAGECART.md)** — start there for Magecart / Grelos context. ### References - Jérôme Segura (Malwarebytes), *Hunting web skimmers with VirusTotal and YARA*, VB2021 — [PDF](https://vblocalhost.com/uploads/VB2021-Segura.pdf) -- Product notes (Magecart / Grelos / Blogspot demo fixtures): [`evaluator/MAGECART.md`](evaluator/MAGECART.md) +- Product notes (Magecart / Grelos / Blogspot demo fixtures): [`evaluator/MAGECART.md`](../evaluator/MAGECART.md) - Astra — signs of hacked OpenCart / Magento / PrestaShop stores (malicious JS): [getastra.com article](https://www.getastra.com/e/malware/infections/the-presence-of-these-malicious-javascript-are-the-sign-of-hacked-opencart-magento-or-prestashop-store) -- Willem de Groot — magento-malware-scanner frontend rules: [`rules/frontend.txt`](https://github.com/gwillem/magento-malware-scanner/blob/master/rules/frontend.txt) (local snapshot: [`evaluator/rules/frontend.txt`](evaluator/rules/frontend.txt)) +- Willem de Groot — magento-malware-scanner frontend rules: [`rules/frontend.txt`](https://github.com/gwillem/magento-malware-scanner/blob/master/rules/frontend.txt) (local snapshot: [`evaluator/rules/frontend.txt`](../evaluator/rules/frontend.txt))
Screenshots — web UI in action @@ -75,13 +86,13 @@ https://evaluator.interchouette.net/evaluate/?url=https://www.w3schools.com/jsre ## Demos — illustrate the PDF story on localhost -Research fixtures under [`evaluator/archive/fixtures/`](evaluator/archive/fixtures/). **Serve only on localhost.** Do **not** expose them through Nest, Express, the Docker public image, or any internet-facing route. Details: [`DEOBFUSCATED.md`](evaluator/archive/fixtures/DEOBFUSCATED.md), [`MAGECART.md`](evaluator/MAGECART.md). +Research fixtures under [`evaluator/archive/fixtures/`](../evaluator/archive/fixtures/). **Serve only on localhost.** Do **not** expose them through Nest, Express, the Docker public image, or any internet-facing route. Details: [`DEOBFUSCATED.md`](../evaluator/archive/fixtures/DEOBFUSCATED.md), [`MAGECART.md`](../evaluator/MAGECART.md). | # | Obfuscated | Deobfuscated | What you learn | | --- | --- | --- | --- | -| **1** | [`demo1shop.html`](evaluator/archive/fixtures/demo1shop.html) | [`demo1shop.deobfuscated.js`](evaluator/archive/fixtures/demo1shop.deobfuscated.js) | Neutral shop JS; historical `_0xd419` hex packer → `eval` → `checkout` / `cart` | -| **2** | [`demo2grelos.html`](evaluator/archive/fixtures/demo2grelos.html) | [`demo2grelos.deobfuscated.js`](evaluator/archive/fixtures/demo2grelos.deobfuscated.js) | Grelos-shaped marker; light teaching packer | -| **3** | [`demo3grelos.html`](evaluator/archive/fixtures/demo3grelos.html) | [`demo3grelos.deobfuscated.js`](evaluator/archive/fixtures/demo3grelos.deobfuscated.js) | Same marker; Magento-era `_0x` hex-table → `eval` (demo‑1 packing family) | +| **1** | [`demo1shop.html`](../evaluator/archive/fixtures/demo1shop.html) | [`demo1shop.deobfuscated.js`](../evaluator/archive/fixtures/demo1shop.deobfuscated.js) | Neutral shop JS; historical `_0xd419` hex packer → `eval` → `checkout` / `cart` | +| **2** | [`demo2grelos.html`](../evaluator/archive/fixtures/demo2grelos.html) | [`demo2grelos.deobfuscated.js`](../evaluator/archive/fixtures/demo2grelos.deobfuscated.js) | Grelos-shaped marker; light teaching packer | +| **3** | [`demo3grelos.html`](../evaluator/archive/fixtures/demo3grelos.html) | [`demo3grelos.deobfuscated.js`](../evaluator/archive/fixtures/demo3grelos.deobfuscated.js) | Same marker; Magento-era `_0x` hex-table → `eval` (demo‑1 packing family) | ```bash cd evaluator/archive/fixtures && python3 -m http.server 8765 @@ -212,7 +223,7 @@ make docker-run-mcp # stdio (evaluator-mcp) | Workflow | Trigger | What | | --- | --- | --- | | `ci.yml` | PR / push to `dev` | `npm ci` + `npm run build` | -| `docker-build-push-dev.yml` | manual | monolith `:dev` + `:latest` → Hub + GHCR; then Render via `RENDER_DEPLOY_HOOK` | +| `docker-build-push-dev.yml` | manual | monolith `:dev` + `:latest` → Hub + GHCR; then Render via `RENDER_DEPLOY_HOOK` (Hub Overview: `python3 .cursor/scripts/sync-hub-description.py`) | | `release.yml` | GitHub Release `vX.Y.Z` | attach host `evaluator` + `evaluator-mcp` binaries; push monolith `:X.Y.Z` + `:latest` → Hub + GHCR; Render redeploy | Secret `RENDER_DEPLOY_HOOK` = full Render Deploy Hook URL (repo secret, not an app env). Without it, Hub still updates; Render stays on the old digests until a manual redeploy. @@ -284,7 +295,7 @@ Needs `npm run build` (Node entry at `dist/evaluator/server/server.js`) + Chromi | One-shot | `evaluator evaluate --url … [--fn …]` | | CSV batch | `evaluator batch -p archive/test.csv -f window.eval -n 1` | -See [evaluator/README.md](evaluator/README.md) for details. +See [evaluator/README.md](../evaluator/README.md) for details.
@@ -338,7 +349,7 @@ Takeaways: ## License -[GPL-3.0-or-later](https://www.gnu.org/licenses/gpl-3.0.html) — see [`LICENSE`](LICENSE). +[GPL-3.0-or-later](https://www.gnu.org/licenses/gpl-3.0.html) — see [`LICENSE`](../LICENSE). ### Security diff --git a/evaluator.code-workspace b/evaluator.code-workspace deleted file mode 100644 index 022c3e5..0000000 --- a/evaluator.code-workspace +++ /dev/null @@ -1,8 +0,0 @@ -{ - "folders": [ - { - "path": "." - } - ], - "settings": {} -} diff --git a/evaluator/src/mcp/server.rs b/evaluator/src/mcp/server.rs index 03c013d..5ae7406 100644 --- a/evaluator/src/mcp/server.rs +++ b/evaluator/src/mcp/server.rs @@ -141,12 +141,12 @@ fn functions_candidates() -> Vec { out.push(PathBuf::from(t)); } } - out.push(PathBuf::from("/app/data/functions.json")); + out.push(PathBuf::from("/app/db/functions.json")); if let Ok(cwd) = env::current_dir() { - out.push(cwd.join("data/functions.json")); - out.push(cwd.join("../data/functions.json")); + out.push(cwd.join("db/functions.json")); + out.push(cwd.join("../db/functions.json")); } - out.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../data/functions.json")); + out.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../db/functions.json")); out } diff --git a/libs/util/functions/src/lib/util-functions.token.ts b/libs/util/functions/src/lib/util-functions.token.ts index 82b465f..3d06da0 100644 --- a/libs/util/functions/src/lib/util-functions.token.ts +++ b/libs/util/functions/src/lib/util-functions.token.ts @@ -3,7 +3,7 @@ import { mkdirSync } from 'fs'; /** * Writable catalog path — no __dirname /dist/ string surgery. - * Priority: FUNCTIONS_PATH → next to SQLITE_PATH → cwd/data/functions.json + * Priority: FUNCTIONS_PATH → next to SQLITE_PATH → cwd/db/functions.json */ export function resolveFunctionsPath(): string { const fromEnv = process.env['FUNCTIONS_PATH']?.trim(); @@ -14,10 +14,10 @@ export function resolveFunctionsPath(): string { if (sqlite) { return join(dirname(resolve(sqlite)), 'functions.json'); } - return resolve(process.cwd(), 'data', 'functions.json'); + return resolve(process.cwd(), 'db', 'functions.json'); } -/** Ensure parent dir exists (Docker volume /app/data, local ./data). */ +/** Ensure parent dir exists (Docker volume /app/db, local ./db). */ export function ensureFunctionsDir(path: string = resolveFunctionsPath()): void { mkdirSync(dirname(path), { recursive: true }); } diff --git a/libs/util/sqlite/src/lib/sqlite.service.ts b/libs/util/sqlite/src/lib/sqlite.service.ts index 0537b64..9de4e6b 100644 --- a/libs/util/sqlite/src/lib/sqlite.service.ts +++ b/libs/util/sqlite/src/lib/sqlite.service.ts @@ -5,7 +5,7 @@ import { dirname, resolve } from 'path'; import { Message } from '@evaluator/shared-types'; const DEFAULT_DB_PATH = resolve( - process.env['SQLITE_PATH'] || 'data/database.db' + process.env['SQLITE_PATH'] || 'db/database.db' ); export class SqliteService {