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
4 changes: 2 additions & 2 deletions .github/scripts/backend-docs.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ test('public API tables enumerate the actual complete command/query contracts',
const commandText = api.split('## v1命令')[1].split('## Execution')[0]
assert.deepEqual(rows(queryText), [...queries].sort())
assert.deepEqual(rows(commandText), operations.filter(name => !queries.includes(name)).sort())
assert.equal(queries.length, 28); assert.equal(rows(commandText).length, 33)
for (const name of ['README.md', 'README.zh.md']) assert.match(read(name), /28查询\/33命令/)
assert.equal(queries.length, 29); assert.equal(rows(commandText).length, 34)
for (const name of ['README.md', 'README.zh.md']) assert.match(read(name), /29查询\/34命令/)
})

test('quickstart JSON, shell syntax and local documentation links stay usable', () => {
Expand Down
2 changes: 1 addition & 1 deletion PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ consultation store, committed Effects and scoped leases. Signed, explicitly
verified Webhook notifications and native wakeups both lead to the same inbox
service; private messages stay outside public SSE. [Contract](docs/agent-inbox.md).
Actual isolated DSH/HTTP and a separate receiver pass with deterministic inference;
live vendor acceptance and deployment remain separate. #2311 is the next credential-provider change.
live vendor acceptance and deployment remain separate. #2311 adds App installation credentials alongside gh-cli, human-only connection selection, and original broker/Effects identity fencing. [Credential contract](docs/github-automation.md); live App acceptance needs authorized test resources.

#2300 publishes the source-matched public [OpenAPI contract](docs/openapi/flybot.openapi.json)
and [third-party Bot guide](docs/third-party-bot-integration.md). Human-issued FBA scope,
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ HTTP 入口为 `POST /api/bot`、事件为 `GET /api/project-events`,

| 需要 | 文档 |
| --- | --- |
| 对象、权限、全部28查询/33命令、原生接口、幂等与SSE | [API参考](docs/development-service-api.md) |
| 对象、权限、全部29查询/34命令、原生接口、幂等与SSE | [API参考](docs/development-service-api.md) |
| Chief/角色/Task/Handoff、咨询、个人代理、安全与值守、状态与上下文 | [协作指南](docs/development-service-guide.md) |
| 数据目录、账号平台、模型/SDK/仓库、停止重启、备份升级、排障 | [部署运维](docs/backend-operations.md) |
| 个人Chief、多Bot、群聊、公共Bot、原版安装与桌面功能 | [原版FlyBot](README.native.zh.md) · [English](README.native.md) |
Expand All @@ -90,3 +90,5 @@ HTTP 入口为 `POST /api/bot`、事件为 `GET /api/project-events`,

真实部署入口是 `scripts/backend.mjs`。`dev/demo/` 是隔离开发验证工具,会使用测试模型或临时数据,不能作为生产启动方法。
自动测试通过、真实 SDK 验证、浏览器验收、PR 合入与生产部署分别记录;本分支不承诺未验证的完整真实模型开发闭环。

项目自动化支持显式 gh-cli 或 GitHub App installation;人类选择已预置连接,个人用户授权独立。见 [GitHub配置与身份](docs/github-automation.md)。
4 changes: 3 additions & 1 deletion README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ HTTP 入口为 `POST /api/bot`、事件为 `GET /api/project-events`,

| 需要 | 文档 |
| --- | --- |
| 对象、权限、全部28查询/33命令、原生接口、幂等与SSE | [API参考](docs/development-service-api.md) |
| 对象、权限、全部29查询/34命令、原生接口、幂等与SSE | [API参考](docs/development-service-api.md) |
| Chief/角色/Task/Handoff、咨询、个人代理、安全与值守、状态与上下文 | [协作指南](docs/development-service-guide.md) |
| 数据目录、账号平台、模型/SDK/仓库、停止重启、备份升级、排障 | [部署运维](docs/backend-operations.md) |
| 个人Chief、多Bot、群聊、公共Bot、原版安装与桌面功能 | [原版FlyBot](README.native.zh.md) · [English](README.native.md) |
Expand All @@ -90,3 +90,5 @@ HTTP 入口为 `POST /api/bot`、事件为 `GET /api/project-events`,

真实部署入口是 `scripts/backend.mjs`。`dev/demo/` 是隔离开发验证工具,会使用测试模型或临时数据,不能作为生产启动方法。
自动测试通过、真实 SDK 验证、浏览器验收、PR 合入与生产部署分别记录;本分支不承诺未验证的完整真实模型开发闭环。

项目自动化支持显式 gh-cli 或 GitHub App installation;人类选择已预置连接,个人用户授权独立。见 [GitHub配置与身份](docs/github-automation.md)。
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
#2308 adds one native/external inbox protocol, verified Webhook hints, durable
processing and Chief continuation. Preserve existing personal Chief/group contracts;
do not turn consultations into Tasks or inbox claims into executor identity.
Follow with #2311 GitHub App installation credentials alongside gh-cli; no live rollout.
#2311 implements GitHub App credentials alongside gh-cli, scoped human selection and recovery fencing. Deterministic provider/runtime coverage is separate from real App acceptance; no live rollout.

#2183 adds scoped agent credentials over the existing member-session HTTP channel,
reuses per-participation safe/watch grants, and persists Task-origin provenance.
Expand Down
1 change: 1 addition & 0 deletions changelog.d/2311.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support scoped GitHub App installation credentials alongside gh-cli for project reads, candidate publication and Release operations, with human-only connection selection, short-lived host credentials and identity-fenced recovery.
2 changes: 1 addition & 1 deletion core/src/host/personal-agent-auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function install(rt) {
const projectId = body.projectId ?? ownEntry(state.tasks, body.id ?? body.taskId)?.projectId ?? ownEntry(state.projectAttempts ?? {}, body.attemptId)?.projectId ?? ownEntry(state.projectQueue ?? {}, body.queueId)?.projectId ?? body.id
const { grant } = scope(actor, projectId)
const nativeWrite = ['task-create', 'task-update', 'task-cancel', 'task-answer-input'].includes(endpoint) || endpoint.startsWith('project-') && WORKBENCH_ENDPOINTS.has(endpoint) && !reserved.has(endpoint) && endpoint !== 'project-development' && endpoint !== 'project-agents'
const developmentWrite = endpoint === 'project-development' && body.kind === 'command' && !['agent.mode', 'proposal.publish', 'inbox.connection.configure'].includes(body.name)
const developmentWrite = endpoint === 'project-development' && body.kind === 'command' && !['agent.mode', 'proposal.publish', 'inbox.connection.configure', 'github.connection.configure'].includes(body.name)
if (!grant || !nativeWrite && !developmentWrite) throw refusal('This agent operation requires authorized watch mode')
}
} catch (error) { return { ok: false, code: error.code ?? 'bot/forbidden', message: error.message } }
Expand Down
7 changes: 5 additions & 2 deletions core/src/host/project-credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { join } from 'node:path'
import { EffectDeferred, EffectCancelled } from './project-effects.js'
import { ownEntry, setEntry } from './store.js'
import { resolveGithubIdentity } from './project-github.js'
import { createGithubConnections, projectGithubStatus } from './project-github-connections.js'
const refused = message => Object.assign(new Error(message), { code: 'bot/forbidden' })

export function createProjectCredentials(rt, { identity = resolveGithubIdentity, request = fetch } = {}) {
Expand All @@ -30,6 +31,7 @@ export function createProjectCredentials(rt, { identity = resolveGithubIdentity,
try { project = rt.requireProjectAccess(actor, effect.projectId, 'create') }
catch { throw new EffectCancelled('Comment Project authority was revoked') }
const settings = personal(actor, effect.projectId)
if (settings?.credential === 'github-app') throw new EffectCancelled('Personal operations require independent user credentials')
if (project.repository !== effect.payload.repository || settings?.login?.toLowerCase() !== effect.payload.login.toLowerCase()) throw new EffectCancelled('Comment repository or personal identity changed')
context.assertCurrent()
return { actor, project, settings }
Expand Down Expand Up @@ -76,8 +78,9 @@ export function createProjectCredentials(rt, { identity = resolveGithubIdentity,
const settings = personal(actor, projectId)
if (endpoint === 'project-credential-status') return {
personal: { configured: Boolean(settings?.credential && settings.configDir && settings.login), login: settings?.login ?? null },
automation: { configured: Boolean(ownEntry(rt.config.projectRepositories ?? {}, project.repository)?.github?.credential && ownEntry(rt.config.projectRepositories ?? {}, project.repository)?.github?.configDir) },
automation: projectGithubStatus(rt, project),
}
if (settings?.credential === 'github-app') throw refused('Personal operations require independent user credentials')
if (body.scope !== undefined && body.scope !== 'personal') throw refused('This endpoint only uses your own personal GitHub identity')
if (!settings?.login) throw refused('Personal GitHub credential is not configured for this human and Project')
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(project.repository ?? '')) throw refused('Project repository is unavailable')
Expand Down Expand Up @@ -121,4 +124,4 @@ export function createProjectCredentials(rt, { identity = resolveGithubIdentity,
},
}
}
export function install(rt) { rt.projectCredentials = createProjectCredentials(rt) }
export function install(rt) { rt.projectCredentials = createProjectCredentials(rt); rt.projectGithubConnections = createGithubConnections(rt) }
25 changes: 21 additions & 4 deletions core/src/host/project-delivery.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { createResourceWorkspaces, fileDigest } from './project-resource-workspa
import { ROLE_POLICY_FILES, assembleRolePolicy, verifyRolePolicy, deliveryReviewReport, checkDevelopmentEvidence } from './project-role-policy.js'
import { buildProjectContext, contextHash } from './project-role-context.js'
import { githubPublisher } from './project-github.js'
import { projectGithubSettings } from './project-github-connections.js'
import { githubCredentialBinding } from './project-github-app.js'
import { taskSessionId, confirmedWorkerExit, pinTaskRoleDefinition, discardUnpreparedTaskDefinitions } from './project-task-sessions.js'
import { readMergeHeads } from './project-merge-heads.js'
import { childResultSources, createChildInputAdmission } from './project-child-inputs.js'
Expand Down Expand Up @@ -68,11 +70,13 @@ export function createProjectDelivery(rt, { publisher, releaseProvider } = {}) {
const taskOf = id => ownEntry(state.tasks, String(id ?? ''))
const projectOf = (id, attempt) => { const project = rt.projects.repository.get(id, { includeDeleted: true }); return attempt ? attemptProject(rt, project, attempt) : project }
const settingsOf = project => repositorySettings(rt, project)
const publisherOf = project => publisher ?? githubPublisher(settingsOf(project).github)
const publisherOf = (project, authorize) => { settingsOf(project); return publisher ?? githubPublisher(projectGithubSettings(rt, project), { authorize }) }
const releases = createProjectReleases(rt, { projectOf, ...(releaseProvider ? { providerFactory: () => releaseProvider } : {}) })
async function validatePublication(effect, context) {
const attempt = ownEntry(attempts(), effect.payload.attemptId), task = taskOf(effect.taskId)
if (attempt?.publicationCredential && attempt.publicationCredential !== githubCredentialBinding(projectGithubSettings(rt, projectOf(attempt.projectId, attempt)), attempt.requirement.repository ?? projectOf(attempt.projectId, attempt).repository)) throw new EffectCancelled('Publication GitHub connection changed; reconcile with the original identity')
if (!attempt || attempt.projectId !== effect.projectId || task?.deliveryAttemptId !== attempt.id || attempt.candidateSha !== effect.payload.candidateSha || ['accepted', 'cancelled'].includes(task.status) || attempt.cancelRequested) throw new EffectCancelled('Publication candidate was stopped, superseded or ended')
settingsOf(projectOf(attempt.projectId, attempt))
try {
await rt.projectDelegations?.background(effect.payload.initiatedBy, task.collaboration?.sourceSessionId)
rt.requireProjectAccess({ memberId: effect.payload.initiatedBy }, effect.projectId, 'task-write', task)
Expand All @@ -99,7 +103,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider } = {}) {
if (!context.inline) await validatePublication(effect, context)
context.assertCurrent()
const attempt = ownEntry(attempts(), effect.payload.attemptId)
await publishNow(attempt)
await publishNow(attempt, async () => { await validatePublication(effect, context) })
return structuredClone(attempt.pr)
}
return context.inline ? publish() : serialAttempt(effect.payload.attemptId, publish)
Expand Down Expand Up @@ -141,6 +145,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider } = {}) {
async function publicationOf(previous, project) {
if (!previous) return undefined
if (previous.publicationEffectId && !previous.pr) throw reject('Previous publication is unresolved; reconcile its existing effect before creating rework')
if (previous.publicationCredential && previous.publicationCredential !== githubCredentialBinding(projectGithubSettings(rt, project), project.repository)) throw reject('Previous publication GitHub connection changed; reconcile before rework')
const binding = previous.pr ? { number: previous.pr.number, repository: previous.pr.repository, branch: previous.publicationBinding?.branch ?? previous.branch, previousSha: previous.candidateSha } : structuredClone(previous.publicationBinding)
if (binding && (binding.repository !== project.repository || !Number.isSafeInteger(binding.number) || binding.number < 1 || typeof binding.branch !== 'string' || !binding.branch || !/^[a-f0-9]{40}$/.test(binding.previousSha) || previous.pr && previous.pr.sha !== previous.candidateSha)) throw reject('Existing delivery PR binding is inconsistent; reconcile before rework')
if (binding) {
Expand Down Expand Up @@ -569,6 +574,11 @@ export function createProjectDelivery(rt, { publisher, releaseProvider } = {}) {
await publishVerified(attempt)
}
async function publishVerified(attempt) {
if (!publisher) {
const project = projectOf(attempt.projectId, attempt), binding = githubCredentialBinding(projectGithubSettings(rt, project), project.repository)
if (attempt.publicationCredential && attempt.publicationCredential !== binding) throw reject('Publication GitHub connection changed; reconcile before retry')
if (!attempt.publicationCredential) { attempt.publicationCredential = binding; await rt.save() }
}
if (!rt.projectEffects) return publishNow(attempt)
const task = taskOf(attempt.taskId)
const spec = { kind: 'github.publish', key: `${attempt.id}:${attempt.candidateSha}`, projectId: attempt.projectId, taskId: attempt.taskId,
Expand All @@ -577,8 +587,15 @@ export function createProjectDelivery(rt, { publisher, releaseProvider } = {}) {
attempt.publicationEffectId = token.id
return rt.projectEffects.perform(spec)
}
async function publishNow(attempt) {
const pr = await publisherOf(projectOf(attempt.projectId, attempt)).publish(attempt, projectOf(attempt.projectId, attempt))
async function publishNow(attempt, authorize) {
const project = projectOf(attempt.projectId, attempt)
const check = async () => {
if (authorize) await authorize()
settingsOf(projectOf(attempt.projectId, attempt))
if (attempt.cancelRequested || taskOf(attempt.taskId)?.deliveryAttemptId !== attempt.id) throw reject('Publication Task changed')
if (attempt.publicationCredential && attempt.publicationCredential !== githubCredentialBinding(projectGithubSettings(rt, projectOf(attempt.projectId, attempt)), project.repository)) throw reject('Publication GitHub connection changed')
}
const pr = await publisherOf(project, check).publish(attempt, projectOf(attempt.projectId, attempt))
if (pr.sha !== attempt.candidateSha || attempt.publicationBinding && (pr.number !== attempt.publicationBinding.number || pr.repository !== attempt.publicationBinding.repository)) throw reject('Published PR does not match candidate or existing binding')
if (attempt.cancelRequested) throw reject('Delivery was stopped')
attempt.pr = pr
Expand Down
Loading
Loading