Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ loopback only; keep it that way.

The bridge returns `202 {ok:true}` once DSH accepted the message. It does not wait
for the job; the agent reports back to Fledge. `sessionId` in the payload routes
the job to a repository-specific Lead or Reviewer.
the job to a repository-specific Lead, Product, Test or Reviewer session. `kind`
may be `iterate`, `plan`, `scope`, `verify` or `review`.

The bridge also exposes `POST /dsh` (authenticated with `BRIDGE_TOKEN`) as a
small proxy for DSH `/api/bot`. Fledge uses it to read state, rename the Chief
Expand All @@ -85,11 +86,15 @@ The agent prompt includes:
3. for `kind=iterate`: push to `fork`, create/reuse an issue on `upstream`, open
one focused PR against `upstream`, no force-push and never merge; Fledge
performs the maintainer-enabled auto-merge after checking the PR;
4. for `kind=review`: review every open PR, score each 0–100; use
post a COMMENT review, never request changes or approve/merge, and report
`reviewScore` plus a `followUps` array for each Fledge PR that needs a
same-branch fix;
5. POST the result to `callbackUrl` with
4. for `kind=scope`: use the Product session to produce scope, non-goals,
acceptance criteria and a test plan; do not create a branch or PR;
5. for `kind=verify`: use the Test session to run the relevant tests against the
existing PR/branch and report `testRed`/`testGreen`; do not create a branch
or PR;
6. for `kind=review`: review every open PR, score each 0–100; post a COMMENT
review, never request changes or approve/merge, and report `reviewScore` plus
a `followUps` array for each Fledge PR that needs a same-branch fix;
7. POST the result to `callbackUrl` with
`{status, branch, prUrl, issueUrl, reviewScore, summary, error}`.

The payload also carries `repoRole` (`maintainer` or `developer`) and
Expand Down
16 changes: 14 additions & 2 deletions bridge/flybot-bridge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ async function sendToBot(text, sessionId) {
}

