Jobsuche: Agent pro Benutzer in isoliertem Docker-Container

Jeder Suchlauf läuft nun in einem frischen Container pro Benutzer, dessen
pro-Benutzer-Home als ~/.claude gemountet ist — Memories und Session-Contexte
liegen damit strikt getrennt pro Benutzer. Die Such-Skills sind Shared-Code
aus dem Image und werden im Container nur nach ~/.claude/skills verlinkt.

Der Host-Runner startet pro Lauf `docker run --rm` und führt bis zu
JOBSUCHE_MAX_PARALLEL (Default 4) Läufe parallel über verschiedene Benutzer
aus (jeder hat eigenen Ollama-Key = getrennte Rate-Limits). Der alte gemeinsame
~/.claude-Pfad wird vom Runner nicht mehr beschrieben.

- source/agent/: neues Agent-Image (Dockerfile + entrypoint + drei Skills)
- scripts/jobsuche-runner.js: agentStarten als docker run, ensureAgentDir, runPool
- scripts/jobsuche-runner.sh + bin/-Kopie: Image-Guard
- package.json: docker:build-agent (lokal, ohne Registry-Push)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-14 15:41:22 +02:00
co-authored by Claude
parent 7c92f23351
commit ce844412e5
15 changed files with 2014 additions and 71 deletions
+120 -66
View File
@@ -2,29 +2,39 @@
// Host-side runner for the per-user job search.
//
// Why it lives on the host and not in the app: the app container has neither
// `claude` nor `ollama`. The app therefore only *queues* runs (table suchlaeufe);
// this script — started by cron every few minutes — picks them up and executes
// them. It replaces the two old single-user scripts (jobsuche-cron.sh and
// jobsuche-remote-cron.sh), whose search criteria were hard-coded into the prompt.
// `claude` nor `ollama` nor a docker socket. The app therefore only *queues*
// runs (table suchlaeufe); this script — started by cron every few minutes —
// picks them up and executes them. It replaces the two old single-user scripts
// (jobsuche-cron.sh and jobsuche-remote-cron.sh), whose search criteria were
// hard-coded into the prompt.
//
// Isolation: every run executes in its OWN throwaway Docker container
// (AGENT_IMAGE), with the requesting user's dedicated home directory mounted
// as /home/agent. That home IS that user's ~/.claude (memories, session
// contexts, history, settings) — never shared with another user. The three
// search skills are shared code baked into the image and symlinked into
// ~/.claude/skills by the image entrypoint.
//
// Per run it:
// 1. enqueues scheduled runs that are due (per user's Zeitplan),
// 2. claims one queued run at a time (status angefordert -> laeuft),
// 2. claims queued runs (status angefordert -> laeuft) and runs them in
// parallel up to MAX_PARALLEL — different users have different Ollama
// keys and thus separate rate limits, so parallelism is safe across users,
// 3. builds the prompt from that user's Suchprofil + their Lebenslauf,
// 4. runs the headless agent with THAT user's credentials:
// - BEWERBUNG_API_KEY = the user's API token (created if missing), so every
// imported Jobangebot lands in their account and passes the same
// dedup/blacklist path as the rest of the app,
// - the model is driven by the user's own Ollama key (see KI_AUTH below),
// 5. parses the agent's final "ERGEBNIS neu=… dubletten=… verworfen=…" line and
// writes status + counts back, so the UI can show what happened.
//
// Everything is per user; nothing here reads a global .env any more.
// 4. starts a container that runs the headless agent with THAT user's
// credentials:
// - BEWERBUNG_API_KEY = the user's API token (created if missing), so
// every imported Jobangebot lands in their account and passes the
// same dedup/blacklist path as the rest of the app,
// - ANTHROPIC_* = the user's own Ollama endpoint+key, so the search is
// billed to whoever requested it,
// 5. parses the agent's final "ERGEBNIS neu=… dubletten=… verworfen=…" line
// and writes status + counts back, so the UI can show what happened.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process');
const { spawn, spawnSync } = require('child_process');
const sqlite3 = require('sqlite3');
const suchprofil = require('../lib/suchprofil');
@@ -32,16 +42,25 @@ const suchprofil = require('../lib/suchprofil');
const DB_PATH = process.env.JOBSUCHE_DB || '/opt/jobbi-bewerbung/data/bewerbungen.db';
const LOG_DIR = process.env.JOBSUCHE_LOG_DIR || '/opt/jobbi-bewerbung/logs';
const API_URL = process.env.JOBSUCHE_API_URL || 'http://localhost:4327/api/v1';
// Swappable so the pipeline can be exercised end-to-end without burning tokens.
const CLAUDE_BIN = process.env.JOBSUCHE_CLAUDE_BIN || 'claude';
// 'benutzer' = each run uses that user's own Ollama key (they pay for their own
// search). 'host' = fall back to the machine's ollama login via `ollama launch`.
const KI_AUTH = process.env.JOBSUCHE_KI_AUTH || 'benutzer';
const RUN_TIMEOUT_MS = (Number(process.env.JOBSUCHE_TIMEOUT) || 2700) * 1000;
// A run that has been 'laeuft' for longer than this lost its process (reboot,
// OOM); it would otherwise block the user's queue forever.
const STALE_MS = RUN_TIMEOUT_MS + 10 * 60 * 1000;
const LOG_RETENTION_DAYS = Number(process.env.JOBSUCHE_LOG_RETENTION_DAYS) || 30;
// Agent-Container. Das Image wird lokal gebaut (npm run docker:build-agent),
// nicht über die Registry verteilt — es läuft nur auf diesem Host.
const AGENT_IMAGE = process.env.JOBSUCHE_AGENT_IMAGE || 'jobbi-bewerbung-agent:latest';
// uid/gid, als die der Container läuft und der das pro-Benutzer-Home gehört.
const AGENT_UID = Number(process.env.JOBSUCHE_AGENT_UID) || 1000;
const AGENT_GID = Number(process.env.JOBSUCHE_AGENT_GID) || AGENT_UID;
// Pro-Benutzer-Verzeichnisse (je HOME = je ~/.claude).
const AGENTS_DIR = process.env.JOBSUCHE_AGENTS_DIR || '/opt/jobbi-bewerbung/agents';
// Gleichzeitig laufende Such-Container. Verschiedene Benutzer haben getrennte
// Ollama-Keys -> getrennte Rate-Limits, deshalb ist pro-Benutzer-Parallelität
// sicher. Der Wert begrenzt lediglich die Host-Last.
const MAX_PARALLEL = Number(process.env.JOBSUCHE_MAX_PARALLEL) || 4;
const CFG = 'cfg:';
const db = new sqlite3.Database(DB_PATH);
@@ -88,6 +107,16 @@ async function profilText(userId) {
return rows.map((r) => (r.inhalt || '').trim()).filter(Boolean).join('\n\n---\n\n');
}
// Legt das pro-Benutzer-Home an (falls noch nicht vorhanden) und übergibt es
// an den Container-Agent. Bleibt über Läufe hinweg bestehen, damit Memories
// und Contexte erhalten bleiben.
function ensureAgentDir(userId) {
const dir = path.join(AGENTS_DIR, String(userId));
fs.mkdirSync(dir, { recursive: true });
spawnSync('chown', ['-R', `${AGENT_UID}:${AGENT_GID}`, dir]);
return dir;
}
// 1. Queue scheduled runs that are due.
async function faelligeEinreihen() {
const jetzt = new Date();
@@ -120,7 +149,7 @@ async function faelligeEinreihen() {
async function verwaisteAufraeumen() {
const res = await run(
`UPDATE suchlaeufe SET status = 'fehler', beendet_at = CURRENT_TIMESTAMP,
fehler = 'Lauf abgebrochen (Prozess nicht mehr vorhanden).'
fehler = 'Lauf abgebrochen (Container nicht mehr vorhanden).'
WHERE status = 'laeuft'
AND gestartet_at IS NOT NULL
AND (julianday('now') - julianday(gestartet_at)) * 86400000 > ?`,
@@ -152,26 +181,30 @@ async function laufAbschliessen(id, status, felder = {}) {
);
}
// Spawn the headless agent and collect its output.
function agentStarten(prompt, env, logStream) {
// Start the headless agent inside a throwaway container for this user. Only the
// curated env below is passed in (not process.env) so no host secrets leak.
function agentStarten(prompt, envObj, logStream, containerName, agentDir) {
return new Promise((resolve) => {
let argv;
if (KI_AUTH === 'host') {
// Legacy path: the machine's ollama login pays for the run.
argv = ['launch', 'claude', '--model', env.JOBSUCHE_MODEL, '--',
'--dangerously-skip-permissions', '-p', prompt];
argv = { cmd: 'ollama', args: argv };
} else {
// Per-user path: Claude Code talks to Ollama's Anthropic-compatible API with
// *this user's* key, so the search is billed to whoever requested it.
argv = { cmd: CLAUDE_BIN, args: ['--dangerously-skip-permissions', '-p', prompt] };
}
const args = [
'run', '--rm',
'--name', containerName,
// --network host: der Agent erreicht den Tracker (localhost:4327), das
// Internet (WebSearch/WebFetch/Arbeitsagentur) und den Ollama-Endpoint
// des Benutzers — alles über die Host-Netzwerk-Sicht.
'--network', 'host',
'--user', `${AGENT_UID}:${AGENT_GID}`,
'-e', 'HOME=/home/agent',
'-e', 'IS_SANDBOX=1',
];
for (const [k, v] of Object.entries(envObj)) args.push('-e', `${k}=${v}`);
args.push(
'-v', `${agentDir}:/home/agent`,
'-w', '/work',
AGENT_IMAGE,
'claude', '--dangerously-skip-permissions', '-p', prompt,
);
const kind = spawn(argv.cmd, argv.args, {
env,
cwd: '/opt/jobbi-bewerbung',
stdio: ['ignore', 'pipe', 'pipe'],
});
const kind = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let ausgabe = '';
const sammeln = (buf) => {
@@ -182,14 +215,17 @@ function agentStarten(prompt, env, logStream) {
kind.stdout.on('data', sammeln);
kind.stderr.on('data', sammeln);
// Beim Timeout den `docker run`-Client killen UND den Container per Name
// stoppen — ein kill des Clients allein ließe den Container sonst weiterlaufen.
const timer = setTimeout(() => {
spawnSync('docker', ['kill', containerName], { stdio: 'ignore' });
kind.kill('SIGTERM');
setTimeout(() => kind.kill('SIGKILL'), 60000);
setTimeout(() => kind.kill('SIGKILL'), 10000);
}, RUN_TIMEOUT_MS);
kind.on('error', (err) => {
clearTimeout(timer);
resolve({ code: -1, ausgabe, fehler: `Agent nicht startbar: ${err.message}` });
resolve({ code: -1, ausgabe, fehler: `Agent-Container nicht startbar: ${err.message}` });
});
kind.on('close', (code, signal) => {
clearTimeout(timer);
@@ -207,7 +243,8 @@ async function laufAusfuehren(lauf) {
const userId = lauf.user_id;
const user = await get('SELECT username FROM users WHERE id = ?', [userId]);
const name = user ? user.username : `#${userId}`;
log(`Lauf ${lauf.id} (Benutzer ${name}, ${lauf.ausloeser}) startet.`);
const containerName = `jobbi-agent-${userId}-${lauf.id}`;
log(`Lauf ${lauf.id} (Benutzer ${name}, ${lauf.ausloeser}) startet im Container ${containerName}.`);
const profil = suchprofil.fromRow(await get('SELECT * FROM suchprofil WHERE user_id = ?', [userId]));
const fehler = suchprofil.validate(profil);
@@ -218,7 +255,7 @@ async function laufAusfuehren(lauf) {
}
const ollamaKey = await cfgWert(userId, 'OLLAMA_API_KEY');
if (KI_AUTH !== 'host' && !ollamaKey) {
if (!ollamaKey) {
const msg = 'Kein eigener Ollama-API-Schlüssel hinterlegt — der Suchlauf läuft über das KI-Kontingent des Benutzers. Bitte in den Einstellungen eintragen.';
await laufAbschliessen(lauf.id, 'fehler', { fehler: msg });
log(` abgebrochen: kein Ollama-Key für ${name}.`);
@@ -235,27 +272,21 @@ async function laufAusfuehren(lauf) {
const logStream = fs.createWriteStream(logDatei, { flags: 'a' });
logStream.write(`=== Suchlauf ${lauf.id} — Benutzer ${name} (${lauf.ausloeser}) ===\n`);
logStream.write(`Modus: ${profil.modus} | Städte: ${profil.staedte.join(', ') || ''}\n`);
logStream.write(`Modell: ${modell} | KI-Auth: ${KI_AUTH}\n${'-'.repeat(50)}\n`);
logStream.write(`Modell: ${modell} | Container: ${containerName}\n${'-'.repeat(50)}\n`);
const env = {
...process.env,
HOME: process.env.HOME || '/root',
PATH: `/root/.local/bin:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}`,
IS_SANDBOX: '1',
// The tracker API — the agent imports Jobangebote as THIS user.
// Nur kuratierte Env-Vars in den Container — keine Host-Secrets.
const envObj = {
ANTHROPIC_BASE_URL: (await cfgWert(userId, 'OLLAMA_HOST')) || 'https://ollama.com',
ANTHROPIC_AUTH_TOKEN: ollamaKey,
ANTHROPIC_MODEL: modell,
ANTHROPIC_SMALL_FAST_MODEL: modell,
BEWERBUNG_API_URL: API_URL,
BEWERBUNG_API_KEY: apiKey,
JOBSUCHE_MODEL: modell,
};
if (KI_AUTH !== 'host') {
// Point Claude Code at Ollama's Anthropic-compatible endpoint with the user's key.
env.ANTHROPIC_BASE_URL = (await cfgWert(userId, 'OLLAMA_HOST')) || 'https://ollama.com';
env.ANTHROPIC_AUTH_TOKEN = ollamaKey;
env.ANTHROPIC_MODEL = modell;
env.ANTHROPIC_SMALL_FAST_MODEL = modell;
}
const { code, ausgabe, fehler: laufFehler } = await agentStarten(prompt, env, logStream);
const agentDir = ensureAgentDir(userId);
const { code, ausgabe, fehler: laufFehler } = await agentStarten(prompt, envObj, logStream, containerName, agentDir);
const ergebnis = suchprofil.parseErgebnis(ausgabe);
logStream.write(`\n${'-'.repeat(50)}\nExit-Code: ${code}\n`);
@@ -286,6 +317,27 @@ async function laufAusfuehren(lauf) {
log(` Lauf ${lauf.id} fertig: ${ergebnis.neu} neu, ${ergebnis.dubletten} Dubletten, ${ergebnis.verworfen} verworfen.`);
}
// Führt eine Liste von Läufen mit höchstens `parallel` gleichzeitig aus. Jeder
// Lauf läuft in seinem eigenen Container; verschiedene Benutzer haben getrennte
// Ollama-Keys, deshalb ist pro-Benutzer-Parallelität sicher.
async function runPool(items, parallel, fn) {
const queue = items.slice();
const workers = Array.from({ length: Math.min(parallel, queue.length || 1) }, async () => {
while (queue.length) {
const item = queue.shift();
try {
await fn(item);
} catch (e) {
log(` Lauf ${item.id} unerwartet abgebrochen: ${e && e.message ? e.message : e}`);
try {
await laufAbschliessen(item.id, 'fehler', { fehler: `Runner-Ausnahme: ${e && e.message ? e.message : e}` });
} catch (_) { /* Status ist dann zwar offen, aber ein Folge-Tick räumt auf. */ }
}
}
});
await Promise.all(workers);
}
function alteLogsAufraeumen() {
try {
const grenze = Date.now() - LOG_RETENTION_DAYS * 86400000;
@@ -307,15 +359,17 @@ async function main() {
await verwaisteAufraeumen();
await faelligeEinreihen();
// Work the queue until it is empty. Runs are serialised on purpose: a search is
// web- and token-heavy, and several in parallel would trip rate limits.
// Alle wartenden Läufe einsammeln (pro Benutzer steht ohnehin nur einer an)
// und mit begrenzter Parallelität abarbeiten.
const laeufe = [];
let lauf;
let anzahl = 0;
while ((lauf = await naechstenLaufClaimen())) {
await laufAusfuehren(lauf);
anzahl += 1;
while ((lauf = await naechstenLaufClaimen())) laeufe.push(lauf);
if (!laeufe.length) {
log('Nichts zu tun.');
} else {
log(`${laeufe.length} Lauf/Läufe eingereiht, starte bis zu ${MAX_PARALLEL} parallel.`);
await runPool(laeufe, MAX_PARALLEL, laufAusfuehren);
}
if (!anzahl) log('Nichts zu tun.');
alteLogsAufraeumen();
db.close();
@@ -324,4 +378,4 @@ async function main() {
main().catch((e) => {
console.error('Runner-Fehler:', e);
process.exit(1);
});
});