✨ 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:
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user