function buildText(job) {
const kind = job.kind === 'review' ? 'review' : job.kind === 'plan' ? 'plan' : job.kind === 'provision' ? 'provision' : 'iterate'
const kind = job.kind === 'review' ? 'review' : job.kind === 'plan' ? 'plan' : job.kind === 'scope' ? 'scope' : job.kind === 'verify' ? 'verify' : job.kind === 'provision' ? 'provision' : 'iterate'
const upstream = job.upstream || job.repo || {}
const fork = job.fork || job.repo || upstream
const rules = kind === 'review'
Expand All @@ -143,6 +143,18 @@ function buildText(job) {
'- Include problem/evidence, files, ordered steps, tests, acceptance criteria, risks, rollback and size.',
'- Finish with status succeeded and the plan in summary; no branch, prUrl or issueUrl.',
]
: kind === 'scope'
? [
'- This is a Product scoping job. Do not write code, create a branch, push, open a PR/issue or change settings.',
'- Read the issue, repository docs, open issues/PRs and tests. Produce scope, non-goals, acceptance criteria and a test plan.',
'- Finish with status succeeded and evidence.scope, evidence.acceptance and evidence.testPlan; no branch, prUrl or issueUrl.',
]
: kind === 'verify'
? [
'- This is a Test verification job. Do not write code, push, open a PR or merge.',
'- Read the existing PR/branch and run the relevant tests; report failing and passing evidence plus CI/check status.',
'- Finish with status succeeded and evidence.testRed, evidence.testGreen, evidence.tests and evidence.checks; no branch, prUrl or issueUrl.',
]
: kind === 'provision'
? ['- This is a provisioning request. Do not write code, push, or open PRs.']
: [
Expand Down Expand Up @@ -184,7 +196,7 @@ function buildText(job) {
'- Do not merge; Fledge handles maintainer-enabled auto-merge. Never force-push.',
'- End every GitHub issue, PR and comment with one small line: "<sub>Fledge · <team> · <role> · <step></sub>" where step is claimed, implemented, tested or reviewed. Never claim a role that did not do the work.',
'- The final callback must include "attribution": {"writtenBy":"...","claimedBy":"...","implementedBy":"...","testedBy":"...","reviewedBy":"..."} with role names only (Lead, Product, Test, Security, Reviewer, Dev-1, Dev-2). Omit a key for a step that did not happen; never write "not run", "pending" or "(Team)".',
'- Also include "evidence": {"tests":"<command and pass/fail>","checks":"<CI/check status>","files":<changed files>,"risk":"low|medium|high","rollback":"<one line>"}; use only what you actually ran.',
'- Also include "evidence": {"scope":"<scope>","acceptance":"<acceptance>","testPlan":"<test plan>","testRed":"<failing test>","testGreen":"<passing test>","tests":"<command and pass/fail>","checks":"<CI/check status>","files":<changed files>,"risk":"low|medium|high","rollback":"<one line>"}; use only what you actually ran.',
`- After each major step and at least every 10 minutes, POST ${job.callbackUrl} with JSON {"status":"running","summary":"<one line progress>","checkpoint":"<done + next>"}`,
` and "Authorization: Bearer ${job.callbackSecret}". This is a heartbeat, not a finish.`,
`- Finish by POSTing ${job.callbackUrl} with JSON {status, branch, prUrl, issueUrl, reviewScore, summary, error}`,
Expand Down
1 change: 1 addition & 0 deletions changelog.d/stage-sessions.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Product and Test now run as independent stage jobs. The Product scope job goes to the Product DSH session and returns scope, non-goals, acceptance criteria and a test plan; the Test verify job goes to the Test session and returns failing/passing test evidence. Stage mode queues verification after implementation before review; neither stage creates a branch or PR.
1 change: 1 addition & 0 deletions changelog.d/token-rotation.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added `scripts/rotate-instance-tokens.mjs` to rotate Fledge-managed bridge and per-instance gateway tokens, with a dry-run mode, an active-job guard and rollback on a failed bridge restart. The leaked GitHub development PAT is not managed by this script and must be rotated in GitHub settings.
9 changes: 7 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,13 @@ question can appear without a reload.

### Fledge-driven delivery chain

After an iterate job succeeds with a PR, Fledge queues a review job when the
repository has an enabled reviewer and no other job is active. A successful review
After an iterate job succeeds with a PR, Fledge queues a Product scope job from
the work-item action, and in stage mode queues a Test verify job to the Test
session before review. The scope and verify jobs use the DSH `sessionId` of the
Product/Test bot and only produce evidence (`scope`/`acceptance`/`testPlan` or
`testRed`/`testGreen`); they never create a branch or PR. After verification (or
iteration outside stage mode), Fledge queues a review job when the repository has
an enabled reviewer and no other job is active. A successful review
moves the linked work item to `approved` (score >= 80 and no follow-ups) or
`changes_requested`; follow-ups are queued on the same branch. Plan approval
moves the work item to `assigned`, and the execute action enqueues the
Expand Down
17 changes: 17 additions & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,23 @@ empty and set `ALLOW_TOKEN_LOGIN_ONLY=true`. The landing page then offers a
**GitHub token login** that uses `GET /user` only to confirm identity and never
stores the token. Configure OAuth before a public launch.

### Rotate managed instance tokens

Fledge-managed bridge and per-instance gateway tokens can be rotated without
touching user data:

```sh
cd /home/xuke/work/fledge
node --env-file=.env scripts/rotate-instance-tokens.mjs --dry-run
node --env-file=.env scripts/rotate-instance-tokens.mjs
```

The script refuses while a job is active unless `--force` is passed. It updates
`bridge.env`, `repo_instances.bridge_token_enc` and the matching `api_tokens` row
and restarts each bridge. The GitHub PAT used by the development routine is not
managed by this script: create a new classic PAT with `repo` scope in GitHub
settings, replace it in `~/.dsh/bot/secrets.json`, and revoke the old token.

## 3. SMTP

Configure your operator SMTP account (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`,
Expand Down
2 changes: 1 addition & 1 deletion mock/fledge-ui.html
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,7 @@ <h2>${esc(t('选择你在这个仓库的角色', 'Choose your role for this repo
<td><span class="badge ${workStateClass(item.state)}">${esc(workStateLabel(item.state))}</span></td>
<td>${item.priority}</td>
<td>${item.issue === '—' ? '' : `<a data-action="toast" data-message="${esc(t('打开 issue(mock)。', 'Open issue (mock).'))}">${esc(item.issue)}</a>`}${item.pr === '—' ? '' : ` <a data-action="toast" data-message="${esc(t('打开 PR(mock)。', 'Open PR (mock).'))}">${esc(item.pr)}</a>`}</td>
<td><div class="row">${item.state === 'merged' || item.state === 'closed' ? '' : `<button class="btn btn-small" data-action="work-run" data-work="${esc(item.id)}">${esc(t('开始工作', 'Start work'))}</button>`}${workNextStates(item.state).length === 0 ? '' : `<select data-work-state="${esc(item.id)}">${workNextStates(item.state).map((state) => `<option value="${state}">${esc(workStateLabel(state))}</option>`).join('')}</select><button class="btn ghost btn-small" data-action="work-state" data-work="${esc(item.id)}">${esc(t('更新', 'Update'))}</button>`}</div></td>
<td><div class="row">${item.state === 'merged' || item.state === 'closed' ? '' : `<button class="btn btn-small" data-action="work-run" data-work="${esc(item.id)}">${esc(t('开始工作', 'Start work'))}</button>`}<button class="btn ghost btn-small" data-action="toast" data-message="${esc(t('Product 范围任务已排队(mock)。', 'Product scope queued (mock).'))}">${esc(t('Product 范围', 'Product scope'))}</button><button class="btn ghost btn-small" data-action="toast" data-message="${esc(t('Test 验证任务已排队(mock)。', 'Test verify queued (mock).'))}">${esc(t('Test 验证', 'Test verify'))}</button>${workNextStates(item.state).length === 0 ? '' : `<select data-work-state="${esc(item.id)}">${workNextStates(item.state).map((state) => `<option value="${state}">${esc(workStateLabel(state))}</option>`).join('')}</select><button class="btn ghost btn-small" data-action="work-state" data-work="${esc(item.id)}">${esc(t('更新', 'Update'))}</button>`}</div></td>
</tr>`).join('');
const workCard = `<section class="card" id="work">
<div class="row between"><h2>${esc(t('工作项', 'Work items'))}</h2><span class="fine">${esc(t('Issue / PR / 状态 / 优先级', 'Issue / PR / state / priority'))}</span></div>
Expand Down
102 changes: 102 additions & 0 deletions scripts/rotate-instance-tokens.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env node
// Rotate the Fledge-managed bridge and per-instance gateway tokens.
//
// node --env-file=.env scripts/rotate-instance-tokens.mjs
// node --env-file=.env scripts/rotate-instance-tokens.mjs --dry-run
//
// This rotates BRIDGE_TOKEN and FLEDGE_GATEWAY_TOKEN in each ready instance's
// bridge.env, the encrypted bridge token in repo_instances, and the matching
// api_tokens row. It refuses to run while a job is active unless --force is
// passed. The GitHub PAT used by the dev routine is not managed here; rotate it
// in GitHub settings and replace it in ~/.dsh/bot/secrets.json.

import { copyFileSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { loadConfig } from '../dist/config.js'
import { Store } from '../dist/db.js'
import { InstanceManager } from '../dist/flybot/instances.js'
import { encryptSecret, randomToken, sha256Hex } from '../dist/lib/secrets.js'
import { nowIso } from '../dist/lib/time.js'

const dryRun = process.argv.includes('--dry-run')
const force = process.argv.includes('--force')

function upsertEnv(text, values) {
const seen = new Set()
const lines = text.split(/\r?\n/).filter((line) => line !== '')
const next = lines.map((line) => {
const index = line.indexOf('=')
if (index <= 0) return line
const key = line.slice(0, index)
if (!(key in values)) return line
seen.add(key)
return `${key}=${values[key]}`
})
for (const [key, value] of Object.entries(values)) {
if (!seen.has(key)) next.push(`${key}=${value}`)
}
return next.join('\n') + '\n'
}

const config = loadConfig()
const store = new Store(config.dbPath)
const manager = new InstanceManager(store, config)
const stamp = new Date().toISOString().replace(/[:.]/g, '-')

try {
const active = store.countActiveJobs()
if (active > 0 && !force) {
console.error(`rotate-instance-tokens: refusing to rotate while ${active} job(s) are active; finish them or pass --force`)
process.exit(2)
}
const instances = store.listRepoInstances().filter((instance) => instance.status === 'ready')
if (instances.length === 0) {
console.log('rotate-instance-tokens: no ready instances')
process.exit(0)
}
for (const instance of instances) {
const repo = store.getRepo(instance.repo_id)
const label = repo === undefined ? instance.repo_id : `${repo.owner}/${repo.name}`
const envPath = join(dirname(instance.data_dir), 'bridge.env')
if (dryRun) {
console.log(`would rotate ${label}`)
continue
}
const oldEnv = readFileSync(envPath, 'utf8')
const oldBridgeEnc = instance.bridge_token_enc
const bridgeToken = randomToken(32)
const gatewayToken = `flg_${randomToken(32)}`
copyFileSync(envPath, `${envPath}.bak-${stamp}`)
writeFileSync(envPath, upsertEnv(oldEnv, { BRIDGE_TOKEN: bridgeToken, FLEDGE_GATEWAY_TOKEN: gatewayToken }), { mode: 0o600 })
store.updateRepoInstance(instance.repo_id, { bridge_token_enc: encryptSecret(bridgeToken, config.tokenEncryptionKey), updated_at: nowIso() })
const newToken = store.createApiToken({
id: `tok_${randomToken(8)}`,
userId: instance.user_id,
kind: 'instance',
name: `flybot:${instance.repo_id}`,
tokenHash: sha256Hex(gatewayToken),
prefix: gatewayToken.slice(0, 8),
repoId: instance.repo_id,
expiresAt: null,
})
try {
await manager.restartBridge(instance.repo_id)
for (const token of store.listApiTokens(instance.user_id)) {
if (token.kind === 'instance' && token.name === `flybot:${instance.repo_id}` && token.revoked_at === null && token.id !== newToken.id) {
store.revokeApiToken(token.id, token.user_id)
}
}
store.audit(instance.user_id, 'flybot.tokens.rotated', instance.repo_id)
console.log(`rotated ${label}`)
} catch (error) {
writeFileSync(envPath, oldEnv, { mode: 0o600 })
store.updateRepoInstance(instance.repo_id, { bridge_token_enc: oldBridgeEnc ?? '', updated_at: nowIso() })
store.revokeApiToken(newToken.id, newToken.user_id)
await manager.restartBridge(instance.repo_id).catch(() => {})
console.error(`rolled back ${label}: ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}
}
} finally {
store.close()
}
30 changes: 30 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1485,6 +1485,36 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
return reply.redirect(`/repos/${repo.id}/jobs/${result.job.id}?notice=${encodeURIComponent(kind === 'plan' ? '计划任务已排队。' : '工作项任务已排队。')}`)
})

