mirror of
https://github.com/Heretek-AI/job-bot.git
synced 2026-11-07 10:55:57 -10:00
feat: add prepackaged Option 3 Hybrid Docker Compose stack, CloakBrowser, jobbot-mcp, and GHCR CI
This commit is contained in:
1 parent
790af8d4cf
commit
932bee05de
24 files changed
+5011
No files matched your search
@@ -0,0 +1,9 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
storage
|
||||
shared
|
||||
*.log
|
||||
@@ -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=
|
||||
@@ -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 }}
|
||||
@@ -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`. |
|
||||
@@ -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:
|
||||
@@ -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"]
|
||||
@@ -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}
|
||||
@@ -0,0 +1,4 @@
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 8080
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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))
|
||||
Generated
+2581
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(/&/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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
Reference in new issue
Block a user