From 5c3636fe6ad0a7796459cabc657eaf5ef1e23716 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 23 Mar 2026 13:31:23 +0800 Subject: [PATCH] feat: expose boss via nginx base path --- README.md | 10 ++-- compose.cloud.yaml | 1 + public/app.js | 22 ++++++++- public/index.html | 6 +-- scripts/boss_cloud_status.sh | 2 +- scripts/deploy_ai_glasses_server.sh | 64 +++++++++++++++++++++++-- scripts/server_start.sh | 8 +++- scripts/server_status.sh | 8 +++- src/server.ts | 72 ++++++++++++++++++----------- 9 files changed, 149 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index b4af46e..e4fbd24 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ BOSS_DATA_FILE=.boss-data/local-dev.json npm run dev 默认云端入口: ```bash -http://111.231.132.51:43210 +http://111.231.132.51/boss/ ``` 这就是你当前最短的“主账号对话入口”。 @@ -154,7 +154,7 @@ npm run worker -- \ 打开云端控制台: ```bash -http://111.231.132.51:43210 +http://111.231.132.51/boss/ ``` 这是最符合产品策略的入口,也是主控面。 @@ -163,9 +163,9 @@ http://111.231.132.51:43210 仓库里自带一个简单 CLI,可以直接发消息给 Boss: ```bash -BOSS_SERVER_URL=http://111.231.132.51:43210 ./scripts/boss_chat.sh create "Boss 主控对话" -BOSS_SERVER_URL=http://111.231.132.51:43210 ./scripts/boss_chat.sh send "先调研这个问题,不要急着改代码。" -BOSS_SERVER_URL=http://111.231.132.51:43210 ./scripts/boss_chat.sh status +BOSS_SERVER_URL=http://111.231.132.51/boss ./scripts/boss_chat.sh create "Boss 主控对话" +BOSS_SERVER_URL=http://111.231.132.51/boss ./scripts/boss_chat.sh send "先调研这个问题,不要急着改代码。" +BOSS_SERVER_URL=http://111.231.132.51/boss ./scripts/boss_chat.sh status ``` 这条 CLI 入口后面也很容易改造成 Telegram / Slack / 企业微信 webhook。 diff --git a/compose.cloud.yaml b/compose.cloud.yaml index 292f00e..0828f34 100644 --- a/compose.cloud.yaml +++ b/compose.cloud.yaml @@ -6,6 +6,7 @@ services: environment: PORT: 43210 BOSS_DATA_FILE: .boss-data/cloud-store.json + BOSS_BASE_PATH: /boss ports: - "43210:43210" volumes: diff --git a/public/app.js b/public/app.js index 920b5bb..27d9fc2 100644 --- a/public/app.js +++ b/public/app.js @@ -10,6 +10,24 @@ const state = { banner: null, }; +function normalizeBasePath(pathname) { + if (!pathname || pathname === "/") { + return ""; + } + + return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; +} + +const appBasePath = normalizeBasePath(new URL(".", import.meta.url).pathname); + +function resolveUrl(path) { + if (/^https?:\/\//.test(path)) { + return path; + } + + return `${appBasePath}${path}`; +} + const TASK_GROUPS = [ { title: "进行中", statuses: ["assigned", "running"] }, { title: "等待处理", statuses: ["planning", "queued"] }, @@ -90,7 +108,7 @@ function renderBanner() { } async function request(url, options = {}) { - const response = await fetch(url, { + const response = await fetch(resolveUrl(url), { headers: { "Content-Type": "application/json" }, ...options, }); @@ -791,7 +809,7 @@ elements.resetDemo.addEventListener("click", async () => { render(); }); -const stream = new EventSource("/api/events/stream"); +const stream = new EventSource(resolveUrl("/api/events/stream")); stream.onopen = () => { const wasReconnecting = state.connection === "reconnecting"; diff --git a/public/index.html b/public/index.html index 43073c7..f1b8aa4 100644 --- a/public/index.html +++ b/public/index.html @@ -4,8 +4,8 @@ Boss Control Plane - - + +
@@ -107,6 +107,6 @@
- + diff --git a/scripts/boss_cloud_status.sh b/scripts/boss_cloud_status.sh index bd440bc..c3fd753 100755 --- a/scripts/boss_cloud_status.sh +++ b/scripts/boss_cloud_status.sh @@ -7,4 +7,4 @@ export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" export AG_SERVER_SKILL="${AG_SERVER_SKILL:-$CODEX_HOME/skills/ai-glasses-server-debug}" export AG_SERVER="${AG_SERVER:-$AG_SERVER_SKILL/scripts/server_ssh.sh}" -"$AG_SERVER" exec "set -euo pipefail; cd $(printf '%q' "$remote_dir"); if sudo docker ps --format '{{.Names}}' | grep -qx 'boss-control-plane'; then sudo docker compose -f compose.cloud.yaml -p boss ps; echo '---'; curl -fsS http://127.0.0.1:43210/api/health; else ./scripts/server_status.sh; fi" +"$AG_SERVER" exec "set -euo pipefail; cd $(printf '%q' "$remote_dir"); if sudo docker ps --format '{{.Names}}' | grep -qx 'boss-control-plane'; then sudo docker compose -f compose.cloud.yaml -p boss ps; echo '---'; curl -fsS http://127.0.0.1:43210/boss/api/health; else BOSS_BASE_PATH=/boss ./scripts/server_status.sh; fi" diff --git a/scripts/deploy_ai_glasses_server.sh b/scripts/deploy_ai_glasses_server.sh index ca7aa56..cc6b9b0 100755 --- a/scripts/deploy_ai_glasses_server.sh +++ b/scripts/deploy_ai_glasses_server.sh @@ -45,11 +45,40 @@ if [[ "\$DEPLOY_MODE" != "node" ]]; then fi if [[ "\$docker_ok" -eq 1 ]]; then + sudo python3 - <<'PY' +from pathlib import Path +path = Path("/etc/nginx/sites-enabled/hybrid_updates.conf") +text = path.read_text() +if "location /boss/" not in text: + block = """ + location = /boss { + return 302 /boss/; + } + + location /boss/ { + proxy_pass http://127.0.0.1:43210; + proxy_http_version 1.1; + proxy_set_header Host $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_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + } + +""" + marker = " location / {\n" + text = text.replace(marker, block + marker, 1) + path.write_text(text) +PY + sudo nginx -t + sudo systemctl reload nginx echo "__BOSS_DEPLOY_OK__" echo "mode=docker" sudo docker compose -f compose.cloud.yaml -p boss ps sleep 3 - curl -fsS http://127.0.0.1:43210/api/health + curl -fsS http://127.0.0.1:43210/boss/api/health exit 0 fi @@ -60,10 +89,39 @@ fi npm install npm run build -PORT=43210 BOSS_DATA_FILE=.boss-data/cloud-store.json ./scripts/server_start.sh +PORT=43210 BOSS_DATA_FILE=.boss-data/cloud-store.json BOSS_BASE_PATH=/boss ./scripts/server_start.sh +sudo python3 - <<'PY' +from pathlib import Path +path = Path("/etc/nginx/sites-enabled/hybrid_updates.conf") +text = path.read_text() +if "location /boss/" not in text: + block = """ + location = /boss { + return 302 /boss/; + } + + location /boss/ { + proxy_pass http://127.0.0.1:43210; + proxy_http_version 1.1; + proxy_set_header Host $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_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + } + +""" + marker = " location / {\n" + text = text.replace(marker, block + marker, 1) + path.write_text(text) +PY +sudo nginx -t +sudo systemctl reload nginx echo "__BOSS_DEPLOY_OK__" echo "mode=node" -./scripts/server_status.sh +BOSS_BASE_PATH=/boss ./scripts/server_status.sh EOF ) diff --git a/scripts/server_start.sh b/scripts/server_start.sh index b6b0109..9ef8797 100755 --- a/scripts/server_start.sh +++ b/scripts/server_start.sh @@ -6,6 +6,12 @@ data_dir="${BOSS_DATA_DIR:-.boss-data}" data_file="${BOSS_DATA_FILE:-$data_dir/server-store.json}" pid_file="${BOSS_PID_FILE:-$data_dir/server.pid}" log_file="${BOSS_LOG_FILE:-$data_dir/server.log}" +base_path="${BOSS_BASE_PATH:-}" +if [[ -n "$base_path" && "$base_path" != /* ]]; then + base_path="/$base_path" +fi +base_path="${base_path%/}" +health_url="http://127.0.0.1:${port}${base_path}/api/health" mkdir -p "$data_dir" @@ -27,7 +33,7 @@ nohup env PORT="$port" BOSS_DATA_FILE="$data_file" node dist/server.js >>"$log_f echo $! > "$pid_file" for _ in {1..30}; do - if curl -fsS "http://127.0.0.1:${port}/api/health" >/dev/null 2>&1; then + if curl -fsS "$health_url" >/dev/null 2>&1; then echo "Boss server started on :${port}" exit 0 fi diff --git a/scripts/server_status.sh b/scripts/server_status.sh index eb60057..a181487 100755 --- a/scripts/server_status.sh +++ b/scripts/server_status.sh @@ -5,6 +5,12 @@ port="${PORT:-43210}" data_dir="${BOSS_DATA_DIR:-.boss-data}" pid_file="${BOSS_PID_FILE:-$data_dir/server.pid}" log_file="${BOSS_LOG_FILE:-$data_dir/server.log}" +base_path="${BOSS_BASE_PATH:-}" +if [[ -n "$base_path" && "$base_path" != /* ]]; then + base_path="/$base_path" +fi +base_path="${base_path%/}" +health_url="http://127.0.0.1:${port}${base_path}/api/health" if [[ -f "$pid_file" ]]; then pid="$(cat "$pid_file")" @@ -18,7 +24,7 @@ else fi echo "---" -curl -fsS "http://127.0.0.1:${port}/api/health" || true +curl -fsS "$health_url" || true echo echo "---" tail -n 40 "$log_file" 2>/dev/null || true diff --git a/src/server.ts b/src/server.ts index c750a10..560561a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,6 +5,22 @@ import { BossEngine } from "./engine.js"; const engine = new BossEngine(); const app = Fastify({ logger: process.env.BOSS_DEBUG === "1" }); +const basePath = normalizeBasePath(process.env.BOSS_BASE_PATH ?? ""); + +function normalizeBasePath(input: string) { + if (!input || input === "/") { + return ""; + } + const normalized = input.startsWith("/") ? input : `/${input}`; + return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized; +} + +function withBase(pathname: string) { + if (!basePath) { + return pathname; + } + return pathname === "/" ? `${basePath}/` : `${basePath}${pathname}`; +} app.setErrorHandler((error, request, reply) => { const message = @@ -38,61 +54,61 @@ app.setErrorHandler((error, request, reply) => { await app.register(fastifyStatic, { root: path.resolve(process.cwd(), "public"), - prefix: "/", + prefix: withBase("/"), }); function ok() { return { ok: true }; } -app.get("/api/health", async () => ({ +app.get(withBase("/api/health"), async () => ({ status: "ok", sessions: engine.getState().sessions.length, workers: engine.getState().workers.length, })); -app.get("/api/bootstrap", async () => engine.bootstrap()); +app.get(withBase("/api/bootstrap"), async () => engine.bootstrap()); -app.get("/api/events", async (request) => { +app.get(withBase("/api/events"), async (request) => { const query = request.query as { limit?: string }; const limit = Number(query.limit ?? 100); return engine.listEvents(Number.isFinite(limit) ? limit : 100); }); -app.get("/api/sessions", async () => engine.listSessions()); +app.get(withBase("/api/sessions"), async () => engine.listSessions()); -app.post("/api/sessions", async (request) => { +app.post(withBase("/api/sessions"), async (request) => { const body = (request.body ?? {}) as { title?: string }; return engine.createSession(body.title); }); -app.post("/api/sessions/:sessionId/archive", async (request) => { +app.post(withBase("/api/sessions/:sessionId/archive"), async (request) => { const params = request.params as { sessionId: string }; return engine.archiveSession(params.sessionId); }); -app.post("/api/sessions/:sessionId/restore", async (request) => { +app.post(withBase("/api/sessions/:sessionId/restore"), async (request) => { const params = request.params as { sessionId: string }; return engine.restoreSession(params.sessionId); }); -app.get("/api/sessions/:sessionId", async (request) => { +app.get(withBase("/api/sessions/:sessionId"), async (request) => { const params = request.params as { sessionId: string }; return engine.getSession(params.sessionId); }); -app.get("/api/tasks/:taskId", async (request) => { +app.get(withBase("/api/tasks/:taskId"), async (request) => { const params = request.params as { taskId: string }; return engine.getTask(params.taskId); }); -app.post("/api/sessions/:sessionId/messages", async (request) => { +app.post(withBase("/api/sessions/:sessionId/messages"), async (request) => { const params = request.params as { sessionId: string }; const body = (request.body ?? {}) as { content?: string; channel?: string }; return engine.addMessage(params.sessionId, body.content ?? "", body.channel ?? "web"); }); -app.get("/api/events/stream", async (_request, reply) => { +app.get(withBase("/api/events/stream"), async (_request, reply) => { reply.raw.setHeader("Content-Type", "text/event-stream"); reply.raw.setHeader("Cache-Control", "no-cache"); reply.raw.setHeader("Connection", "keep-alive"); @@ -113,14 +129,14 @@ app.get("/api/events/stream", async (_request, reply) => { }); }); -app.get("/api/workers", async () => engine.getState().workers); +app.get(withBase("/api/workers"), async () => engine.getState().workers); -app.get("/api/workers/:workerId", async (request) => { +app.get(withBase("/api/workers/:workerId"), async (request) => { const params = request.params as { workerId: string }; return engine.getWorker(params.workerId); }); -app.post("/api/workers/register", async (request) => { +app.post(withBase("/api/workers/register"), async (request) => { const body = request.body as { name?: string; os?: "windows" | "macos" | "linux"; @@ -133,25 +149,25 @@ app.post("/api/workers/register", async (request) => { }); }); -app.post("/api/workers/:workerId/heartbeat", async (request) => { +app.post(withBase("/api/workers/:workerId/heartbeat"), async (request) => { const params = request.params as { workerId: string }; const body = (request.body ?? {}) as { load?: number }; return engine.heartbeat(params.workerId, body.load ?? 0); }); -app.post("/api/workers/:workerId/offline", async (request) => { +app.post(withBase("/api/workers/:workerId/offline"), async (request) => { const params = request.params as { workerId: string }; return engine.markWorkerOffline(params.workerId); }); -app.post("/api/workers/:workerId/claim-next", async (request) => { +app.post(withBase("/api/workers/:workerId/claim-next"), async (request) => { const params = request.params as { workerId: string }; return { task: engine.claimNextTask(params.workerId), }; }); -app.post("/api/tasks/:taskId/progress", async (request) => { +app.post(withBase("/api/tasks/:taskId/progress"), async (request) => { const params = request.params as { taskId: string }; const body = request.body as { workerId: string; @@ -168,7 +184,7 @@ app.post("/api/tasks/:taskId/progress", async (request) => { }); }); -app.post("/api/tasks/:taskId/complete", async (request) => { +app.post(withBase("/api/tasks/:taskId/complete"), async (request) => { const params = request.params as { taskId: string }; const body = request.body as { workerId: string; @@ -177,7 +193,7 @@ app.post("/api/tasks/:taskId/complete", async (request) => { return engine.completeTask(params.taskId, body.workerId, body.summary); }); -app.post("/api/tasks/:taskId/fail", async (request) => { +app.post(withBase("/api/tasks/:taskId/fail"), async (request) => { const params = request.params as { taskId: string }; const body = request.body as { workerId: string; @@ -186,27 +202,27 @@ app.post("/api/tasks/:taskId/fail", async (request) => { return engine.failTask(params.taskId, body.workerId, body.errorMessage); }); -app.post("/api/tasks/:taskId/pause", async (request) => { +app.post(withBase("/api/tasks/:taskId/pause"), async (request) => { const params = request.params as { taskId: string }; return engine.pauseTask(params.taskId); }); -app.post("/api/tasks/:taskId/cancel", async (request) => { +app.post(withBase("/api/tasks/:taskId/cancel"), async (request) => { const params = request.params as { taskId: string }; return engine.cancelTask(params.taskId); }); -app.post("/api/tasks/:taskId/resume", async (request) => { +app.post(withBase("/api/tasks/:taskId/resume"), async (request) => { const params = request.params as { taskId: string }; return engine.resumeTask(params.taskId); }); -app.post("/api/tasks/:taskId/requeue", async (request) => { +app.post(withBase("/api/tasks/:taskId/requeue"), async (request) => { const params = request.params as { taskId: string }; return engine.requeueTask(params.taskId); }); -app.post("/api/approvals/:approvalId/respond", async (request) => { +app.post(withBase("/api/approvals/:approvalId/respond"), async (request) => { const params = request.params as { approvalId: string }; const body = request.body as { approved: boolean; @@ -215,12 +231,12 @@ app.post("/api/approvals/:approvalId/respond", async (request) => { return engine.respondApproval(params.approvalId, body.approved, body.responder ?? "user"); }); -app.post("/api/demo/reset", async (request) => { +app.post(withBase("/api/demo/reset"), async (request) => { const body = (request.body ?? {}) as { preserveWorkers?: boolean }; return engine.resetDemo(body.preserveWorkers ?? true); }); -app.post("/api/reconcile", async () => engine.reconcileNow()); +app.post(withBase("/api/reconcile"), async () => engine.reconcileNow()); const port = Number(process.env.PORT ?? 43210); await app.listen({ port, host: "0.0.0.0" });