app.post('/repos/:id/work/:workItemId/scope', async (request, reply) => {
const { viewer, repo } = ownedRepo(request, String((request.params as Record<string, string>).id), true)
const item = store.getWorkItem(String((request.params as Record<string, string>).workItemId))
if (item === undefined || item.repo_id !== repo.id) throw httpError(404, '工作项不存在。', 'WORK_ITEM_NOT_FOUND')
if (repo.repo_role !== 'maintainer' && repo.repo_role !== 'developer') return reply.redirect(`/repos/${repo.id}?error=${encodeURIComponent('请先选择你的角色。')}#work`)
if (item.state === 'merged' || item.state === 'closed') return reply.redirect(`/repos/${repo.id}?error=${encodeURIComponent('这个工作项已经结束。')}#work`)
const active = store.activeJobForRepo(repo.id)
if (active !== undefined) return reply.redirect(`/repos/${repo.id}?error=${encodeURIComponent('这个仓库已有任务在运行。')}#work`)
const goal = [`Scope work item: ${item.title}`, item.detail === '' ? '' : item.detail, item.issue_url === null ? '' : `Issue: ${item.issue_url}`, 'Product scoping only: goal, non-goals, acceptance criteria and test plan. Do not code.'].filter((line) => line !== '').join('\n\n')
const result = await jobs.enqueue(repo, 'manual', goal, 'scope', { workItemId: item.id, priority: item.priority })
store.audit(viewer.user.id, 'work_item.scope', `${item.id} -> ${result.job?.id ?? result.error ?? 'failed'}`)
if (!result.ok || result.job === undefined) return reply.redirect(`/repos/${repo.id}?error=${encodeURIComponent(result.error ?? '范围任务启动失败。')}#work`)
return reply.redirect(`/repos/${repo.id}/jobs/${result.job.id}?notice=${encodeURIComponent('Product 范围任务已排队。')}`)
})

