feat(vscode): 多人协作二期:状态分层/写校验/签名/团队视图

- 本机状态(会话id/worktree/profile/prDiffFile)迁 workspaceState,
  共享 state JSON 只留团队字段;读侧本地记录叠加,旧数据兜底
- mergeStateJsonComment 写后读回校验,并发覆盖时在最新状态上重放一次
- webhook 支持 Gitea secret,校验 X-Gitea-Signature(HMAC-SHA256),
  不匹配 401;设置面板新增 Webhook Secret 字段
- 看板新增 我的/全部 范围切换(团队视图),按工作区持久化
This commit is contained in:
2026-08-19 00:32:06 +08:00
parent c370214d12
commit faf227412e
18 changed files with 665 additions and 202 deletions
+47 -39
View File
@@ -21,6 +21,7 @@ import { detectRepo } from '../git/remote'
import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api'
import { loadIssues, loadSingleIssue } from '../gitea/issueLoader'
import { mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson'
import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState'
import { logger } from '../logging/logger'
import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals'
import { getSettings } from '../settings/store'
@@ -86,6 +87,7 @@ class WebhookCoordinator {
}
this.server = new WebhookServer()
this.server.setSecret(getSettings(ctx).webhookSecret)
this.eventSubscription = this.server.onEvent((event: WebhookEvent) => {
void this.handleEvent(event)
})
@@ -160,6 +162,11 @@ class WebhookCoordinator {
await srv.start(port)
}
/** 设置面板保存后热更新签名 secret,无需重启监听。 */
ensureSecret(secret: string): void {
this.server?.setSecret(secret)
}
/** Register a pending hook after createWebhook on gitea succeeded. */
async addPending(issueNumber: number, info: PendingHook): Promise<void> {
this.pendingHooks.set(issueNumber, info)
@@ -529,14 +536,15 @@ class WebhookCoordinator {
message: `匹配到 pending 创建 nonce=${nonce} → 写入 state JSON`,
details: `issue=#${event.issueNumber} sessionId=${pending.sessionId ?? '<待定>'}`,
})
const extra: Record<string, unknown> = {
column: 'todo',
color: pending.color,
}
// 会话 id / profile 路径是本机状态,落 workspaceState;共享评论只写
// column / color 这类团队可见字段。
const localPatch: Record<string, unknown> = {}
if (typeof pending.sessionId === 'string' && pending.sessionId.length > 0)
extra.sessionId = pending.sessionId
localPatch.sessionId = pending.sessionId
if (typeof pending.brainstormProfilePath === 'string' && pending.brainstormProfilePath.length > 0)
extra.brainstormProfilePath = pending.brainstormProfilePath
localPatch.brainstormProfilePath = pending.brainstormProfilePath
if (Object.keys(localPatch).length > 0)
await mergeLocalIssueState(this.ctx, 'gitea', event.issueNumber, localPatch)
try {
await mergeStateJsonComment({
@@ -545,7 +553,10 @@ class WebhookCoordinator {
repo: remote.repo,
token,
issueNumber: event.issueNumber,
extra,
extra: {
column: 'todo',
color: pending.color,
},
})
}
catch (err) {
@@ -589,7 +600,7 @@ class WebhookCoordinator {
if (this.activePanel) {
try {
const issue = await loadSingleIssue({
const loaded = await loadSingleIssue({
host: remote.host,
owner: remote.owner,
repo: remote.repo,
@@ -597,6 +608,7 @@ class WebhookCoordinator {
workspaceRoot: ws,
issueNumber: event.issueNumber,
})
const issue = loaded ? overlayLocalIssueState(this.ctx, [loaded], ws)[0] : null
if (issue) {
this.activePanel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined })
if (pending) {
@@ -1473,39 +1485,35 @@ class WebhookCoordinator {
return
}
// Re-fetch state JSON to pick up the issue's worktreePath. We don't
// hard-fail if it's missing — triggerAutoReviewTab will fall back to
// workspaceRoot and toast the user.
// worktree 是本机状态:优先 workspaceState 本地记录('' 墓碑 = 本机已删,
// 不回落);本地无记录时读共享评论兜底(存量数据)。缺失不硬失败——
// triggerAutoReviewTab 会回退 workspaceRoot toast
let worktreePath = ''
try {
const comments = await listIssueComments({
host: ctx.host,
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
index: issueNumber,
})
const last = comments[comments.length - 1]
const body = (last?.body ?? '').trim()
if (body) {
try {
const parsed = JSON.parse(body) as { worktreePath?: unknown }
if (typeof parsed?.worktreePath === 'string' && parsed.worktreePath.length > 0)
worktreePath = parsed.worktreePath
}
catch {
// last comment isn't JSON — proceed without a worktree.
}
}
const local = getLocalIssueState(this.ctx, 'gitea', issueNumber)
if (local.worktreePath !== undefined) {
worktreePath = local.worktreePath
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: `读取 state JSON 失败(triggerReview#${issueNumber}`,
details: message,
})
else {
try {
const state = await readStateJsonComment({
host: ctx.host,
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
issueNumber,
})
if (typeof state.worktreePath === 'string')
worktreePath = state.worktreePath
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: `读取 state JSON 失败(triggerReview#${issueNumber}`,
details: message,
})
}
}
logger.add({
+40 -1
View File
@@ -24,6 +24,8 @@
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import type { Event } from 'vscode'
import { Buffer } from 'node:buffer'
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createServer } from 'node:http'
import { EventEmitter } from 'vscode'
import { logger } from '../logging/logger'
@@ -111,10 +113,33 @@ export interface PushWebhookEvent {
export class WebhookServer {
private server?: Server
private port?: number
private secret = ''
private readonly emitter = new EventEmitter<WebhookEvent>()
readonly onEvent: Event<WebhookEvent> = this.emitter.event
/**
* 设置(或清空)Gitea webhook secret。非空时对每个请求校验
* `X-Gitea-Signature`(HMAC-SHA256 over 原始 body 字节),不匹配一律 401
* ——端口经 frp 暴露公网,没有签名任何人都能伪造事件触发 codex 执行。
*/
setSecret(secret: string): void {
this.secret = secret
}
/** 时序安全的签名比对;头缺失/长度不符/非法 hex 都按不通过处理。 */
private verifySignature(rawBody: Buffer, signatureHeader: string): boolean {
const expected = createHmac('sha256', this.secret).update(rawBody).digest()
let received: Buffer
try {
received = Buffer.from(signatureHeader, 'hex')
}
catch {
return false
}
return received.length === expected.length && timingSafeEqual(received, expected)
}
/** The port currently bound, or undefined if the server is not listening. */
get currentPort(): number | undefined {
return this.port
@@ -220,7 +245,21 @@ export class WebhookServer {
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => {
try {
const body = Buffer.concat(chunks).toString('utf-8')
const rawBody = Buffer.concat(chunks)
if (this.secret) {
const signatureHeader = (req.headers['x-gitea-signature'] || '').toString()
if (!signatureHeader || !this.verifySignature(rawBody, signatureHeader)) {
logger.add({
level: 'warn',
source: 'webhook',
message: `签名校验失败,拒绝请求 (signature=${signatureHeader ? '不匹配' : '<缺失>'})`,
})
res.writeHead(401, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: false, error: 'invalid_signature' }))
return
}
}
const body = rawBody.toString('utf-8')
const eventHeader = (req.headers['x-gitea-event'] || '').toString() || '<missing>'
logger.add({
level: 'info',