feat: add prepackaged Option 3 Hybrid Docker Compose stack, CloakBrowser, jobbot-mcp, and GHCR CI

This commit is contained in:
John Smith committed 2026-10-27 17:14:43 -04:00
1 parent 790af8d4cf
commit 932bee05de
24 files changed
+5011

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
**/node_modules
**/dist
.git
.env
.env.*
!.env.example
storage
shared
*.log
+55
View File
@@ -0,0 +1,55 @@
# ==============================================================================
# JOB-BOT: Prepackaged Docker Compose Configuration (.env)
# Everything comes prebuilt & prewired—you only need to fill out Sections 1 & 2!
# ==============================================================================
# ------------------------------------------------------------------------------
# 1. REQUIRED: OpenCode Claude Code Harness Configuration
# Docs: https://opencode.ai/v2/docs/console/harnesses/claude-code
# ------------------------------------------------------------------------------
ANTHROPIC_BASE_URL=https://opencode.ai/inference/anthropic
ANTHROPIC_AUTH_TOKEN=oc_sk_replace_with_your_opencode_key
ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-5-5
ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-5-5
ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-haiku-5-5
# ------------------------------------------------------------------------------
# 2. REQUIRED: Gmail IMAP (App Password) for 2FA/OTPs & Recruiter Replies
# Create a 16-char App Password at: https://myaccount.google.com/apppasswords
# ------------------------------------------------------------------------------
GMAIL_ADDRESS=[email protected]
GMAIL_APP_PASSWORD=xxxx xxxx xxxx xxxx
GMAIL_IMAP_HOST=imap.gmail.com
GMAIL_IMAP_PORT=993
# ------------------------------------------------------------------------------
# 3. Pre-Wired Defaults (No changes needed unless customizing)
# ------------------------------------------------------------------------------
PUBLIC_PORT=8080
GHCR_REPO=heretek-ai/job-bot
# Database & Encryption Secrets (pre-populated with valid 32-byte keys; feel free to rotate)
POSTGRES_USER=jobpilot
POSTGRES_PASSWORD=jobpilot_secret_password
POSTGRES_DB=jobpilot
DATABASE_URL=postgresql://jobpilot:jobpilot_secret_password@postgres:5432/jobpilot
SECRET_MASTER_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
ATS_VAULT_MASTER_SECRET=change_this_to_a_32_byte_random_master_secret_key!
ATS_DEFAULT_PASSWORD_PREFIX=JobBot#2026!
SESSION_TTL_DAYS=30
SUPER_ADMIN_EMAIL=[email protected]
CORS_ORIGINS=http://localhost:8080,http://127.0.0.1:8080,http://localhost:4100
APP_URL=http://localhost:8080
LOG_LEVEL=info
# Stealth Browser & Optional Secondary Firefox MCP
CLOAKBROWSER_CDP_URL=http://cloakbrowser:9222
CLOAKBROWSER_TIMEZONE=America/New_York
CLOAKBROWSER_LOCALE=en-US
BROWSER_PROXY_URL=
ENABLE_INVISIBLE_PLAYWRIGHT_FIREFOX_MCP=true
# Optional Paid Token CAPTCHA Solvers (Layer 2 fallback if CloakBrowser checkbox triggers grid)
CAPSOLVER_API_KEY=
TWOCAPTCHA_API_KEY=
+116
View File
@@ -0,0 +1,116 @@
name: Build & Publish Prepackaged Containers (GHCR)
on:
push:
branches:
- main
tags:
- "v*"
pull_request:
branches:
- main
workflow_dispatch:
env:
REGISTRY: ghcr.io
jobs:
# ============================================================================
# 1. Unit Test @job-bot/jobbot-mcp (Vault, AES-256-GCM, IMAP OTP, Captcha)
# ============================================================================
test-mcp:
name: Test jobbot-mcp
runs-on: ubuntu-latest
defaults:
run:
working-directory: mcp/jobbot-mcp
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js 22
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: mcp/jobbot-mcp/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build TypeScript
run: npm run build
- name: Run Unit Tests
run: npm test
# ============================================================================
# 2. Build & Push All 5 Prepackaged Images to GitHub Container Registry
# ============================================================================
build-and-push:
name: Build & Push (${{ matrix.image.name }})
needs: test-mcp
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
image:
- name: postgres
context: ./docker/postgres
dockerfile: ./docker/postgres/Dockerfile
- name: nginx
context: ./docker/nginx
dockerfile: ./docker/nginx/Dockerfile
- name: cloakbrowser
context: ./docker/cloakbrowser
dockerfile: ./docker/cloakbrowser/Dockerfile
- name: web
context: ./docker/web
dockerfile: ./docker/web/Dockerfile
- name: terminal
context: .
dockerfile: ./docker/terminal/Dockerfile
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Lowercase repository owner/name for GHCR
id: repo
run: |
echo "repo_lower=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry (GHCR)
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata (tags & labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ steps.repo.outputs.repo_lower }}/${{ matrix.image.name }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=sha-
type=semver,pattern={{version}}
- name: Build and push container image
uses: docker/build-push-action@v6
with:
context: ${{ matrix.image.context }}
file: ${{ matrix.image.dockerfile }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.image.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.image.name }}
+42
View File
@@ -0,0 +1,42 @@
# Job-Bot: Prepackaged Autonomous Job Application Stack
Everything comes **prebuilt and prewired** via GitHub Actions CI (`ghcr.io/heretek-ai/job-bot/*`). To run on any remote Docker server, the **only** thing you need to do is fill out `.env` and start the stack.
---
## 2-Step Remote Deployment
### 1. Create `.env`
Download `docker-compose.yml` and `.env.example` (or clone the repo) on your remote Docker machine:
```bash
cp .env.example .env
```
Fill in your **OpenCode** key/models and **Gmail App Password** in `.env`:
```dotenv
ANTHROPIC_BASE_URL=https://opencode.ai/inference/anthropic
ANTHROPIC_AUTH_TOKEN=oc_sk_...
ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-5-5
ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-5-5
ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-haiku-5-5
[email protected]
GMAIL_APP_PASSWORD=xxxx xxxx xxxx xxxx
```
### 2. Pull & Start the Prebuilt Stack
```bash
docker compose pull && docker compose up -d
```
Then open `http://<YOUR_REMOTE_SERVER_IP>:8080` in your browser!
- **Origin-Agnostic Web & Reverse Proxy**: The prebuilt `web` and `nginx` containers automatically resolve `window.location.origin` (`/api` and `/terminal/ws`), so the stack works on any IP address, Tailscale hostname, or custom domain without rebuilding images or editing CORS configs.
---
## How Passwords, CAPTCHAs, Email & Stealth Browsers Are Pre-Wired
| Capability | How It Works Out-of-the-Box |
| :--- | :--- |
| **Saving & Reusing Passwords** | **3-Layer Persistence**:<br>1. **`ats_get_or_create_credentials` & `ats_save_credentials` (`jobbot-mcp`)**: Encrypts passwords with AES-256-GCM (`ATS_VAULT_MASTER_SECRET`) and stores them per ATS tenant (`nvidia.wd5.myworkdayjobs.com`, `icims.com`, `taleo.net`, etc.) in PostgreSQL (`ats_credentials`).<br>2. **Auto-Sync to JobPilot Web UI**: Automatically syncs saved logins to `/api/credentials` so you can view/manage them in the dashboard.<br>3. **Browser Password Store (`CloakBrowser`)**: Runs with `--user-data-dir=/data/profile` and `--password-store=basic` on a persistent Docker volume. |
| **reCAPTCHA & Cloudflare Turnstile** | **Layer 1**: `CloakHQ/CloakBrowser` (C++-source-patched Chromium over CDP `:9222`) + optional `invisible_playwright_mcp` (C++-patched Firefox). Passes Cloudflare Turnstile and reCAPTCHA v3 natively.<br>**Layer 2**: `solve_captcha_challenge` MCP tool for CapSolver/2Captcha fallback. |
| **Email Management (2FA & Status)** | Uses your `GMAIL_ADDRESS` + `GMAIL_APP_PASSWORD` over IMAP:<br>- `gmail_wait_for_verification_code`: Extracts 4–8 digit security codes and domain-verified account activation links mid-application.<br>- `gmail_poll_application_updates`: Classifies confirmations, interviews, OAs, and rejections. |
| **GitHub Actions CI/CD** | [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) runs the `@job-bot/jobbot-mcp` unit test suite and builds/pushes `postgres`, `nginx`, `cloakbrowser`, `web`, and `terminal` images to `ghcr.io/heretek-ai/job-bot/*:latest`. |
+201
View File
@@ -0,0 +1,201 @@
name: job-bot
# ==============================================================================
# Prepackaged, Zero-Build Docker Compose Stack
# All containers are prebuilt by GitHub Actions CI to ghcr.io/heretek-ai/job-bot
# Remote usage:
# 1. cp .env.example .env && nano .env
# 2. docker compose pull && docker compose up -d
# ==============================================================================
services:
# ============================================================================
# 1. PostgreSQL 16 (Prepackaged with ATS Password Vault & IMAP Sync schema)
# ============================================================================
postgres:
image: ghcr.io/${GHCR_REPO:-heretek-ai/job-bot}/postgres:latest
build:
context: ./docker/postgres
dockerfile: Dockerfile
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-jobpilot}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-jobpilot_secret_password}
POSTGRES_DB: ${POSTGRES_DB:-jobpilot}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-jobpilot} -d ${POSTGRES_DB:-jobpilot}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# ============================================================================
# 2. Automatic Prisma Database Schema Migration (JobPilot API)
# ============================================================================
db-migrate:
image: ghcr.io/suxrobgm/jobpilot/api:latest
env_file: .env
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql://jobpilot:jobpilot_secret_password@postgres:5432/jobpilot}
depends_on:
postgres:
condition: service_healthy
command: ["bunx", "prisma", "db", "push", "--accept-data-loss", "--skip-generate"]
restart: "no"
# ============================================================================
# 3. JobPilot API Backend (Bun + Elysia + Prisma)
# ============================================================================
api:
image: ghcr.io/suxrobgm/jobpilot/api:latest
restart: unless-stopped
env_file: .env
environment:
NODE_ENV: production
PORT: 4101
DATABASE_URL: ${DATABASE_URL:-postgresql://jobpilot:jobpilot_secret_password@postgres:5432/jobpilot}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8080,http://127.0.0.1:8080,http://localhost:4100}
APP_URL: ${APP_URL:-http://localhost:8080}
ports:
- "${API_PORT:-4101}:4101"
volumes:
- api-storage:/app/apps/api/storage
- shared-workspace:/app/shared
depends_on:
postgres:
condition: service_healthy
db-migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://localhost:4101/api/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
interval: 20s
timeout: 5s
retries: 5
start_period: 20s
# ============================================================================
# 4. JobPilot Web UI (Prepackaged & Origin-Agnostic Next.js Standalone)
# ============================================================================
web:
image: ghcr.io/${GHCR_REPO:-heretek-ai/job-bot}/web:latest
build:
context: ./docker/web
dockerfile: Dockerfile
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 4100
HOSTNAME: 0.0.0.0
INTERNAL_API_URL: http://api:4101
ports:
- "${WEB_PORT:-4100}:4100"
depends_on:
api:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:4100').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
# ============================================================================
# 5. CloakBrowser: C++-Patched Stealth Chromium (CDP + Persistent Password Store)
# ============================================================================
cloakbrowser:
image: ghcr.io/${GHCR_REPO:-heretek-ai/job-bot}/cloakbrowser:latest
build:
context: ./docker/cloakbrowser
dockerfile: Dockerfile
restart: unless-stopped
shm_size: "2gb"
environment:
TZ: ${CLOAKBROWSER_TIMEZONE:-America/New_York}
CLOAK_LOCALE: ${CLOAKBROWSER_LOCALE:-en-US}
CLOAK_PROXY: ${BROWSER_PROXY_URL:-}
CDP_PORT: "9222"
ports:
- "127.0.0.1:9222:9222"
volumes:
- browser-profile:/data/profile
- shared-workspace:/app/shared
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9222/json/version || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 15s
# ============================================================================
# 6. Prepackaged Autonomous Terminal Worker
# - JobPilot.Terminal (.NET host on :4102)
# - Claude Code CLI wired to OpenCode (ANTHROPIC_* env vars)
# - Playwright MCP -> CloakBrowser CDP (:9222) + Invisible Playwright Firefox MCP
# - Career-Ops ATS Scanner & HTML->PDF Generator
# - jobbot-mcp (Gmail IMAP 2FA/OTP + ATS Password Vault + Captcha Solver)
# ============================================================================
terminal:
image: ghcr.io/${GHCR_REPO:-heretek-ai/job-bot}/terminal:latest
build:
context: .
dockerfile: ./docker/terminal/Dockerfile
restart: unless-stopped
shm_size: "1gb"
env_file: .env
environment:
JOBPILOT_API: http://api:4101
JOBPILOT_WEB: http://web:4100
DATABASE_URL: ${DATABASE_URL:-postgresql://jobpilot:jobpilot_secret_password@postgres:5432/jobpilot}
CLOAKBROWSER_CDP_URL: http://cloakbrowser:9222
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-https://opencode.ai/inference/anthropic}
ANTHROPIC_AUTH_TOKEN: ${ANTHROPIC_AUTH_TOKEN}
ANTHROPIC_DEFAULT_OPUS_MODEL: ${ANTHROPIC_DEFAULT_OPUS_MODEL:-claude-opus-5-5}
ANTHROPIC_DEFAULT_SONNET_MODEL: ${ANTHROPIC_DEFAULT_SONNET_MODEL:-claude-sonnet-5-5}
ANTHROPIC_DEFAULT_HAIKU_MODEL: ${ANTHROPIC_DEFAULT_HAIKU_MODEL:-claude-haiku-5-5}
ports:
- "${TERMINAL_PORT:-4102}:4102"
volumes:
- terminal-data:/root/.jobpilot
- claude-data:/root/.claude
- api-storage:/app/apps/api/storage
- shared-workspace:/app/shared
depends_on:
api:
condition: service_healthy
cloakbrowser:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:4102/healthz || exit 1"]
interval: 20s
timeout: 5s
retries: 5
start_period: 25s
# ============================================================================
# 7. Prepackaged Nginx Single-Origin Reverse Proxy (:8080)
# ============================================================================
nginx:
image: ghcr.io/${GHCR_REPO:-heretek-ai/job-bot}/nginx:latest
build:
context: ./docker/nginx
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "${PUBLIC_PORT:-8080}:8080"
depends_on:
web:
condition: service_healthy
api:
condition: service_healthy
terminal:
condition: service_healthy
volumes:
postgres-data:
api-storage:
browser-profile:
terminal-data:
claude-data:
shared-workspace:
+17
View File
@@ -0,0 +1,17 @@
FROM cloakhq/cloakbrowser:latest
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
socat \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /data/profile /app/shared/resumes
COPY entrypoint.sh /usr/local/bin/cloak-entrypoint.sh
RUN chmod +x /usr/local/bin/cloak-entrypoint.sh
EXPOSE 9222
ENTRYPOINT ["/usr/local/bin/cloak-entrypoint.sh"]
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
INTERNAL_CDP_PORT=9223
EXTERNAL_CDP_PORT="${CDP_PORT:-9222}"
echo "[cloakbrowser] Starting C++-patched stealth Chromium on internal CDP port ${INTERNAL_CDP_PORT}..."
python3 - <<'PY' &
import os, time
from cloakbrowser import launch
proxy = os.environ.get("CLOAK_PROXY") or None
timezone = os.environ.get("TZ") or "America/New_York"
locale = os.environ.get("CLOAK_LOCALE") or "en-US"
args = [
"--remote-debugging-address=127.0.0.1",
"--remote-debugging-port=9223",
"--user-data-dir=/data/profile",
"--password-store=basic",
"--enable-features=PasswordImport",
"--no-sandbox",
"--disable-dev-shm-usage",
]
browser = launch(
headless=True,
proxy=proxy,
timezone=timezone,
locale=locale,
args=args,
)
print("[cloakbrowser] Stealth Chromium launched with persistent /data/profile & password-store=basic on 127.0.0.1:9223", flush=True)
while True:
time.sleep(3600)
PY
# Wait for internal CDP port to come up
for i in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:${INTERNAL_CDP_PORT}/json/version" >/dev/null 2>&1; then
break
fi
sleep 1
done
echo "[cloakbrowser] Bridging 0.0.0.0:${EXTERNAL_CDP_PORT} -> 127.0.0.1:${INTERNAL_CDP_PORT} for Docker network..."
exec socat TCP-LISTEN:${EXTERNAL_CDP_PORT},fork,reuseaddr,bind=0.0.0.0 TCP:127.0.0.1:${INTERNAL_CDP_PORT}
+4
View File
@@ -0,0 +1,4 @@
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
+85
View File
@@ -0,0 +1,85 @@
upstream jobpilot_api {
server api:4101;
}
upstream jobpilot_web {
server web:4100;
}
upstream jobpilot_terminal {
server terminal:4102;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 8080;
server_name _;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
gzip_min_length 256;
client_max_body_size 25M;
# 1. JobPilot API (/api/*) - SSE unbuffered
# Normalizes Origin to http://localhost:8080 so any remote IP/domain works without editing CORS_ORIGINS
location /api/ {
proxy_pass http://jobpilot_api;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Origin "http://localhost:8080";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
# 2. Containerized JobPilot Terminal Host (/terminal/* -> terminal:4102/*)
# Normalizes Origin to http://localhost:4100 so OriginPolicy.cs always accepts remote browser WebSockets
location /terminal/ {
rewrite ^/terminal/(.*)$ /$1 break;
proxy_pass http://jobpilot_terminal;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Origin "http://localhost:4100";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 24h;
proxy_send_timeout 24h;
}
# 3. Next.js static assets
location /_next/static {
proxy_pass http://jobpilot_web;
proxy_http_version 1.1;
proxy_set_header Connection "";
expires 365d;
add_header Cache-Control "public, immutable";
}
# 4. JobPilot Web App (/)
location / {
proxy_pass http://jobpilot_web;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
+4
View File
@@ -0,0 +1,4 @@
FROM postgres:16-alpine
COPY init-vault.sql /docker-entrypoint-initdb.d/10-init-vault.sql
RUN chmod 644 /docker-entrypoint-initdb.d/10-init-vault.sql
+35
View File
@@ -0,0 +1,35 @@
-- ============================================================================
-- ATS Multi-Tenant Credential Vault & Gmail IMAP Sync Tables
-- Initialized automatically on first Postgres startup alongside JobPilot tables
-- ============================================================================
CREATE TABLE IF NOT EXISTS ats_credentials (
id SERIAL PRIMARY KEY,
portal_hostname VARCHAR(255) NOT NULL UNIQUE,
ats_family VARCHAR(64) NOT NULL DEFAULT 'generic',
company_name VARCHAR(255),
email VARCHAR(255) NOT NULL,
encrypted_password TEXT NOT NULL,
iv VARCHAR(64) NOT NULL,
auth_tag VARCHAR(64) NOT NULL,
account_verified BOOLEAN NOT NULL DEFAULT FALSE,
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ats_credentials_hostname ON ats_credentials(portal_hostname);
CREATE TABLE IF NOT EXISTS imap_processed_emails (
message_id VARCHAR(512) PRIMARY KEY,
uid BIGINT,
from_address VARCHAR(255),
subject TEXT,
classification VARCHAR(64),
verification_code VARCHAR(32),
verification_link TEXT,
board_domain VARCHAR(255),
received_at TIMESTAMPTZ,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_imap_board_domain ON imap_processed_emails(board_domain, received_at DESC);
+55
View File
@@ -0,0 +1,55 @@
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
ENV JOBPILOT_INSTALL_DIR=/root/.jobpilot
ENV PATH="/root/.jobpilot:/root/.local/bin:/root/.bun/bin:${PATH}"
# 1. System dependencies + Node.js 22 + Python 3 + ICU/libssl for .NET NativeAOT binary
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
jq \
unzip \
xz-utils \
python3 \
python3-pip \
python3-venv \
libicu74 \
libssl3t64 \
procps \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# 2. Install Bun & uv
RUN curl -fsSL https://bun.sh/install | bash \
&& curl -LsSf https://astral.sh/uv/install.sh | sh
# 3. Install Claude Code CLI & Playwright MCP globally
RUN npm install -g @anthropic-ai/claude-code@latest @playwright/mcp@latest
# 4. Install JobPilot Terminal Host binary + bundled plugin/ directory
RUN curl -fsSL https://raw.githubusercontent.com/suxrobGM/jobpilot/main/apps/terminal/install.sh | bash
# 5. Clone career-ops-hq/career-ops for zero-token ATS portal scanning & HTML->PDF generation
RUN git clone --depth 1 https://github.com/career-ops-hq/career-ops.git /opt/career-ops \
&& cd /opt/career-ops \
&& (npm install --omit=dev || true)
# 6. Build custom jobbot-mcp server (Gmail IMAP 2FA/OTP + ATS Password Vault + Captcha + Career-Ops bridge)
WORKDIR /opt/jobbot-mcp
COPY mcp/jobbot-mcp/package.json mcp/jobbot-mcp/tsconfig.json ./
RUN npm install
COPY mcp/jobbot-mcp/src ./src
RUN npm run build
# 7. Copy container entrypoint & skill enhancements
COPY docker/terminal/entrypoint.sh /usr/local/bin/terminal-entrypoint.sh
RUN chmod +x /usr/local/bin/terminal-entrypoint.sh \
&& mkdir -p /app/shared/resumes /root/.claude
WORKDIR /root/.jobpilot
EXPOSE 4102
ENTRYPOINT ["/usr/local/bin/terminal-entrypoint.sh"]
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALL_DIR="${JOBPILOT_INSTALL_DIR:-/root/.jobpilot}"
PLUGIN_DIR="${INSTALL_DIR}/plugin"
CLAUDE_SETTINGS_PLUGIN="${PLUGIN_DIR}/settings/claude.json"
CLAUDE_SETTINGS_HOME="/root/.claude/settings.json"
MCP_CONFIG_FILE="${PLUGIN_DIR}/.mcp.json"
echo "[terminal] Configuring OpenCode Claude Code Harness, Stealth Browser MCP & Password Vault..."
# 1. Configure Kestrel to listen on 0.0.0.0:4102 inside the container and allow remote origins
python3 - <<'PY'
import json, os
install_dir = os.environ.get("JOBPILOT_INSTALL_DIR", "/root/.jobpilot")
appsettings_path = os.path.join(install_dir, "appsettings.json")
origins = {
"http://localhost:4100",
"http://127.0.0.1:4100",
"http://localhost:8080",
"http://127.0.0.1:8080",
"http://web:4100",
}
for env_key in ("PUBLIC_URL", "APP_URL", "CORS_ORIGINS"):
val = os.environ.get(env_key, "")
for item in val.split(","):
item = item.strip().rstrip("/")
if item:
origins.add(item)
cfg = {
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://0.0.0.0:4102"
}
}
},
"Terminal": {
"AllowedOrigins": sorted(origins)
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
with open(appsettings_path, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2)
print(f"[terminal] Wrote {appsettings_path} with AllowedOrigins: {sorted(origins)}")
PY
# 2. Inject OpenCode Claude Code Harness settings into both ~/.claude/settings.json and plugin/settings/claude.json
# Reference: https://opencode.ai/v2/docs/console/harnesses/claude-code
python3 - <<'PY'
import json, os
install_dir = os.environ.get("JOBPILOT_INSTALL_DIR", "/root/.jobpilot")
plugin_claude_path = os.path.join(install_dir, "plugin", "settings", "claude.json")
home_claude_path = "/root/.claude/settings.json"
opencode_env = {
"ANTHROPIC_BASE_URL": os.environ.get("ANTHROPIC_BASE_URL", "https://opencode.ai/inference/anthropic"),
"ANTHROPIC_AUTH_TOKEN": os.environ.get("ANTHROPIC_AUTH_TOKEN", ""),
"ANTHROPIC_DEFAULT_OPUS_MODEL": os.environ.get("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-5-5"),
"ANTHROPIC_DEFAULT_SONNET_MODEL": os.environ.get("ANTHROPIC_DEFAULT_SONNET_MODEL", "claude-sonnet-5-5"),
"ANTHROPIC_DEFAULT_HAIKU_MODEL": os.environ.get("ANTHROPIC_DEFAULT_HAIKU_MODEL", "claude-haiku-5-5"),
}
if os.path.exists(plugin_claude_path):
with open(plugin_claude_path, "r", encoding="utf-8") as f:
plugin_cfg = json.load(f)
else:
plugin_cfg = {"model": "sonnet", "env": {}}
plugin_cfg.setdefault("env", {}).update(opencode_env)
os.makedirs(os.path.dirname(plugin_claude_path), exist_ok=True)
with open(plugin_claude_path, "w", encoding="utf-8") as f:
json.dump(plugin_cfg, f, indent=2)
os.makedirs(os.path.dirname(home_claude_path), exist_ok=True)
with open(home_claude_path, "w", encoding="utf-8") as f:
json.dump({"env": opencode_env}, f, indent=2)
print(f"[terminal] Configured OpenCode Claude Code harness ({opencode_env['ANTHROPIC_BASE_URL']}) with models: "
f"opus={opencode_env['ANTHROPIC_DEFAULT_OPUS_MODEL']}, "
f"sonnet={opencode_env['ANTHROPIC_DEFAULT_SONNET_MODEL']}, "
f"haiku={opencode_env['ANTHROPIC_DEFAULT_HAIKU_MODEL']}")
PY
# 3. Wire plugin/.mcp.json to CloakBrowser C++ Stealth Chromium over CDP + jobbot-mcp + optional Invisible Playwright Firefox
python3 - <<'PY'
import json, os
install_dir = os.environ.get("JOBPILOT_INSTALL_DIR", "/root/.jobpilot")
mcp_path = os.path.join(install_dir, "plugin", ".mcp.json")
cdp_url = os.environ.get("CLOAKBROWSER_CDP_URL", "http://cloakbrowser:9222")
mcp_servers = {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--cdp-endpoint",
cdp_url,
"--snapshot-mode",
"none"
]
},
"jobbot-mcp": {
"command": "node",
"args": ["/opt/jobbot-mcp/dist/index.js"],
"env": {
"DATABASE_URL": os.environ.get("DATABASE_URL", ""),
"JOBPILOT_API": os.environ.get("JOBPILOT_API", "http://api:4101"),
"GMAIL_ADDRESS": os.environ.get("GMAIL_ADDRESS", ""),
"GMAIL_APP_PASSWORD": os.environ.get("GMAIL_APP_PASSWORD", ""),
"GMAIL_IMAP_HOST": os.environ.get("GMAIL_IMAP_HOST", "imap.gmail.com"),
"GMAIL_IMAP_PORT": os.environ.get("GMAIL_IMAP_PORT", "993"),
"ATS_VAULT_MASTER_SECRET": os.environ.get("ATS_VAULT_MASTER_SECRET", ""),
"ATS_DEFAULT_PASSWORD_PREFIX": os.environ.get("ATS_DEFAULT_PASSWORD_PREFIX", "JobBot#2026!"),
"CLOAKBROWSER_CDP_URL": cdp_url,
"CAPSOLVER_API_KEY": os.environ.get("CAPSOLVER_API_KEY", ""),
"TWOCAPTCHA_API_KEY": os.environ.get("TWOCAPTCHA_API_KEY", ""),
"CAREER_OPS_DIR": "/opt/career-ops",
"SHARED_RESUMES_DIR": "/app/shared/resumes"
}
},
"upwork": {
"type": "http",
"url": "https://mcp.upwork.com/mcp"
}
}
if os.environ.get("ENABLE_INVISIBLE_PLAYWRIGHT_FIREFOX_MCP", "true").lower() == "true":
mcp_servers["invisible-firefox"] = {
"command": "uvx",
"args": ["invisible-playwright-mcp"]
}
with open(mcp_path, "w", encoding="utf-8") as f:
json.dump({"mcpServers": mcp_servers}, f, indent=2)
print(f"[terminal] Updated {mcp_path} with CloakBrowser CDP ({cdp_url}) and jobbot-mcp.")
PY
# 4. Pre-wire plugin/skills/_shared/auth.md, get-code, and apply skills for automatic password saving & IMAP OTPs
if [ -f "${PLUGIN_DIR}/skills/_shared/auth.md" ] && ! grep -q "ats_save_credentials" "${PLUGIN_DIR}/skills/_shared/auth.md"; then
cat >> "${PLUGIN_DIR}/skills/_shared/auth.md" << 'EOF'
## Self-Hosted Job-Bot Password Vault (`jobbot-mcp` Auto-Create & Save Rules)
1. **When `/api/credentials/resolve` returns `null`**:
- Do **not** skip login or stop. Immediately call `jobbot-mcp`'s `ats_get_or_create_credentials({ "portalUrl": "<current_url_or_domain>", "companyName": "<company>" })`.
- It returns `{ email, password, isNew }` encrypted and persisted in Postgres (and synced to `/api/credentials`).
2. **Whenever you create/register a new account, verify an account, or reset a password**:
- Always call `jobbot-mcp`'s `ats_save_credentials({ "portalUrl": "<current_url_or_domain>", "email": "<email>", "password": "<password>", "accountVerified": true })` so the credential is permanently saved in both the Postgres vault and the JobPilot Web UI!
EOF
fi
if [ -f "${PLUGIN_DIR}/skills/get-code/SKILL.md" ] && ! grep -q "gmail_wait_for_verification_code" "${PLUGIN_DIR}/skills/get-code/SKILL.md"; then
cat >> "${PLUGIN_DIR}/skills/get-code/SKILL.md" << 'EOF'
## Phase 0 (Preferred in Self-Hosted Job-Bot): Direct Gmail IMAP App-Password Tool
Before checking `/api/email/account` OAuth, if the `jobbot-mcp` tool `gmail_wait_for_verification_code` is available, call:
`gmail_wait_for_verification_code({ "boardDomain": "$BOARD_DOMAIN", "timeoutSeconds": 45 })`
If it returns a `verificationCode` or `verificationLink`, output that JSON immediately and exit.
EOF
fi
if [ -f "${PLUGIN_DIR}/skills/apply/SKILL.md" ] && ! grep -q "ats_get_or_create_credentials" "${PLUGIN_DIR}/skills/apply/SKILL.md"; then
cat >> "${PLUGIN_DIR}/skills/apply/SKILL.md" << 'EOF'
## Self-Hosted Job-Bot Extensions (ATS Password Vault & Career-Ops PDF)
- **Creating & Saving Passwords**: Whenever an ATS portal (Workday, iCIMS, Taleo, etc.) requires login or registration, call `ats_get_or_create_credentials` to get/generate the login, and call `ats_save_credentials` whenever a password is created, updated, or verified.
- **Verification Codes**: For email 2FA or Workday account activation links, call `gmail_wait_for_verification_code` from `jobbot-mcp`.
- **ATS-Clean PDF Generation**: Call `career_ops_compile_pdf` from `jobbot-mcp` to render an ATS-optimized single-page PDF into `/app/shared/resumes/`.
EOF
fi
echo "[terminal] Launching JobPilot Terminal Host on 0.0.0.0:4102..."
exec "${INSTALL_DIR}/jobpilot"
+34
View File
@@ -0,0 +1,34 @@
# Builds a prepackaged, origin-agnostic suxrobGM/jobpilot Web UI image.
# Browser requests dynamically use window.location.origin (/api and /terminal) via Nginx,
# so one prebuilt GHCR image works on ANY remote IP, Tailscale hostname, or domain!
FROM oven/bun:1-slim AS build
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates python3 && rm -rf /var/lib/apt/lists/*
RUN git clone --depth 1 https://github.com/suxrobGM/jobpilot.git /app
COPY patch-origin.py /tmp/patch-origin.py
RUN python3 /tmp/patch-origin.py
RUN bun install --frozen-lockfile --ignore-scripts
WORKDIR /app/apps/api
RUN DATABASE_URL="postgresql://build:build@localhost:5432/build" bun run db:generate
WORKDIR /app/apps/web
RUN bun run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/apps/web/.next/standalone ./
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build /app/apps/web/public ./apps/web/public
ENV PORT=4100
ENV HOSTNAME=0.0.0.0
ENV INTERNAL_API_URL=http://api:4101
EXPOSE 4100
CMD ["node", "apps/web/server.js"]
+57
View File
@@ -0,0 +1,57 @@
import sys
import urllib.request
def apply_patches(app_root="/app", dry_run=False):
base_url_content = """export const API_BASE_URL =
typeof window !== "undefined"
? window.location.origin
: (process.env.INTERNAL_API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://api:4101");
export const API_ORIGIN =
typeof window === "undefined"
? (process.env.INTERNAL_API_URL ?? API_BASE_URL)
: window.location.origin;
"""
old_block = (
'const TERMINAL_HTTP_URL = process.env.NEXT_PUBLIC_TERMINAL_URL ?? "http://localhost:4102";\n\n'
'export const TERMINAL_WS_URL = `${TERMINAL_HTTP_URL.replace(/^http/, "ws")}/ws`;'
)
new_block = (
'const TERMINAL_HTTP_URL =\n'
' typeof window !== "undefined"\n'
' ? `${window.location.origin}/terminal`\n'
' : (process.env.NEXT_PUBLIC_TERMINAL_URL ?? "http://terminal:4102");\n\n'
'export const TERMINAL_WS_URL =\n'
' typeof window !== "undefined"\n'
' ? `${window.location.origin.replace(/^http/, "ws")}/terminal/ws`\n'
' : `${TERMINAL_HTTP_URL.replace(/^http/, "ws")}/ws`;'
)
if dry_run:
u = "https://raw.githubusercontent.com/suxrobGM/jobpilot/main/apps/web/src/lib/terminal.ts"
req = urllib.request.Request(u, headers={"User-Agent": "Mozilla/5.0"})
src = urllib.request.urlopen(req, timeout=10).read().decode("utf-8").replace("\r\n", "\n")
assert old_block in src, "Expected TERMINAL_HTTP_URL block in upstream terminal.ts"
print("Dry-run verified: patch-origin.py matches upstream suxrobGM/jobpilot 100%.")
return
base_url_path = f"{app_root}/apps/web/src/api/base-url.ts"
with open(base_url_path, "w", encoding="utf-8") as f:
f.write(base_url_content)
terminal_path = f"{app_root}/apps/web/src/lib/terminal.ts"
with open(terminal_path, "r", encoding="utf-8") as f:
src = f.read().replace("\r\n", "\n")
assert old_block in src, f"Expected TERMINAL_HTTP_URL block in {terminal_path}"
src = src.replace(old_block, new_block, 1)
with open(terminal_path, "w", encoding="utf-8") as f:
f.write(src)
print("Successfully patched base-url.ts and terminal.ts for dynamic origin resolution.")
if __name__ == "__main__":
apply_patches(dry_run=("--dry-run" in sys.argv))
+2581
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@job-bot/jobbot-mcp",
"version": "1.0.0",
"description": "Self-hosted Job-Bot MCP Server: Multi-Tenant ATS Password Vault, Gmail IMAP App-Password 2FA/OTP & Status Reader, Captcha Solver, and Career-Ops ATS/PDF Bridge",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"test": "node --import tsx --test tests/*.test.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
"imapflow": "^1.0.168",
"mailparser": "^3.7.2",
"pg": "^8.14.1",
"playwright-core": "^1.51.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/mailparser": "^3.4.6",
"@types/node": "^22.13.10",
"@types/pg": "^8.11.11",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
}
}
+192
View File
@@ -0,0 +1,192 @@
export interface CaptchaSolveRequest {
pageUrl: string;
siteKey: string;
captchaType: "recaptcha_v2" | "recaptcha_v3" | "hcaptcha" | "turnstile";
}
export interface CaptchaSolveResult {
solved: boolean;
provider: string;
token: string | null;
error?: string;
}
/**
* Layer 2 CAPTCHA token solver fallback (CapSolver / 2Captcha)
* Triggered only when an ATS form presents a hard image-grid challenge that survives
* Layer 1 (CloakBrowser C++-patched Chromium + native checkbox click).
*/
export async function solveCaptchaWithTokenService(
req: CaptchaSolveRequest
): Promise<CaptchaSolveResult> {
const capsolverKey = process.env.CAPSOLVER_API_KEY?.trim();
const twocaptchaKey = process.env.TWOCAPTCHA_API_KEY?.trim();
if (capsolverKey) {
return solveViaCapSolver(capsolverKey, req);
}
if (twocaptchaKey) {
return solveViaTwoCaptcha(twocaptchaKey, req);
}
return {
solved: false,
provider: "none",
token: null,
error:
"No CAPSOLVER_API_KEY or TWOCAPTCHA_API_KEY configured in .env. Relying on CloakBrowser C++ stealth checkbox/audio bypass.",
};
}
async function solveViaCapSolver(
apiKey: string,
req: CaptchaSolveRequest
): Promise<CaptchaSolveResult> {
const taskTypeMap: Record<CaptchaSolveRequest["captchaType"], string> = {
recaptcha_v2: "ReCaptchaV2TaskProxyLess",
recaptcha_v3: "ReCaptchaV3TaskProxyLess",
hcaptcha: "HCaptchaTaskProxyless",
turnstile: "AntiTurnstileTaskProxyLess",
};
const createResp = await fetch("https://api.capsolver.com/createTask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientKey: apiKey,
task: {
type: taskTypeMap[req.captchaType],
websiteURL: req.pageUrl,
websiteKey: req.siteKey,
},
}),
});
const createData = (await createResp.json()) as {
errorId?: number;
errorDescription?: string;
taskId?: string;
};
if (createData.errorId || !createData.taskId) {
return {
solved: false,
provider: "capsolver",
token: null,
error: createData.errorDescription || "Failed to create CapSolver task",
};
}
for (let i = 0; i < 24; i++) {
await new Promise((r) => setTimeout(r, 3000));
const pollResp = await fetch("https://api.capsolver.com/getTaskResult", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientKey: apiKey,
taskId: createData.taskId,
}),
});
const pollData = (await pollResp.json()) as {
status?: string;
solution?: { gRecaptchaResponse?: string; token?: string };
errorDescription?: string;
};
if (pollData.status === "ready") {
const token =
pollData.solution?.gRecaptchaResponse || pollData.solution?.token || null;
return {
solved: Boolean(token),
provider: "capsolver",
token,
};
}
if (pollData.status === "failed") {
return {
solved: false,
provider: "capsolver",
token: null,
error: pollData.errorDescription || "CapSolver task failed",
};
}
}
return {
solved: false,
provider: "capsolver",
token: null,
error: "CapSolver timed out after 72 seconds",
};
}
async function solveViaTwoCaptcha(
apiKey: string,
req: CaptchaSolveRequest
): Promise<CaptchaSolveResult> {
const methodMap: Record<CaptchaSolveRequest["captchaType"], string> = {
recaptcha_v2: "userrecaptcha",
recaptcha_v3: "userrecaptcha",
hcaptcha: "hcaptcha",
turnstile: "turnstile",
};
const params = new URLSearchParams({
key: apiKey,
method: methodMap[req.captchaType],
pageurl: req.pageUrl,
json: "1",
});
if (req.captchaType === "hcaptcha" || req.captchaType === "turnstile") {
params.set("sitekey", req.siteKey);
} else {
params.set("googlekey", req.siteKey);
if (req.captchaType === "recaptcha_v3") {
params.set("version", "v3");
}
}
const inResp = await fetch(`https://2captcha.com/in.php?${params.toString()}`);
const inData = (await inResp.json()) as { status: number; request: string };
if (inData.status !== 1) {
return {
solved: false,
provider: "2captcha",
token: null,
error: inData.request,
};
}
const taskId = inData.request;
for (let i = 0; i < 24; i++) {
await new Promise((r) => setTimeout(r, 4000));
const resResp = await fetch(
`https://2captcha.com/res.php?key=${encodeURIComponent(
apiKey
)}&action=get&id=${encodeURIComponent(taskId)}&json=1`
);
const resData = (await resResp.json()) as { status: number; request: string };
if (resData.status === 1) {
return {
solved: true,
provider: "2captcha",
token: resData.request,
};
}
if (resData.request !== "CAPCHA_NOT_READY") {
return {
solved: false,
provider: "2captcha",
token: null,
error: resData.request,
};
}
}
return {
solved: false,
provider: "2captcha",
token: null,
error: "2Captcha timed out",
};
}
+71
View File
@@ -0,0 +1,71 @@
import fs from "node:fs/promises";
import path from "node:path";
import { chromium } from "playwright-core";
/**
* Compiles an ATS-clean HTML or Markdown resume into a single-page PDF using
* Career-Ops's styling conventions and our CloakBrowser Chromium CDP server.
*/
export async function compileAtsPdfViaCloakBrowser(params: {
htmlContent: string;
outputFilename: string;
cdpUrl?: string;
outputDir?: string;
}): Promise<{ pdfPath: string; bytes: number }> {
const cdpUrl =
params.cdpUrl || process.env.CLOAKBROWSER_CDP_URL || "http://cloakbrowser:9222";
const outputDir =
params.outputDir || process.env.SHARED_RESUMES_DIR || "/app/shared/resumes";
await fs.mkdir(outputDir, { recursive: true });
const safeName = params.outputFilename
.replace(/[^a-zA-Z0-9._-]/g, "_")
.replace(/\.pdf$/i, "");
const pdfPath = path.join(outputDir, `${safeName}.pdf`);
const fullHtml = params.htmlContent.includes("<html")
? params.htmlContent
: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<style>
@page { size: letter; margin: 0.45in 0.5in; }
body {
font-family: "Calibri", "Helvetica Neue", Arial, sans-serif;
font-size: 10pt;
line-height: 1.35;
color: #111111;
margin: 0;
padding: 0;
}
h1 { font-size: 18pt; margin: 0 0 4pt 0; }
h2 { font-size: 11pt; text-transform: uppercase; border-bottom: 1px solid #333; margin: 10pt 0 4pt 0; padding-bottom: 2pt; }
h3 { font-size: 10.5pt; margin: 6pt 0 2pt 0; }
p, ul { margin: 2pt 0 4pt 0; }
ul { padding-left: 16pt; }
li { margin-bottom: 2pt; }
</style>
</head>
<body>
${params.htmlContent}
</body>
</html>`;
const browser = await chromium.connectOverCDP(cdpUrl);
try {
const context = await browser.newContext();
const page = await context.newPage();
await page.setContent(fullHtml, { waitUntil: "networkidle" });
const pdfBuffer = await page.pdf({
format: "Letter",
printBackground: true,
margin: { top: "0.45in", bottom: "0.45in", left: "0.5in", right: "0.5in" },
});
await fs.writeFile(pdfPath, pdfBuffer);
await context.close();
return { pdfPath, bytes: pdfBuffer.byteLength };
} finally {
await browser.close();
}
}
+342
View File
@@ -0,0 +1,342 @@
import { ImapFlow } from "imapflow";
import { simpleParser } from "mailparser";
export interface ExtractedVerification {
verificationCode: string | null;
verificationLink: string | null;
subject: string;
fromAddress: string;
receivedAt: string;
}
export interface ClassifiedEmailUpdate {
messageId: string;
fromAddress: string;
subject: string;
receivedAt: string;
classification:
| "verification"
| "confirmation"
| "interview_invite"
| "assessment_oa"
| "rejection"
| "other";
verificationCode: string | null;
verificationLink: string | null;
snippet: string;
}
/**
* Safely extracts a 4-8 character verification OTP code and/or a magic verification link
* whose hostname belongs to the target boardDomain (preventing phishing or tracking links).
*/
export function extractVerificationFromContent(
subject: string,
textBody: string,
htmlBody: string,
boardDomain: string
): { verificationCode: string | null; verificationLink: string | null } {
const combinedText = `${subject}\n${textBody}`;
const cleanDomain = boardDomain
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/\/.*$/, "");
// 1. Extract explicit security/verification codes (Greenhouse 8-char alphanumeric or 4-8 digit OTPs)
let verificationCode: string | null = null;
const explicitPatterns = [
/(?:security|verification|confirmation|login|passcode|one-time)\s+code(?:\s+is)?[\s:–-]+([A-Z0-9]{4,8})\b/i,
/\bcode[\s:–-]+([A-Z0-9]{6,8})\b/i,
/\b(\d{6})\b/,
/\b(\d{4,8})\b/,
];
for (const regex of explicitPatterns) {
const match = combinedText.match(regex);
if (match && match[1]) {
const candidate = match[1].trim();
// Avoid matching years like 2025/2026 unless explicitly labeled
if (!/^(19\d\d|20[2-3]\d)$/.test(candidate)) {
verificationCode = candidate;
break;
}
}
}
// 2. Extract verification/activation URLs scoped to the target board domain (or known ATS email routers)
let verificationLink: string | null = null;
const urlRegex = /https?:\/\/[^\s"'<>)+]+/gi;
const allUrls = [
...(textBody.match(urlRegex) ?? []),
...(htmlBody.match(urlRegex) ?? []),
];
// Allowed base domains for the target board
const baseDomainParts = cleanDomain.split(".");
const rootDomain =
baseDomainParts.length >= 2
? baseDomainParts.slice(-2).join(".")
: cleanDomain;
for (const rawUrl of allUrls) {
const cleanedUrl = rawUrl.replace(/&amp;/g, "&").replace(/[.,;!?]+$/, "");
try {
const parsed = new URL(cleanedUrl);
const host = parsed.hostname.toLowerCase();
const domainMatches =
!cleanDomain ||
host === cleanDomain ||
host.endsWith(`.${cleanDomain}`) ||
host === rootDomain ||
host.endsWith(`.${rootDomain}`) ||
(rootDomain.includes("workday") &&
(host.includes("myworkdayjobs.com") || host.includes("myworkdaysite.com")));
if (!domainMatches) {
continue;
}
const fullLower = cleanedUrl.toLowerCase();
if (
fullLower.includes("verify") ||
fullLower.includes("confirm") ||
fullLower.includes("activate") ||
fullLower.includes("token") ||
fullLower.includes("magic") ||
fullLower.includes("auth")
) {
verificationLink = cleanedUrl;
break;
}
} catch {
// Ignore malformed URLs
}
}
return { verificationCode, verificationLink };
}
/**
* Classifies a job-related email into pipeline stages.
*/
export function classifyJobEmail(
subject: string,
bodyText: string
): ClassifiedEmailUpdate["classification"] {
const text = `${subject}\n${bodyText}`.toLowerCase();
if (
/\b(verification code|security code|verify your email|activate your account|one-time passcode)\b/.test(
text
)
) {
return "verification";
}
if (
/\b(unfortunately|not moving forward|other candidates|will not be proceeding|regret to inform|decided to pursue other)\b/.test(
text
)
) {
return "rejection";
}
if (
/\b(hackerrank|codility|codesignal|online assessment|take-home|technical assessment|coding challenge)\b/.test(
text
)
) {
return "assessment_oa";
}
if (
/\b(schedule an interview|phone screen|invite you to interview|availability for a call|chat with our recruiting|calendly\.com)\b/.test(
text
)
) {
return "interview_invite";
}
if (
/\b(thank you for applying|application received|we have received your application|thanks for applying)\b/.test(
text
)
) {
return "confirmation";
}
return "other";
}
function createImapClient(): ImapFlow {
const user = process.env.GMAIL_ADDRESS || "";
const pass = (process.env.GMAIL_APP_PASSWORD || "").replace(/\s+/g, "");
const host = process.env.GMAIL_IMAP_HOST || "imap.gmail.com";
const port = Number(process.env.GMAIL_IMAP_PORT || 993);
if (!user || !pass) {
throw new Error(
"GMAIL_ADDRESS and GMAIL_APP_PASSWORD must be set in .env to use Gmail IMAP tools."
);
}
return new ImapFlow({
host,
port,
secure: true,
auth: { user, pass },
logger: false,
});
}
export async function waitForVerificationEmail(params: {
boardDomain: string;
timeoutSeconds?: number;
pollIntervalSeconds?: number;
}): Promise<ExtractedVerification | null> {
const timeoutMs = Math.min(Math.max(params.timeoutSeconds ?? 45, 5), 120) * 1000;
const pollMs = (params.pollIntervalSeconds ?? 5) * 1000;
const deadline = Date.now() + timeoutMs;
const sinceWindow = new Date(Date.now() - 10 * 60 * 1000); // Look back 10 minutes
while (Date.now() <= deadline) {
const client = createImapClient();
try {
await client.connect();
const lock = await client.getMailboxLock("INBOX");
try {
const uids = await client.search({ since: sinceWindow }, { uid: true });
if (uids && uids.length > 0) {
const recentUids = uids.slice(-15).reverse();
for (const uid of recentUids) {
const msg = await client.fetchOne(
String(uid),
{ source: true, envelope: true, internalDate: true },
{ uid: true }
);
if (!msg || !msg.source) continue;
const parsed = await simpleParser(msg.source);
const subject = parsed.subject || "";
const fromAddress = parsed.from?.value?.[0]?.address || "";
const textBody = parsed.text || "";
const htmlBody = typeof parsed.html === "string" ? parsed.html : "";
const domainLower = params.boardDomain.toLowerCase().replace(/^https?:\/\//, "");
const baseKeyword = domainLower.split(".")[0];
const haystack = `${fromAddress} ${subject} ${textBody}`.toLowerCase();
if (
domainLower &&
!haystack.includes(baseKeyword) &&
!haystack.includes("greenhouse") &&
!haystack.includes("workday") &&
!haystack.includes("ashby") &&
!haystack.includes("lever")
) {
continue;
}
const { verificationCode, verificationLink } =
extractVerificationFromContent(
subject,
textBody,
htmlBody,
params.boardDomain
);
if (verificationCode || verificationLink) {
return {
verificationCode,
verificationLink,
subject,
fromAddress,
receivedAt: (parsed.date || new Date()).toISOString(),
};
}
}
}
} finally {
lock.release();
}
await client.logout();
} catch (err) {
try {
await client.logout();
} catch {
// ignore logout error
}
throw err;
}
if (Date.now() + pollMs > deadline) break;
await new Promise((r) => setTimeout(r, pollMs));
}
return null;
}
export async function pollRecentJobEmails(
sinceHours = 24
): Promise<ClassifiedEmailUpdate[]> {
const client = createImapClient();
const sinceDate = new Date(Date.now() - sinceHours * 3600 * 1000);
const results: ClassifiedEmailUpdate[] = [];
try {
await client.connect();
const lock = await client.getMailboxLock("INBOX");
try {
const uids = await client.search({ since: sinceDate }, { uid: true });
if (uids && uids.length > 0) {
const recentUids = uids.slice(-30).reverse();
for (const uid of recentUids) {
const msg = await client.fetchOne(
String(uid),
{ source: true, envelope: true },
{ uid: true }
);
if (!msg || !msg.source) continue;
const parsed = await simpleParser(msg.source);
const subject = parsed.subject || "";
const fromAddress = parsed.from?.value?.[0]?.address || "";
const textBody = parsed.text || "";
const htmlBody = typeof parsed.html === "string" ? parsed.html : "";
const classification = classifyJobEmail(subject, textBody);
const senderDomain = fromAddress.split("@")[1] || "";
const { verificationCode, verificationLink } =
classification === "verification"
? extractVerificationFromContent(
subject,
textBody,
htmlBody,
senderDomain
)
: { verificationCode: null, verificationLink: null };
results.push({
messageId: parsed.messageId || `uid-${uid}`,
fromAddress,
subject,
receivedAt: (parsed.date || new Date()).toISOString(),
classification,
verificationCode,
verificationLink,
snippet: textBody.slice(0, 280).replace(/\s+/g, " ").trim(),
});
}
}
} finally {
lock.release();
}
await client.logout();
} catch (err) {
try {
await client.logout();
} catch {
// ignore
}
throw err;
}
return results;
}
+236
View File
@@ -0,0 +1,236 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import pg from "pg";
import { z } from "zod";
import {
getOrCreateAtsCredentials,
saveAtsCredentials,
listAtsCredentials,
} from "./vault.js";
import {
waitForVerificationEmail,
pollRecentJobEmails,
} from "./imap.js";
import { solveCaptchaWithTokenService } from "./captcha.js";
import { compileAtsPdfViaCloakBrowser } from "./career-ops.js";
const { Pool } = pg;
const dbUrl = process.env.DATABASE_URL?.trim();
const pool = dbUrl ? new Pool({ connectionString: dbUrl }) : null;
const server = new McpServer({
name: "jobbot-mcp",
version: "1.1.0",
});
// 1. Multi-Tenant ATS Password Vault: Get or Auto-Create Credential
server.tool(
"ats_get_or_create_credentials",
"Retrieves an existing login or generates & saves a new AES-256-GCM encrypted login credential for a specific ATS tenant domain (e.g., nvidia.wd5.myworkdayjobs.com, icims.com, taleo.net).",
{
portalUrl: z.string().describe("Full URL or hostname of the ATS login/registration page"),
companyName: z.string().optional().describe("Company name for this ATS portal"),
emailOverride: z.string().optional().describe("Optional email override; defaults to GMAIL_ADDRESS"),
},
async ({ portalUrl, companyName, emailOverride }) => {
const email = emailOverride || process.env.GMAIL_ADDRESS || "[email protected]";
const masterSecret =
process.env.ATS_VAULT_MASTER_SECRET || "default-jobbot-master-secret-32b!";
const passwordPrefix = process.env.ATS_DEFAULT_PASSWORD_PREFIX || "JobBot#2026!";
const creds = await getOrCreateAtsCredentials(pool, {
portalUrl,
companyName,
email,
masterSecret,
passwordPrefix,
});
return {
content: [
{
type: "text",
text: JSON.stringify(creds, null, 2),
},
],
};
}
);
// 2. Multi-Tenant ATS Password Vault: Explicitly Save or Update Password
server.tool(
"ats_save_credentials",
"Explicitly saves or updates a password for an ATS portal or job board in the encrypted Postgres vault AND syncs it to JobPilot's /api/credentials store. Call this after creating an account, resetting a password, or when the user provides a login.",
{
portalUrl: z.string().describe("Full URL or hostname of the ATS portal or job board"),
email: z.string().optional().describe("Account email address; defaults to GMAIL_ADDRESS"),
password: z.string().describe("The password to save and encrypt"),
companyName: z.string().optional().describe("Company or board name"),
accountVerified: z
.boolean()
.optional()
.default(true)
.describe("Whether the account has been verified/activated"),
},
async ({ portalUrl, email, password, companyName, accountVerified }) => {
const effectiveEmail = email || process.env.GMAIL_ADDRESS || "[email protected]";
const masterSecret =
process.env.ATS_VAULT_MASTER_SECRET || "default-jobbot-master-secret-32b!";
const saved = await saveAtsCredentials(pool, {
portalUrl,
companyName,
email: effectiveEmail,
password,
masterSecret,
accountVerified,
});
return {
content: [
{
type: "text",
text: JSON.stringify(saved, null, 2),
},
],
};
}
);
// 3. Multi-Tenant ATS Password Vault: List Saved Portals
server.tool(
"ats_list_credentials",
"Lists all ATS portals and job boards that have saved credentials in the vault (without exposing plaintext passwords).",
{},
async () => {
const items = await listAtsCredentials(pool);
return {
content: [
{
type: "text",
text: JSON.stringify({ count: items.length, items }, null, 2),
},
],
};
}
);
// 4. Real-Time Gmail IMAP 2FA / OTP / Magic Link Reader
server.tool(
"gmail_wait_for_verification_code",
"Polls Gmail via IMAP (App Password) for a recent 4-8 digit security/verification code or account activation link from the target ATS domain (Greenhouse, Workday, Ashby, Lever, etc.).",
{
boardDomain: z
.string()
.describe("Target ATS or company domain (e.g., workday.com, greenhouse.io, ashbyhq.com)"),
timeoutSeconds: z
.number()
.optional()
.default(45)
.describe("Maximum seconds to wait for the verification email (5-120)"),
},
async ({ boardDomain, timeoutSeconds }) => {
const result = await waitForVerificationEmail({
boardDomain,
timeoutSeconds,
});
return {
content: [
{
type: "text",
text: JSON.stringify(result ?? {}, null, 2),
},
],
};
}
);
// 5. Gmail IMAP Pipeline Status Scanner
server.tool(
"gmail_poll_application_updates",
"Scans recent Gmail inbox messages via IMAP and classifies recruiter replies into confirmation, interview_invite, assessment_oa, rejection, or verification.",
{
sinceHours: z
.number()
.optional()
.default(24)
.describe("How many hours back to scan in the inbox"),
},
async ({ sinceHours }) => {
const updates = await pollRecentJobEmails(sinceHours);
return {
content: [
{
type: "text",
text: JSON.stringify({ count: updates.length, items: updates }, null, 2),
},
],
};
}
);
// 6. Layer 2 CAPTCHA Token Solver Fallback
server.tool(
"solve_captcha_challenge",
"Solves a reCAPTCHA v2/v3, hCaptcha, or Cloudflare Turnstile challenge via CapSolver/2Captcha token service if CloakBrowser C++ stealth checkbox click triggers an image challenge.",
{
pageUrl: z.string().describe("Full URL of the page containing the CAPTCHA"),
siteKey: z.string().describe("The data-sitekey or k= parameter of the CAPTCHA widget"),
captchaType: z
.enum(["recaptcha_v2", "recaptcha_v3", "hcaptcha", "turnstile"])
.describe("Type of CAPTCHA widget"),
},
async ({ pageUrl, siteKey, captchaType }) => {
const res = await solveCaptchaWithTokenService({
pageUrl,
siteKey,
captchaType,
});
return {
content: [
{
type: "text",
text: JSON.stringify(res, null, 2),
},
],
};
}
);
// 7. Career-Ops ATS-Clean HTML-to-PDF Resume Compiler
server.tool(
"career_ops_compile_pdf",
"Compiles tailored resume HTML into an ATS-friendly single-page PDF in /app/shared/resumes using the CloakBrowser Chromium CDP instance.",
{
htmlContent: z.string().describe("Tailored resume body in clean HTML"),
outputFilename: z
.string()
.describe("Target filename (e.g., Derek_Resume_Stripe_Backend.pdf)"),
},
async ({ htmlContent, outputFilename }) => {
const res = await compileAtsPdfViaCloakBrowser({
htmlContent,
outputFilename,
});
return {
content: [
{
type: "text",
text: JSON.stringify(res, null, 2),
},
],
};
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((err) => {
console.error("[jobbot-mcp] Fatal error:", err);
process.exit(1);
});
+422
View File
@@ -0,0 +1,422 @@
import crypto from "node:crypto";
import type { Pool } from "pg";
export interface AtsCredentialRecord {
portalHostname: string;
atsFamily: string;
companyName: string | null;
email: string;
password: string;
isNew: boolean;
accountVerified: boolean;
syncedToJobPilotApi?: boolean;
}
export interface AtsCredentialSummary {
portalHostname: string;
atsFamily: string;
companyName: string | null;
email: string;
accountVerified: boolean;
lastUsedAt: string;
}
interface StoredVaultEntry {
portalHostname: string;
atsFamily: string;
companyName: string | null;
email: string;
encryptedPassword: string;
iv: string;
authTag: string;
accountVerified: boolean;
lastUsedAt: string;
}
// In-memory fallback store used when DATABASE_URL is not connected (e.g. local unit tests)
const memoryVault = new Map<string, StoredVaultEntry>();
/**
* Normalizes any ATS URL into its tenant-specific hostname and detects the ATS family.
* E.g. https://nvidia.wd5.myworkdayjobs.com/en-US/External/job/123 -> { hostname: 'nvidia.wd5.myworkdayjobs.com', atsFamily: 'workday' }
*/
export function parsePortalIdentity(portalUrlOrHost: string): {
hostname: string;
atsFamily: string;
} {
let raw = portalUrlOrHost.trim();
if (!/^https?:\/\//i.test(raw)) {
raw = `https://${raw}`;
}
const url = new URL(raw);
const hostname = url.hostname.toLowerCase();
let atsFamily = "generic";
if (
hostname.includes("myworkdayjobs.com") ||
hostname.includes("myworkdaysite.com") ||
hostname.endsWith("workday.com")
) {
atsFamily = "workday";
} else if (hostname.includes("icims.com")) {
atsFamily = "icims";
} else if (hostname.includes("taleo.net") || hostname.includes("oraclecloud.com")) {
atsFamily = "oracle_taleo";
} else if (
hostname.includes("successfactors.com") ||
hostname.includes("successfactors.eu")
) {
atsFamily = "successfactors";
} else if (hostname.includes("greenhouse.io")) {
atsFamily = "greenhouse";
} else if (hostname.includes("lever.co")) {
atsFamily = "lever";
} else if (hostname.includes("ashbyhq.com")) {
atsFamily = "ashby";
} else if (hostname.includes("smartrecruiters.com")) {
atsFamily = "smartrecruiters";
}
return { hostname, atsFamily };
}
/**
* Generates a strong password that satisfies strict enterprise ATS password policies
* (Workday, iCIMS, Oracle/Taleo, SuccessFactors):
* - 16-20 chars
* - Uppercase, lowercase, digits, special symbol (!@#$%^&*)
*/
export function generateCompliantAtsPassword(
hostname: string,
masterSecret: string,
prefix = "JobBot#2026!"
): string {
const hmac = crypto
.createHmac("sha256", masterSecret)
.update(`ats-pwd-v1:${hostname}`)
.digest();
const uppers = "ABCDEFGHJKLMNPQRSTUVWXYZ";
const lowers = "abcdefghijkmnopqrstuvwxyz";
const digits = "23456789";
const specials = "!@#$%&*?";
const pick = (charset: string, byteIndex: number) =>
charset[hmac[byteIndex % hmac.length] % charset.length];
const suffix = [
pick(uppers, 0),
pick(lowers, 1),
pick(digits, 2),
pick(specials, 3),
pick(uppers, 4),
pick(lowers, 5),
pick(digits, 6),
pick(lowers, 7),
].join("");
const cleanPrefix = prefix.replace(/\s+/g, "") || "JobBot#2026!";
return `${cleanPrefix}${suffix}`;
}
function deriveKey(masterSecret: string): Buffer {
return crypto.createHash("sha256").update(masterSecret).digest();
}
export function encryptPassword(
plainText: string,
masterSecret: string
): {
encryptedPassword: string;
iv: string;
authTag: string;
} {
const key = deriveKey(masterSecret);
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const encrypted = Buffer.concat([
cipher.update(plainText, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return {
encryptedPassword: encrypted.toString("base64"),
iv: iv.toString("base64"),
authTag: authTag.toString("base64"),
};
}
export function decryptPassword(
encryptedPassword: string,
ivBase64: string,
authTagBase64: string,
masterSecret: string
): string {
const key = deriveKey(masterSecret);
const iv = Buffer.from(ivBase64, "base64");
const authTag = Buffer.from(authTagBase64, "base64");
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(Buffer.from(encryptedPassword, "base64")),
decipher.final(),
]);
return decrypted.toString("utf8");
}
/**
* Best-effort sync to JobPilot's native /api/credentials endpoint so saved passwords
* also appear in the JobPilot Web UI under Credentials.
*/
export async function syncCredentialToJobPilotApi(params: {
hostname: string;
email: string;
password: string;
}): Promise<boolean> {
const apiUrl = (process.env.JOBPILOT_API || "http://api:4101").replace(/\/+$/, "");
const apiToken = process.env.JOBPILOT_API_TOKEN?.trim();
if (!apiToken) {
return false;
}
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${apiToken}`,
};
try {
// 1. Check if a board credential already resolves for this domain
const resolveResp = await fetch(
`${apiUrl}/api/credentials/resolve?domain=${encodeURIComponent(params.hostname)}`,
{ headers }
);
if (resolveResp.ok) {
const body = (await resolveResp.json()) as {
data?: { id?: string; kind?: string } | null;
};
if (body?.data?.id && body.data.kind === "board") {
const patchResp = await fetch(
`${apiUrl}/api/credentials/${encodeURIComponent(body.data.id)}`,
{
method: "PATCH",
headers,
body: JSON.stringify({
email: params.email,
password: params.password,
}),
}
);
return patchResp.ok;
}
}
// 2. Otherwise create a new board credential
const createResp = await fetch(`${apiUrl}/api/credentials`, {
method: "POST",
headers,
body: JSON.stringify({
kind: "board",
board: params.hostname,
email: params.email,
password: params.password,
}),
});
return createResp.ok;
} catch {
return false;
}
}
/**
* Retrieves an existing ATS credential for a portal or generates & saves a new one.
*/
export async function getOrCreateAtsCredentials(
pool: Pool | null,
params: {
portalUrl: string;
companyName?: string;
email: string;
masterSecret: string;
passwordPrefix?: string;
}
): Promise<AtsCredentialRecord> {
const { hostname, atsFamily } = parsePortalIdentity(params.portalUrl);
if (pool) {
const existing = await pool.query(
`SELECT portal_hostname, ats_family, company_name, email, encrypted_password, iv, auth_tag, account_verified
FROM ats_credentials
WHERE portal_hostname = $1`,
[hostname]
);
if (existing.rows.length > 0) {
const row = existing.rows[0];
await pool.query(
`UPDATE ats_credentials SET last_used_at = NOW() WHERE portal_hostname = $1`,
[hostname]
);
const password = decryptPassword(
row.encrypted_password,
row.iv,
row.auth_tag,
params.masterSecret
);
return {
portalHostname: row.portal_hostname,
atsFamily: row.ats_family,
companyName: row.company_name,
email: row.email,
password,
isNew: false,
accountVerified: row.account_verified,
};
}
} else if (memoryVault.has(hostname)) {
const entry = memoryVault.get(hostname)!;
entry.lastUsedAt = new Date().toISOString();
const password = decryptPassword(
entry.encryptedPassword,
entry.iv,
entry.authTag,
params.masterSecret
);
return {
portalHostname: entry.portalHostname,
atsFamily: entry.atsFamily,
companyName: entry.companyName,
email: entry.email,
password,
isNew: false,
accountVerified: entry.accountVerified,
};
}
const password = generateCompliantAtsPassword(
hostname,
params.masterSecret,
params.passwordPrefix
);
return saveAtsCredentials(pool, {
portalUrl: params.portalUrl,
companyName: params.companyName,
email: params.email,
password,
masterSecret: params.masterSecret,
accountVerified: false,
isNewFlag: true,
});
}
/**
* Explicitly saves or updates an ATS/Job-Board login credential in PostgreSQL
* (and syncs to JobPilot's /api/credentials store when paired).
*/
export async function saveAtsCredentials(
pool: Pool | null,
params: {
portalUrl: string;
companyName?: string;
email: string;
password: string;
masterSecret: string;
accountVerified?: boolean;
isNewFlag?: boolean;
}
): Promise<AtsCredentialRecord> {
const { hostname, atsFamily } = parsePortalIdentity(params.portalUrl);
const { encryptedPassword, iv, authTag } = encryptPassword(
params.password,
params.masterSecret
);
const verified = params.accountVerified ?? true;
if (pool) {
await pool.query(
`INSERT INTO ats_credentials (
portal_hostname, ats_family, company_name, email, encrypted_password, iv, auth_tag, account_verified, last_used_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
ON CONFLICT (portal_hostname) DO UPDATE SET
ats_family = EXCLUDED.ats_family,
company_name = COALESCE(EXCLUDED.company_name, ats_credentials.company_name),
email = EXCLUDED.email,
encrypted_password = EXCLUDED.encrypted_password,
iv = EXCLUDED.iv,
auth_tag = EXCLUDED.auth_tag,
account_verified = EXCLUDED.account_verified,
last_used_at = NOW()`,
[
hostname,
atsFamily,
params.companyName ?? null,
params.email,
encryptedPassword,
iv,
authTag,
verified,
]
);
} else {
memoryVault.set(hostname, {
portalHostname: hostname,
atsFamily,
companyName: params.companyName ?? null,
email: params.email,
encryptedPassword,
iv,
authTag,
accountVerified: verified,
lastUsedAt: new Date().toISOString(),
});
}
const synced = await syncCredentialToJobPilotApi({
hostname,
email: params.email,
password: params.password,
});
return {
portalHostname: hostname,
atsFamily,
companyName: params.companyName ?? null,
email: params.email,
password: params.password,
isNew: params.isNewFlag ?? false,
accountVerified: verified,
syncedToJobPilotApi: synced,
};
}
/**
* Lists all saved ATS credentials (metadata only, no plaintext passwords).
*/
export async function listAtsCredentials(
pool: Pool | null
): Promise<AtsCredentialSummary[]> {
if (pool) {
const res = await pool.query(
`SELECT portal_hostname, ats_family, company_name, email, account_verified, last_used_at
FROM ats_credentials
ORDER BY last_used_at DESC`
);
return res.rows.map((r) => ({
portalHostname: r.portal_hostname,
atsFamily: r.ats_family,
companyName: r.company_name,
email: r.email,
accountVerified: Boolean(r.account_verified),
lastUsedAt: new Date(r.last_used_at).toISOString(),
}));
}
return Array.from(memoryVault.values()).map((e) => ({
portalHostname: e.portalHostname,
atsFamily: e.atsFamily,
companyName: e.companyName,
email: e.email,
accountVerified: e.accountVerified,
lastUsedAt: e.lastUsedAt,
}));
}
+176
View File
@@ -0,0 +1,176 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
parsePortalIdentity,
generateCompliantAtsPassword,
encryptPassword,
decryptPassword,
getOrCreateAtsCredentials,
saveAtsCredentials,
listAtsCredentials,
} from "../src/vault.js";
import {
extractVerificationFromContent,
classifyJobEmail,
} from "../src/imap.js";
describe("ATS Multi-Tenant Password Vault", () => {
it("normalizes Workday, Greenhouse, iCIMS, and Taleo tenant hostnames", () => {
const wd = parsePortalIdentity(
"https://nvidia.wd5.myworkdayjobs.com/en-US/NVIDIAExternalCareerSite/login"
);
assert.equal(wd.hostname, "nvidia.wd5.myworkdayjobs.com");
assert.equal(wd.atsFamily, "workday");
const icims = parsePortalIdentity("https://careers-acme.icims.com/jobs/1234/login");
assert.equal(icims.hostname, "careers-acme.icims.com");
assert.equal(icims.atsFamily, "icims");
const gh = parsePortalIdentity("https://job-boards.greenhouse.io/anthropic/jobs/456");
assert.equal(gh.hostname, "job-boards.greenhouse.io");
assert.equal(gh.atsFamily, "greenhouse");
});
it("generates deterministic Workday-compliant strong passwords and roundtrips AES-256-GCM", () => {
const secret = "test-master-secret-32-bytes-long!!";
const pwd = generateCompliantAtsPassword(
"nvidia.wd5.myworkdayjobs.com",
secret,
"JobBot#2026!"
);
assert.ok(pwd.length >= 16, "Password must be at least 16 chars");
assert.match(pwd, /[A-Z]/, "Must contain uppercase");
assert.match(pwd, /[a-z]/, "Must contain lowercase");
assert.match(pwd, /[0-9]/, "Must contain digit");
assert.match(pwd, /[!@#$%&*?]/, "Must contain special symbol");
const encrypted = encryptPassword(pwd, secret);
const decrypted = decryptPassword(
encrypted.encryptedPassword,
encrypted.iv,
encrypted.authTag,
secret
);
assert.equal(decrypted, pwd);
});
it("saves, updates, retrieves, and lists credentials in the vault", async () => {
const secret = "test-master-secret-32-bytes-long!!";
// 1. Auto-create credential for a new Workday portal
const created = await getOrCreateAtsCredentials(null, {
portalUrl: "https://stripe.wd5.myworkdayjobs.com/en-US/Stripe/login",
companyName: "Stripe",
email: "[email protected]",
masterSecret: secret,
});
assert.equal(created.portalHostname, "stripe.wd5.myworkdayjobs.com");
assert.equal(created.isNew, true);
assert.equal(created.accountVerified, false);
// 2. Subsequent lookup returns the saved password with isNew = false
const fetched = await getOrCreateAtsCredentials(null, {
portalUrl: "https://stripe.wd5.myworkdayjobs.com/en-US/Stripe/job/123",
email: "[email protected]",
masterSecret: secret,
});
assert.equal(fetched.isNew, false);
assert.equal(fetched.password, created.password);
// 3. Explicitly save/update a custom password (e.g., after password reset or account verification)
const updated = await saveAtsCredentials(null, {
portalUrl: "https://stripe.wd5.myworkdayjobs.com/en-US/Stripe/login",
companyName: "Stripe",
email: "[email protected]",
password: "CustomResetPassword#2026!",
masterSecret: secret,
accountVerified: true,
});
assert.equal(updated.password, "CustomResetPassword#2026!");
assert.equal(updated.accountVerified, true);
// 4. Verify getOrCreateAtsCredentials now returns the updated password
const afterUpdate = await getOrCreateAtsCredentials(null, {
portalUrl: "stripe.wd5.myworkdayjobs.com",
email: "[email protected]",
masterSecret: secret,
});
assert.equal(afterUpdate.password, "CustomResetPassword#2026!");
assert.equal(afterUpdate.accountVerified, true);
// 5. Verify listAtsCredentials includes the portal without exposing plaintext password
const list = await listAtsCredentials(null);
const stripeEntry = list.find(
(i) => i.portalHostname === "stripe.wd5.myworkdayjobs.com"
);
assert.ok(stripeEntry, "Expected stripe portal in credential list");
assert.equal(stripeEntry.email, "[email protected]");
assert.equal(stripeEntry.accountVerified, true);
});
});
describe("Gmail IMAP Verification OTP & Magic Link Extractor", () => {
it("extracts 8-character Greenhouse security codes", () => {
const res = extractVerificationFromContent(
"Greenhouse Security Code for your application",
"Hi Derek, Your security code is: X9K4M2P7. Paste this into your application form.",
"<p>Your security code is: <strong>X9K4M2P7</strong></p>",
"greenhouse.io"
);
assert.equal(res.verificationCode, "X9K4M2P7");
assert.equal(res.verificationLink, null);
});
it("extracts Workday account activation links and rejects untrusted phishing domains", () => {
const goodRes = extractVerificationFromContent(
"Verify your Workday account",
"Click the link to activate your account: https://nvidia.wd5.myworkdayjobs.com/en-US/External/activate?token=abc123xyz",
"",
"workday.com"
);
assert.equal(
goodRes.verificationLink,
"https://nvidia.wd5.myworkdayjobs.com/en-US/External/activate?token=abc123xyz"
);
const phishingRes = extractVerificationFromContent(
"Verify your Workday account",
"Click to verify: https://evil-phishing-site.ru/verify?token=steal",
"",
"workday.com"
);
assert.equal(phishingRes.verificationLink, null);
});
it("classifies job pipeline emails accurately", () => {
assert.equal(
classifyJobEmail(
"Thank you for applying to Stripe",
"We have received your application for Backend Engineer."
),
"confirmation"
);
assert.equal(
classifyJobEmail(
"Invitation to Interview - Senior Engineer",
"We would love to schedule an interview and phone screen with you."
),
"interview_invite"
);
assert.equal(
classifyJobEmail(
"Update on your application",
"Unfortunately, we will not be proceeding with your candidacy at this time."
),
"rejection"
);
assert.equal(
classifyJobEmail(
"Complete your HackerRank Technical Assessment",
"Please complete the online assessment within 72 hours."
),
"assessment_oa"
);
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true
},
"include": ["src/**/*"]
}