app.post('/repos/:id/work/:workItemId/verify', async (request, reply) => {
const { viewer, repo } = ownedRepo(request, String((request.params as Record<string, string>).id), true)
const item = store.getWorkItem(String((request.params as Record<string, string>).workItemId))
if (item === undefined || item.repo_id !== repo.id) throw httpError(404, '工作项不存在。', 'WORK_ITEM_NOT_FOUND')
if (item.pr_url === null) return reply.redirect(`/repos/${repo.id}?error=${encodeURIComponent('这个工作项还没有 PR。')}#work`)
if (repo.repo_role !== 'maintainer' && repo.repo_role !== 'developer') return reply.redirect(`/repos/${repo.id}?error=${encodeURIComponent('请先选择你的角色。')}#work`)
const active = store.activeJobForRepo(repo.id)
if (active !== undefined) return reply.redirect(`/repos/${repo.id}?error=${encodeURIComponent('这个仓库已有任务在运行。')}#work`)
const goal = `Verify PR ${item.pr_url} with the Test role. Read the PR and branch, run the relevant tests, and report failing and passing evidence. Do not code, push or merge.`
const result = await jobs.enqueue(repo, 'manual', goal, 'verify', { prUrl: item.pr_url, workItemId: item.id, priority: item.priority })
store.audit(viewer.user.id, 'work_item.verify', `${item.id} -> ${result.job?.id ?? result.error ?? 'failed'}`)
if (!result.ok || result.job === undefined) return reply.redirect(`/repos/${repo.id}?error=${encodeURIComponent(result.error ?? '验证任务启动失败。')}#work`)
return reply.redirect(`/repos/${repo.id}/jobs/${result.job.id}?notice=${encodeURIComponent('Test 验证任务已排队。')}`)
})

app.get('/repos/:id/workspace', async (request, reply) => {
const { viewer, repo } = ownedRepo(request, String((request.params as Record<string, string>).id))
const instance = store.getRepoInstance(repo.id)
Expand Down
Loading
Loading