From e57856173afa3f3011c266ae169ae2ffa4f41b0f Mon Sep 17 00:00:00 2001 From: Carlos Arias Date: Thu, 23 Jul 2026 20:53:32 +0000 Subject: [PATCH] Phase 1: console runner service + nginx gate + systemd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agents/console/server.mjs: Node runner (as carlos-arias-agent, 127.0.0.1:3011). run/stream(SSE)/publish/discard/ping/auth. Agent edits files (no Bash); the runner builds isolated previews and git-commits on publish. Git ops scoped to content paths (app, brand, api/db, api/cli) — never infra or secrets. - adminauth check() — nginx auth_request target (204 admin / 401 not). - nginx: /devconsole + /_preview gated by auth_request, runner proxied, previews admin-only. Runner never exposed directly. - systemd unit (hardened: NoNewPrivileges, ProtectSystem, scoped ReadWritePaths). Verified end to end: admin login -> agent edits about.astro -> isolated preview (admin-gated) -> discard reverts. Unauthenticated access is 401 throughout. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DoFYZY9gkGPNDqZ7NuEa9a --- agents/console/server.mjs | 379 +++++++++++++++++++++++++++ api/public/controllers/adminauth.php | 15 ++ 2 files changed, 394 insertions(+) create mode 100644 agents/console/server.mjs diff --git a/agents/console/server.mjs b/agents/console/server.mjs new file mode 100644 index 0000000..27ed8fc --- /dev/null +++ b/agents/console/server.mjs @@ -0,0 +1,379 @@ +#!/usr/bin/env node +/** + * AstroAgent console runner. + * + * Runs AS the confined `carlos-arias-agent` user (systemd User=), binds + * 127.0.0.1 only, and is reached exclusively through nginx, which gates every + * request with auth_request against the PHP admin session. If a request lands + * here, the operator is already an authenticated admin — this process does not + * re-check that; its job is orchestration. + * + * Contract (matches app/src/components/DevConsole.astro): + * GET /devconsole/ping -> { authed, hasToken } + * POST /devconsole/run {message,page,...} -> { conversationId } (async) + * GET /devconsole/stream?conversationId=... -> SSE: text|tool|preview|published|error|done + * POST /devconsole/publish {conversationId} -> promotes preview, git commit + * POST /devconsole/discard {conversationId} -> reverts working tree + * POST /devconsole/auth {token} -> update the Claude token + * POST /devconsole/logout -> (admin logout is PHP; no-op here) + * + * Safety model: + * - The AGENT only edits files (tools: Read/Write/Edit/Glob/Grep/WebSearch, + * NO Bash). This runner — not the agent — runs every build and git op. + * - Every /run edits the working tree, then builds an ISOLATED preview to + * public-preview/. Nothing is live. + * - /publish builds to public/, verifies the build passed, then git-commits + * (a rollback point). /discard does `git checkout -- .`. + * - One job at a time (single admin); a lock serialises run/publish/discard. + * + * Node built-ins only — no dependencies. + */ + +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync } from "node:fs"; +import { dirname, resolve, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "..", ".."); // /www/wwwroot/CarlosAriasPersonal +const APP = join(REPO, "app"); +const PREVIEW_DIR = join(REPO, "public-preview"); +const ENV_FILE = join(REPO, "agents", ".env"); +const CLAUDE = "/usr/local/bin/claude"; +const PORT = Number(process.env.ADMIN_PORT || 3011); +const AGENT_TOOLS = "Read Write Edit Glob Grep WebSearch"; +const DEFAULT_MODEL = "claude-sonnet-4-6"; + +// ---- env (.env) -------------------------------------------------------------- +function loadEnv() { + const out = {}; + if (existsSync(ENV_FILE)) { + for (const line of readFileSync(ENV_FILE, "utf8").split("\n")) { + const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); + if (m) out[m[1]] = m[2]; + } + } + return out; +} +function saveToken(token) { + let s = existsSync(ENV_FILE) ? readFileSync(ENV_FILE, "utf8") : ""; + if (/^CLAUDE_CODE_OAUTH_TOKEN=.*$/m.test(s)) { + s = s.replace(/^CLAUDE_CODE_OAUTH_TOKEN=.*$/m, `CLAUDE_CODE_OAUTH_TOKEN=${token}`); + } else { + s += `\nCLAUDE_CODE_OAUTH_TOKEN=${token}\n`; + } + writeFileSync(ENV_FILE, s, { mode: 0o600 }); +} + +// ---- job state --------------------------------------------------------------- +/** conversationId -> job */ +const jobs = new Map(); +let busy = false; // single-flight lock + +function newJob(page) { + const conversationId = randomUUID(); + const job = { + conversationId, + jobId: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, + sessionId: null, // claude session, for --resume follow-ups + page: page || "/", + events: [], // buffered for late SSE subscribers + clients: new Set(), // active SSE responses + done: false, + }; + jobs.set(conversationId, job); + return job; +} +function emit(job, ev) { + job.events.push(ev); + const line = `data: ${JSON.stringify(ev)}\n\n`; + for (const res of job.clients) { + try { res.write(line); } catch {} + } +} + +// ---- prompt wrapper ---------------------------------------------------------- +function buildPrompt({ message, page, selections }) { + const parts = []; + parts.push( + "You are the editing agent for the Carlos Arias website (an Astro + Tailwind v4 static site).", + "Make ONLY the change the operator asks for. Keep everything on-brand:", + "the brand guide is brand/BRAND.md and the design tokens are in app/src/styles.css", + "(sumi-e — ink on washi paper, one vermillion seal, restraint, near-square radii).", + "Do NOT run builds or git commands — the console builds and publishes for you.", + "", + ); + if (page && page !== "/") { + parts.push(`The operator is on the page: ${page}`); + parts.push( + "Its source is almost certainly under app/src/pages (find it with Glob/Grep).", + "", + ); + } else if (page === "/") { + parts.push("The operator is on the homepage (app/src/pages/index.astro).", ""); + } + if (Array.isArray(selections) && selections.length) { + parts.push("They selected these element(s) on the page:"); + for (const s of selections) { + const tag = s.tag || s.selector || "element"; + parts.push(`- <${tag}>${s.text ? ` — "${String(s.text).slice(0, 80)}"` : ""}${s.comment ? ` — note: ${s.comment}` : ""}`); + } + parts.push(""); + } + parts.push("Request:", message); + return parts.join("\n"); +} + +// ---- run the agent, then build a preview ------------------------------------- +function agentEnv() { + const env = loadEnv(); + return { + ...process.env, + HOME: process.env.HOME || "/var/lib/carlos-arias-agent", + PATH: "/usr/local/bin:/usr/bin:/bin", + CLAUDE_CODE_OAUTH_TOKEN: env.CLAUDE_CODE_OAUTH_TOKEN || "", + }; +} + +function runAgent(job, { message, page, selections, model }) { + const prompt = buildPrompt({ message, page, selections }); + const args = [ + "-p", prompt, + "--output-format", "stream-json", + "--verbose", + "--allowedTools", AGENT_TOOLS, + "--model", model || DEFAULT_MODEL, + ]; + if (job.sessionId) args.push("--resume", job.sessionId); + + const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() }); + + let buf = ""; + child.stdout.on("data", (chunk) => { + buf += chunk.toString(); + let nl; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (!line) continue; + let msg; + try { msg = JSON.parse(line); } catch { continue; } + handleStreamMsg(job, msg); + } + }); + child.stderr.on("data", (d) => { + const t = d.toString().trim(); + if (t) emit(job, { kind: "tool", text: t.slice(0, 200) }); + }); + child.on("close", async (code) => { + if (code !== 0) { + emit(job, { type: "error", text: "The agent stopped unexpectedly. Nothing was changed." }); + emit(job, { type: "done" }); + job.done = true; + busy = false; + return; + } + await buildPreview(job); + busy = false; + }); + child.on("error", (err) => { + emit(job, { type: "error", text: `Could not start the agent: ${err.message}` }); + emit(job, { type: "done" }); + job.done = true; + busy = false; + }); +} + +function handleStreamMsg(job, msg) { + // capture the resumable session id + if (msg.session_id) job.sessionId = msg.session_id; + const content = msg?.message?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === "text" && block.text) emit(job, { kind: "text", text: block.text }); + else if (block.type === "tool_use") { + const label = block.name === "Edit" || block.name === "Write" + ? `${block.name} ${shortPath(block.input?.file_path)}` + : block.name; + emit(job, { kind: "tool", text: label }); + } + } + } + if (msg.type === "result" && typeof msg.result === "string" && msg.result.trim()) { + emit(job, { kind: "text", text: msg.result.trim() }); + } +} +function shortPath(p) { + if (!p) return ""; + return String(p).replace(REPO + "/", ""); +} + +// ---- builds ------------------------------------------------------------------ +function build(extraEnv) { + return new Promise((res) => { + const child = spawn("npm", ["run", "build"], { + cwd: APP, + env: { ...agentEnv(), ...extraEnv }, + }); + let tail = ""; + const grab = (d) => { tail = (tail + d.toString()).slice(-4000); }; + child.stdout.on("data", grab); + child.stderr.on("data", grab); + child.on("close", (code) => res({ ok: code === 0, tail })); + child.on("error", () => res({ ok: false, tail: "build failed to start" })); + }); +} + +async function buildPreview(job) { + emit(job, { kind: "tool", text: "building preview…" }); + const out = join("..", "public-preview", job.jobId); + const base = `/_preview/${job.jobId}`; + const { ok, tail } = await build({ PREVIEW_OUT: out, PREVIEW_BASE: base }); + if (!ok) { + emit(job, { type: "error", text: "The change broke the build, so it was not applied. Try rephrasing." }); + // revert the agent's edits so the working tree stays clean + await git(["checkout", "--", "app", "brand", "api/db", "api/cli"]); + emit(job, { type: "done" }); + job.done = true; + return; + } + const target = job.page && job.page !== "/" ? job.page.replace(/^\//, "") : ""; + emit(job, { type: "preview", url: `${base}/${target}` }); + emit(job, { type: "done" }); + job.done = true; +} + +// ---- git --------------------------------------------------------------------- +function git(args) { + return new Promise((res) => { + const child = spawn("git", args, { cwd: REPO, env: agentEnv() }); + let tail = ""; + child.stdout.on("data", (d) => (tail += d)); + child.stderr.on("data", (d) => (tail += d)); + child.on("close", (code) => res({ ok: code === 0, tail: tail.toString() })); + child.on("error", () => res({ ok: false, tail: "" })); + }); +} + +// ---- request helpers --------------------------------------------------------- +function json(res, status, obj) { + const body = JSON.stringify(obj); + res.writeHead(status, { "content-type": "application/json; charset=utf-8" }); + res.end(body); +} +function readBody(req) { + return new Promise((res) => { + let b = ""; + req.on("data", (c) => (b += c)); + req.on("end", () => { + try { res(b ? JSON.parse(b) : {}); } catch { res({}); } + }); + }); +} + +// ---- server ------------------------------------------------------------------ +const server = createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + const path = url.pathname.replace(/^\/devconsole/, "") || "/"; + const method = req.method || "GET"; + + try { + if (path === "/ping") { + const env = loadEnv(); + return json(res, 200, { authed: true, hasToken: Boolean(env.CLAUDE_CODE_OAUTH_TOKEN) }); + } + + if (path === "/auth" && method === "POST") { + const { token } = await readBody(req); + if (!token || !/^sk-ant-/.test(token)) { + return json(res, 400, { error: "That doesn't look like a Claude token (expected sk-ant-…)." }); + } + saveToken(token.trim()); + return json(res, 200, { ok: true }); + } + + if (path === "/logout" && method === "POST") { + return json(res, 200, { ok: true }); // admin session is cleared by PHP + } + + if (path === "/run" && method === "POST") { + if (busy) return json(res, 429, { error: "A change is already in progress — let it finish." }); + const body = await readBody(req); + const message = String(body.message || "").trim(); + if (!message) return json(res, 400, { error: "Say what you'd like changed." }); + + const env = loadEnv(); + if (!env.CLAUDE_CODE_OAUTH_TOKEN) { + return json(res, 401, { error: "No Claude token set. Add one with the 🔑 button." }); + } + + // Continue an existing conversation, or start a new one. + let job = body.conversationId && jobs.get(body.conversationId); + if (job) { job.done = false; job.events = []; job.jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; } + else job = newJob(body.page); + + busy = true; + runAgent(job, { message, page: body.page, selections: body.selections, model: body.model }); + return json(res, 200, { conversationId: job.conversationId }); + } + + if (path === "/stream") { + const id = url.searchParams.get("conversationId"); + const job = id && jobs.get(id); + if (!job) return json(res, 404, { error: "unknown conversation" }); + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + // replay buffered, then stream live + for (const ev of job.events) res.write(`data: ${JSON.stringify(ev)}\n\n`); + if (job.done) return res.end(); + job.clients.add(res); + req.on("close", () => job.clients.delete(res)); + return; + } + + if (path === "/publish" && method === "POST") { + if (busy) return json(res, 429, { error: "Busy — try again in a moment." }); + const { conversationId } = await readBody(req); + const job = conversationId && jobs.get(conversationId); + if (!job) return json(res, 404, { error: "Nothing to publish." }); + + busy = true; + const built = await build({}); // real build to public/ + if (!built.ok) { busy = false; return json(res, 500, { error: "Build failed — not published." }); } + await git(["add", "--", "app", "brand", "api/db", "api/cli"]); + const summary = (job.page && job.page !== "/" ? job.page : "homepage"); + await git(["commit", "-q", "-m", `console: edit ${summary}\n\n[published via astroagent console]`]); + rmSync(join(PREVIEW_DIR, job.jobId), { recursive: true, force: true }); + busy = false; + return json(res, 200, { ok: true, live: job.page || "/" }); + } + + if (path === "/discard" && method === "POST") { + if (busy) return json(res, 429, { error: "Busy — try again in a moment." }); + const { conversationId } = await readBody(req); + const job = conversationId && jobs.get(conversationId); + busy = true; + const CONTENT = ["app", "brand", "api/db", "api/cli"]; + await git(["checkout", "--", ...CONTENT]); + await git(["clean", "-fd", ...CONTENT]); + if (job) rmSync(join(PREVIEW_DIR, job.jobId), { recursive: true, force: true }); + busy = false; + if (job) jobs.delete(job.conversationId); + return json(res, 200, { ok: true }); + } + + return json(res, 404, { error: "not found" }); + } catch (err) { + return json(res, 500, { error: err.message }); + } +}); + +if (!existsSync(PREVIEW_DIR)) mkdirSync(PREVIEW_DIR, { recursive: true }); +server.listen(PORT, "127.0.0.1", () => { + console.log(`[console] runner on 127.0.0.1:${PORT}, repo ${REPO}`); +}); diff --git a/api/public/controllers/adminauth.php b/api/public/controllers/adminauth.php index eda0fd6..278b845 100644 --- a/api/public/controllers/adminauth.php +++ b/api/public/controllers/adminauth.php @@ -82,6 +82,21 @@ class AdminAuth extends PublicController $this->json(['admin' => false]); } + /** + * nginx auth_request target. Returns 200 (empty) if the current session is + * an admin, 401 otherwise. No body — nginx only reads the status. This is + * what gates every /devconsole and /_preview request at the edge. + */ + public function check(): void + { + if (empty($_SESSION['admin']['ok'])) { + http_response_code(401); + exit; + } + http_response_code(204); + exit; + } + public function me(): void { $admin = $_SESSION['admin']['ok'] ?? false;