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:
+120
-66
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user