ework-web 0.10.61 → 0.10.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/coordination.ts +36 -0
- package/src/giteaApi.ts +33 -3
- package/src/index.ts +27 -6
- package/src/webhooks.ts +1 -1
package/package.json
CHANGED
package/src/coordination.ts
CHANGED
|
@@ -118,3 +118,39 @@ export async function resolveDaemonEndpoint(daemonId: number): Promise<string |
|
|
|
118
118
|
return null;
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
|
+
|
|
122
|
+
export interface RunningSessionInfo {
|
|
123
|
+
issueNumber: string;
|
|
124
|
+
sessionId: string;
|
|
125
|
+
daemonId: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Query daemon DB for sessions with state='running' belonging to a specific project scope.
|
|
130
|
+
* Uses REAL daemon session state instead of the display-only ai_status field.
|
|
131
|
+
*/
|
|
132
|
+
export async function getRunningSessionsForProject(scopeKey: string): Promise<RunningSessionInfo[]> {
|
|
133
|
+
try {
|
|
134
|
+
const rows = await getDB().all<{
|
|
135
|
+
tracker_issue_id: string;
|
|
136
|
+
opencode_session_id: string;
|
|
137
|
+
daemon_id: number;
|
|
138
|
+
}>(
|
|
139
|
+
`SELECT i.tracker_issue_id, s.opencode_session_id, i.owner_daemon_id AS daemon_id
|
|
140
|
+
FROM {{d_op_sessions}} s
|
|
141
|
+
JOIN {{d_issues}} i ON i.uid = s.issue_id
|
|
142
|
+
WHERE s.state = 'running'
|
|
143
|
+
AND i.tracker_scope_key = ?
|
|
144
|
+
AND s.opencode_session_id IS NOT NULL`,
|
|
145
|
+
[scopeKey],
|
|
146
|
+
);
|
|
147
|
+
return rows.map((r) => ({
|
|
148
|
+
issueNumber: r.tracker_issue_id,
|
|
149
|
+
sessionId: r.opencode_session_id,
|
|
150
|
+
daemonId: r.daemon_id,
|
|
151
|
+
}));
|
|
152
|
+
} catch (e) {
|
|
153
|
+
log.info(`coordination: running-sessions query failed (${e instanceof Error ? e.message : String(e)})`);
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/giteaApi.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// bg audit) plus a few cheap stubs (/version, /user, /repos/:o/:r) that
|
|
13
13
|
// Gitea clients commonly hit during connection bootstrap.
|
|
14
14
|
|
|
15
|
-
import { getDB } from "./db";
|
|
15
|
+
import { getDB, getConfigAll } from "./db";
|
|
16
16
|
import {
|
|
17
17
|
StoreError,
|
|
18
18
|
getProject,
|
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
listReactionsFor,
|
|
33
33
|
canWriteProject,
|
|
34
34
|
canReadProject,
|
|
35
|
+
ensureUser,
|
|
36
|
+
getUserByLogin,
|
|
35
37
|
type UserRow,
|
|
36
38
|
} from "./store";
|
|
37
39
|
import {
|
|
@@ -95,6 +97,12 @@ function asContent(v: unknown): string | undefined {
|
|
|
95
97
|
return trimmed;
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
async function canSudo(u: UserRow): Promise<boolean> {
|
|
101
|
+
const cfg = await getConfigAll();
|
|
102
|
+
const allow = (cfg["sudoLogins"] ?? "").split(",").map((s: string) => s.trim()).filter(Boolean);
|
|
103
|
+
return allow.includes(u.login);
|
|
104
|
+
}
|
|
105
|
+
|
|
98
106
|
export async function handleGiteaApi(
|
|
99
107
|
req: Request,
|
|
100
108
|
url: URL,
|
|
@@ -103,8 +111,30 @@ export async function handleGiteaApi(
|
|
|
103
111
|
const path = url.pathname;
|
|
104
112
|
if (!path.startsWith("/api/v1/")) return null;
|
|
105
113
|
const origin = new URL(req.url).origin;
|
|
106
|
-
const
|
|
107
|
-
if (!
|
|
114
|
+
const caller = ctx.user;
|
|
115
|
+
if (!caller) return giteaError(401, "requires authentication");
|
|
116
|
+
|
|
117
|
+
// ─── Sudo: Gitea convention for impersonation ───
|
|
118
|
+
// Allows bridge/integration processes to post on behalf of real users.
|
|
119
|
+
// Permission gated by config KV `sudoLogins` whitelist (not is_admin — bridges
|
|
120
|
+
// should not be admins). Sudo-Kind header controls kind for first-creation
|
|
121
|
+
// only; existing users' kind is never changed via sudo.
|
|
122
|
+
const sudoLoginRaw = req.headers.get("Sudo") ?? url.searchParams.get("sudo");
|
|
123
|
+
const sudoLogin = sudoLoginRaw?.trim() || null;
|
|
124
|
+
let user: UserRow = caller;
|
|
125
|
+
if (sudoLogin && sudoLogin !== caller.login) {
|
|
126
|
+
if (!(await canSudo(caller))) return giteaError(403, "sudo requires permission");
|
|
127
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(sudoLogin)) return giteaError(400, "invalid sudo login");
|
|
128
|
+
const sudoKindRaw = req.headers.get("Sudo-Kind")?.trim();
|
|
129
|
+
const existing = await getUserByLogin(sudoLogin);
|
|
130
|
+
if (existing) {
|
|
131
|
+
user = existing;
|
|
132
|
+
} else {
|
|
133
|
+
const kind = sudoKindRaw === "bot" || sudoKindRaw === "system" ? sudoKindRaw : "human";
|
|
134
|
+
user = await ensureUser(sudoLogin, kind);
|
|
135
|
+
}
|
|
136
|
+
if (!user.is_active) return giteaError(403, "sudo target inactive");
|
|
137
|
+
}
|
|
108
138
|
|
|
109
139
|
if (ROUTES.version.test(path) && req.method === "GET") {
|
|
110
140
|
return { status: 200, body: { version: "1.22.0" } };
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { homedir } from "os";
|
|
|
6
6
|
import { loadConfig, DB_OVERRIDABLE, parseOverride, resolveTtsBackend } from "./config";
|
|
7
7
|
import type { Config } from "./config";
|
|
8
8
|
import { setConfig, deleteConfig, getConfigAll, initDB, getDB } from "./db";
|
|
9
|
-
import { getActiveDaemons, listAllDaemons, getSessionDaemonMap, resolveDaemonEndpoint } from "./coordination";
|
|
9
|
+
import { getActiveDaemons, listAllDaemons, getSessionDaemonMap, resolveDaemonEndpoint, getRunningSessionsForProject } from "./coordination";
|
|
10
10
|
import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv, migrateDaemonSqliteToMysql, generateMysqlDDL } from "./db-admin";
|
|
11
11
|
import type { MysqlTargetOpts } from "./db-admin";
|
|
12
12
|
import { checkAuth, makeAuthCookieHeader, clearAuthCookieHeader, loginHTML, sanitizeNext, ensureBootstrapAdmin, ensureBootstrapSystem, isReservedSystemLogin } from "./auth";
|
|
@@ -1311,6 +1311,22 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1311
1311
|
return json({ ok: true });
|
|
1312
1312
|
}
|
|
1313
1313
|
|
|
1314
|
+
if (url.pathname === "/api/sudo-policy" && req.method === "GET") {
|
|
1315
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
1316
|
+
const cfg = await getConfigAll();
|
|
1317
|
+
const list = (k: string, d: string) => (cfg[k] ?? d).split(",").map((s) => s.trim()).filter(Boolean);
|
|
1318
|
+
return json({ sudoLogins: list("sudoLogins", "") });
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
if (url.pathname === "/api/sudo-policy" && req.method === "POST") {
|
|
1322
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
1323
|
+
const payload = await req.json().catch(() => ({} as Record<string, unknown>));
|
|
1324
|
+
const join = (v: unknown) => Array.isArray(v) ? v.filter((s) => typeof s === "string").join(",") : (typeof v === "string" ? v : "");
|
|
1325
|
+
const sl = join((payload as { sudoLogins?: unknown }).sudoLogins);
|
|
1326
|
+
if (sl) await setConfig("sudoLogins", sl); else await deleteConfig("sudoLogins");
|
|
1327
|
+
return json({ ok: true });
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1314
1330
|
if (url.pathname === "/api/router/daemons" && req.method === "GET") {
|
|
1315
1331
|
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
1316
1332
|
try {
|
|
@@ -1978,12 +1994,17 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1978
1994
|
return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
|
|
1979
1995
|
}
|
|
1980
1996
|
const issues = await listIssues(project.id, { state: "all" });
|
|
1981
|
-
const
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1997
|
+
const running = await getRunningSessionsForProject(`${owner}/${repo}`);
|
|
1998
|
+
const issueIds = new Set<number>();
|
|
1999
|
+
for (const r of running) {
|
|
2000
|
+
const it = issues.find((i) => String(i.number) === r.issueNumber);
|
|
2001
|
+
if (it) issueIds.add(it.id);
|
|
2002
|
+
}
|
|
2003
|
+
for (const id of issueIds) {
|
|
2004
|
+
await updateIssueAiStatus(id, "halted");
|
|
2005
|
+
void emitStatusChanged(project.id, id, "processing", "halted", ctx.user!.login, url.origin);
|
|
1985
2006
|
}
|
|
1986
|
-
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`已停止 ${
|
|
2007
|
+
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`已停止 ${issueIds.size} 个运行中AI会话`)}`, 303);
|
|
1987
2008
|
}
|
|
1988
2009
|
|
|
1989
2010
|
const settingsDispatchMatch = url.pathname.match(SETTINGS_DISPATCH_RE);
|
package/src/webhooks.ts
CHANGED
|
@@ -756,7 +756,7 @@ export async function emitStatusChanged(
|
|
|
756
756
|
const project = await getProjectById(projectId);
|
|
757
757
|
if (!project) return;
|
|
758
758
|
const issue = await getIssueById(issueId);
|
|
759
|
-
if (!issue) return;
|
|
759
|
+
if (!issue) { log.warn(`emitStatusChanged: issue ${issueId} not found (project=${projectId}) — silent skip prevented`); return; }
|
|
760
760
|
const commentCount = await countCommentsSafe(issueId);
|
|
761
761
|
const repo = buildRepository(project, origin);
|
|
762
762
|
const sender = buildUser(actor, origin);
|