From b54062c8c75aea0c47977abfaa0a78decfb3b8b5 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Tue, 28 Jul 2026 00:24:05 +0800 Subject: [PATCH 1/2] feat(settings): group workdir + lifecycle script editor in daemon groups section Extends the Daemon groups section with a per-group config editor: workdir template (with {owner}/{repo}/{issue}/{session} variables), init script (runs on delivery), destroy script (runs on close), and an env-init field (placeholder, disabled). Configs are collected from all group names (daemonGroups + groupBindings + existing configs) and saved into strategy.groupConfigs alongside the rest of the strategy via the existing /api/router/strategy endpoint. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/static/daemon-groups.js | 63 ++++++++++++++++++++++++++++++++++++- src/views/settings.ts | 5 +++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/static/daemon-groups.js b/src/static/daemon-groups.js index 93f92f8..88398b4 100644 --- a/src/static/daemon-groups.js +++ b/src/static/daemon-groups.js @@ -6,7 +6,7 @@ const saveBtn = document.getElementById("strategy-save"); const addBindingBtn = document.getElementById("binding-add"); - let currentStrategy = { strategy: "least-loaded", groupBindings: {}, daemonGroups: {} }; + let currentStrategy = { strategy: "least-loaded", groupBindings: {}, daemonGroups: {}, groupConfigs: {} }; function showResult(msg, ok) { result.textContent = msg; @@ -14,6 +14,47 @@ setTimeout(() => { result.textContent = ""; result.className = "db-result"; }, 3000); } + function esc(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + } + + function collectGroupNames() { + var names = new Set(); + var dg = currentStrategy.daemonGroups || {}; + Object.values(dg).forEach(function (arr) { (arr || []).forEach(function (g) { names.add(g); }); }); + var gb = currentStrategy.groupBindings || {}; + Object.values(gb).forEach(function (g) { names.add(g); }); + Object.keys(currentStrategy.groupConfigs || {}).forEach(function (g) { names.add(g); }); + return Array.from(names).sort(); + } + + function renderGroupConfigs() { + var container = document.getElementById("group-configs-list"); + if (!container) return; + var names = collectGroupNames(); + if (!names.length) { + container.innerHTML = '

先添加分组(上方给 daemon 打组或绑定 repo→组)后这里会出现配置项。

'; + return; + } + container.innerHTML = names.map(function (g) { + var cfg = (currentStrategy.groupConfigs || {})[g] || {}; + return '
' + + '' + esc(g) + '' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
'; + }).join(""); + } + async function load() { try { const [daemonsRes, strategyRes] = await Promise.all([ @@ -35,6 +76,7 @@ }).join("") || '没有已注册的 daemon(节点启动后自动出现)'; renderBindings(); + renderGroupConfigs(); } catch (e) { tbody.innerHTML = '无法连接 router — 确认 router 已启动'; } @@ -67,6 +109,7 @@ document.getElementById("binding-repo").value = ""; document.getElementById("binding-group").value = ""; renderBindings(); + renderGroupConfigs(); }; saveBtn.onclick = async () => { @@ -79,6 +122,24 @@ }); currentStrategy.daemonGroups = newGroups; + var newConfigs = {}; + document.querySelectorAll("details.gc-card").forEach(function (card) { + var g = card.querySelector("summary").textContent.trim(); + var workdir = (card.querySelector(".gc-workdir") || {}).value || ""; + var init = (card.querySelector(".gc-init") || {}).value || ""; + var destroy = (card.querySelector(".gc-destroy") || {}).value || ""; + var envinit = (card.querySelector(".gc-envinit") || {}).value || ""; + if (workdir.trim() || init.trim() || destroy.trim() || envinit.trim()) { + newConfigs[g] = { + workdirTemplate: workdir.trim() || undefined, + initScript: init.trim() || undefined, + destroyScript: destroy.trim() || undefined, + envInitScript: envinit.trim() || undefined, + }; + } + }); + currentStrategy.groupConfigs = newConfigs; + try { const res = await fetch("/api/router/strategy", { method: "POST", diff --git a/src/views/settings.ts b/src/views/settings.ts index c53dbd1..93777c0 100644 --- a/src/views/settings.ts +++ b/src/views/settings.ts @@ -208,6 +208,11 @@ function buildGroupsSection(): string { +
+

分组工作目录与脚本

+

为每个分组配置工作目录模板和生命周期脚本。模板变量:{owner} {repo} {issue} {session}。脚本在 daemon 上以工作目录为 cwd 执行。

+
+
From 715a7afc6588b2b1a2f7027e0abcb6dfc74b9d0f Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Tue, 28 Jul 2026 01:09:33 +0800 Subject: [PATCH 2/2] fix(review): inject router admin token + textarea limits + fix placeholder C1 (CRITICAL): web proxy to /api/router/strategy now injects Authorization: Bearer header when WORK_ROUTER_ADMIN_TOKEN is set, complementing the router-side auth check. H1+M7 (HIGH/MEDIUM): script textareas now have maxlength=4096 (workdir=512) to prevent configs that exceed the router's 12KB header cap. Init script placeholder fixed from the non-existent ${CLONE_URL} to real env vars ($EWORK_OWNER/$EWORK_REPO). Labels now document available env vars and template variables. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/config.ts | 6 ++++-- src/index.ts | 6 ++++-- src/static/daemon-groups.js | 14 +++++++------- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/config.ts b/src/config.ts index 1d75c2d..060705c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -78,6 +78,7 @@ export const configSchema = z.object({ daemonBotLogin: z.string().default(""), daemonWebhookUrl: z.string().default(""), daemonWebhookSecret: z.string().default(""), + routerAdminToken: z.string().default(""), // Default "provider/model" string passed to `opencode run --model `. // Empty = let opencode pick per its own opencode.json + env. ework-daemon // pushes this (or the per-project override) on every spawn to defend @@ -175,8 +176,9 @@ export async function loadConfig(): Promise { ttsDefaultBackend: db.ttsDefaultBackend ?? process.env.WORK_TTS_DEFAULT_BACKEND, ttsSpeed: db.ttsSpeed ?? process.env.WORK_TTS_SPEED, daemonBotLogin: process.env.WORK_DAEMON_BOT_LOGIN ?? "", - daemonWebhookUrl: process.env.WORK_DAEMON_WEBHOOK_URL ?? "", - daemonWebhookSecret: process.env.WORK_DAEMON_WEBHOOK_SECRET ?? "", + daemonWebhookUrl: process.env.WORK_DAEMON_WEBHOOK_URL ?? "", + daemonWebhookSecret: process.env.WORK_DAEMON_WEBHOOK_SECRET ?? "", + routerAdminToken: process.env.WORK_ROUTER_ADMIN_TOKEN ?? "", defaultModel: db.defaultModel ?? process.env.WORK_DEFAULT_MODEL, autowireActive: process.env.WORK_AUTOWIRE_ACTIVE !== "false", webhookMaxConcurrent: Number(process.env.WORK_WEBHOOK_MAX_CONCURRENT ?? "6"), diff --git a/src/index.ts b/src/index.ts index a283def..e38e482 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1060,16 +1060,18 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean if (url.pathname === "/api/router/strategy") { if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403); const routerUrl = cfg.daemonWebhookUrl.replace(/\/$/, ""); + const routerHeaders: Record = { "Content-Type": "application/json" }; + if (cfg.routerAdminToken) routerHeaders["Authorization"] = `Bearer ${cfg.routerAdminToken}`; try { if (req.method === "GET") { - const res = await fetch(`${routerUrl}/api/strategy`, { signal: AbortSignal.timeout(5000) }); + const res = await fetch(`${routerUrl}/api/strategy`, { headers: routerHeaders, signal: AbortSignal.timeout(5000) }); return json(await res.json()); } if (req.method === "POST") { const body = await req.text(); const res = await fetch(`${routerUrl}/api/strategy`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: routerHeaders, body, signal: AbortSignal.timeout(5000), }); diff --git a/src/static/daemon-groups.js b/src/static/daemon-groups.js index 88398b4..bc6dcf3 100644 --- a/src/static/daemon-groups.js +++ b/src/static/daemon-groups.js @@ -43,14 +43,14 @@ return '
' + '' + esc(g) + '' + '
' + - '' + - '' + - '' + - '' + - '' + - '' + + '' + + '' + + '' + + '' + + '' + + '' + '' + - '' + + '' + '
'; }).join(""); }