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
49 changes: 47 additions & 2 deletions .github/scripts/backend-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { test } from 'node:test'
import { backendConfig, callApi, connection, prepareBackend, readJson, root } from '../../scripts/backend-lib.mjs'
import { mockModel } from '../../dev/demo/mock.mjs'
import { agentReply } from '../../dev/demo/agents.mjs'
import { readSpec, validateExchange, validate } from '../../scripts/api-contract-validation.mjs'

const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
async function freePort() { const server = createServer(); server.listen(0, '127.0.0.1'); await once(server, 'listening'); const port = server.address().port; await new Promise(resolve => server.close(resolve)); return port }
Expand Down Expand Up @@ -57,12 +58,42 @@ test('production backend retains canonical Tasks/auth across restart, keeps cons
const login = await run(['login', '--home', home], env)
assert.match(login, /Authenticated/); assert.doesNotMatch(login + host.output(), /\?token=|sessionToken|isolated-test-provider/)
const token = readJson(join(home, 'private/backend-session.json')).sessionToken
const transport = await connection(home), api = (endpoint, body = {}, who = token) => callApi(transport, endpoint, body, who)
const transport = await connection(home), spec = readSpec()
const api = async (endpoint, body = {}, who = token) => {
const value = await callApi(transport, endpoint, body, who)
validateExchange(spec, endpoint, body, { ok: true, value })
return value
}
const dev = (kind, name, projectId, payload = {}, requestId) => api('project-development', { version: 'project-development/v1', kind, name, projectId, payload, ...(requestId ? { requestId } : {}) })
const { project } = await api('project-create', { name: 'Persistent service' })
await api('auth-mode')
await api('auth-whoami')
assert.equal(await api('auth-whoami', {}, ''), null)
assert.equal(await api('auth-whoami', {}, 'invalid-example-token'), null)
await api('workbench-state')
await api('project-read', { id: project.id })
await api('project-detail', { id: project.id, view: 'summary' })
await api('project-members', { id: project.id })
await api('project-repositories', { projectId: project.id })
await api('personal-space', { operation: 'list' })
for (const name of ['roles', 'role.proposals', 'project.snapshot', 'events', 'chief.state', 'pools', 'effects', 'compatibility']) await dev('query', name, project.id)
const room = await api('project-room-create', { projectId: project.id, name: 'Contract room', roles: ['chief'], memberIds: [] })
await api('project-room-read', { projectId: project.id, roomId: room.id })
await api('project-rooms', { projectId: project.id })
await api('project-agents', { projectId: project.id, view: 'summary' })
await api('project-knowledge-write', { projectId: project.id, text: 'Contract fixture fact' })
await api('project-role-memory-write', { projectId: project.id, role: 'developer', text: 'Bounded role fact', source: { kind: 'human', reference: 'isolated-contract-test' } })
await api('project-agent-memory-write', { projectId: project.id, text: 'Private fixture note' })
await api('project-agent-history', { projectId: project.id, visibility: 'private' })
await api('project-resources', { projectId: project.id, view: 'summary' })
const proposed = await dev('command', 'task.propose', project.id, { title: 'Project change', criteria: 'Result checked', impact: 'Keep project history' }, 'one-original-intent')
const taskId = proposed.result.id
assert.ok(taskId)
await dev('query', 'task.detail', project.id, { taskId })
await dev('query', 'handoff.list', project.id, { taskId })
await dev('query', 'task.history', project.id, { taskId, section: 'requirements' })
await dev('query', 'command.receipt', project.id, { commandRequestId: 'one-original-intent' })
await dev('query', 'command.result', project.id, { commandRequestId: 'one-original-intent', limit: 100 })
assert.equal((await dev('query', 'task', project.id, { taskId })).result.task.phase, 'awaiting-chief')
const inquiry = await dev('command', 'consultation.ask', project.id, { text: 'Explain the Project status without creating work' }, 'ordinary-question')
assert.equal(inquiry.result.consultation.initiativeTransferred, false)
Expand All @@ -78,6 +109,17 @@ test('production backend retains canonical Tasks/auth across restart, keeps cons
const external = await api('personal-agent-create', { name: 'External protocol caller', kind: 'external', provider: 'http-contract', endpoint: 'https://agent.example.invalid/callback' })
await api('personal-agent-join', { agentId: external.agent.id, projectId: project.id })
const credential = await api('personal-agent-token-create', { agentId: external.agent.id, projectIds: [project.id] })
await api('personal-agent-token-list', { agentId: external.agent.id })
await api('personal-agent-read', { agentId: external.agent.id })
await api('personal-agent-list')
await api('personal-agent-participations')
await api('auth-whoami', {}, credential.token)
await api('project-development', { version: 'project-development/v1', kind: 'query', name: 'roles', projectId: project.id, payload: {} }, credential.token)
const refused = await fetch(`${transport.baseUrl}/api/bot`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-flybot-member-session': credential.token }, body: JSON.stringify({ endpoint: 'project-development', payload: { version: 'project-development/v1', kind: 'command', name: 'task.propose', projectId: project.id, requestId: 'safe-must-not-publish', payload: { title: 'Refused shared publication' } } }) })
assert.equal(refused.status, 200)
const denial = await refused.json()
validate(spec.components.schemas.DomainError, denial, spec)
assert.equal(denial.ok, false)
const privateProposal = await api('personal-agent-propose-task', { projectId: project.id, title: 'Proposed by the independent agent', criteria: 'Owner reviews the proposal', impact: 'Retain explicit Project impact', scope: 'Transport mapping only', projectTaskKind: 'code', requestId: 'external-safe-proposal' }, credential.token)
assert.equal(privateProposal.proposal.fields.impact, 'Retain explicit Project impact')
assert.equal(privateProposal.proposal.fields.scope, 'Transport mapping only')
Expand All @@ -88,6 +130,7 @@ test('production backend retains canonical Tasks/auth across restart, keeps cons
const watched = await api('project-development', { version: 'project-development/v1', kind: 'command', name: 'task.propose', projectId: project.id, requestId: 'external-watch-intent', payload: { title: 'Delegated Project work', criteria: 'Current authority checked', impact: 'Exercise the delegated publish boundary' } }, credential.token)
assert.ok(watched.result.id)
await api('personal-agent-token-revoke', { credentialId: credential.credential.id })
assert.equal(await api('auth-whoami', {}, credential.token), null)
await assert.rejects(api('project-read', { id: project.id }, credential.token), /unauthorized|forbidden/)
const invitation = await api('auth-invite'), bob = await api('auth-redeem', { token: invitation.token, displayName: 'Bob' })
await assert.rejects(api('project-read', { id: project.id }, bob.sessionToken), /forbidden/)
Expand All @@ -97,7 +140,9 @@ test('production backend retains canonical Tasks/auth across restart, keeps cons
const stream = await fetch(`${transport.baseUrl}/api/project-events?projectId=${project.id}`, { headers: { 'x-flybot-member-session': bob.sessionToken }, signal: AbortSignal.timeout(10000) })
assert.equal(stream.status, 200)
const reader = stream.body.getReader()
assert.match(new TextDecoder().decode((await reader.read()).value), /event: snapshot/)
const firstFrame = new TextDecoder().decode((await reader.read()).value)
assert.match(firstFrame, /event: snapshot/)
validate(spec.components.schemas.Snapshot, JSON.parse(/^data: (.*)$/m.exec(firstFrame)[1]), spec)
await api('project-member-remove', { id: project.id, memberId: bob.memberId })
let revoked = ''
while (!revoked.includes('access-revoked')) { const next = await reader.read(); if (next.done) break; revoked += new TextDecoder().decode(next.value) }
Expand Down
86 changes: 86 additions & 0 deletions .github/scripts/openapi.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict'
import { readFileSync, existsSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { execFileSync } from 'node:child_process'
import { test } from 'node:test'
import { buildSpec, renderSpec, artifactPath, inventory } from '../../scripts/openapi.mjs'
import { readSpec, validate, operationFor } from '../../scripts/api-contract-validation.mjs'

test('published OpenAPI artifact matches all allowlisted endpoints and v1 operations', () => {
assert.equal(readFileSync(artifactPath, 'utf8'), renderSpec())
const spec = readSpec(), inv = inventory(), ops = spec['x-flybot-operations']
assert.equal(spec.openapi, '3.1.0')
assert.deepEqual(Object.keys(spec.paths).sort(), ['/api/bot', '/api/project-events', '/health'])
assert.deepEqual([...new Set(ops.map(row => row.transport.endpoint))].sort(), inv.endpoints.sort())
assert.deepEqual(ops.filter(row => row.transport.kind === 'query').map(row => row.transport.name).sort(), inv.queries.sort())
assert.deepEqual(ops.filter(row => row.transport.kind === 'command').map(row => row.transport.name).sort(), inv.commands.sort())
assert.equal(new Set(ops.map(row => row.operationId)).size, ops.length)
const walk = value => {
if (!value || typeof value !== 'object') return
if (value.$ref) assert.ok(spec.components.schemas[value.$ref.split('/').at(-1)], value.$ref)
Object.values(value).forEach(walk)
}
walk(spec)
for (const row of ops) {
assert.ok(row.requestSchema.$ref && row.resultSchema.$ref && row.successSchema.$ref)
assert.ok(row.security.length && row.permission && row.implementation.sha256.length === 64)
assert.ok(Array.isArray(row.preconditions) && Array.isArray(row.stateChanges.effects))
assert.ok(row.idempotency.mechanism)
assert.equal(row.stateChanges.acknowledgementIsCompletion, false)
}
})

test('FBA safe/watch contracts preserve native asymmetries and human/host-only boundaries', () => {
const spec = buildSpec(), op = (endpoint, kind, name) => operationFor(spec, endpoint, { kind, name })
assert.equal(op('project-roles').personalAgent.safe, false)
assert.equal(op('project-roles').personalAgent.watch, true)
assert.equal(op('project-development', 'query', 'roles').personalAgent.safe, true)
assert.equal(op('project-development', 'query', 'attempt.integration-state').personalAgent.safe, false)
assert.equal(op('project-development', 'command', 'consultation.ask').personalAgent.safe, true)
assert.equal(op('project-development', 'command', 'task.propose').personalAgent.safe, false)
assert.deepEqual(op('project-role').humanRoles, ['owner'])
for (const endpoint of ['personal-agent-token-create', 'personal-agent-delegation-set', 'project-close-prepare', 'project-close-confirm', 'project-terminal-open', 'project-agent-history']) assert.equal(op(endpoint).personalAgent.watch, false, endpoint)
assert.deepEqual(op('personal-agent-propose-task').security, [{ personalAgentToken: [] }])
for (const name of ['claim', 'handoff.offer', 'handoff.accept', 'complete', 'arbitrate', 'split']) assert.throws(() => op('project-development', 'command', name), /Unspecified/)
assert.equal(spec.components.schemas.PersonalAgentTokenCreateResult.properties.token.readOnly, true)
assert.equal(spec.components.schemas.PersonalAgentTokenCreateResult.properties.token.writeOnly, undefined)
})

test('canonical requests catch wrong kind, unknown/mis-scoped fields and missing command identity', () => {
const spec = readSpec(), request = { endpoint: 'project-development', payload: { version: 'project-development/v1', projectId: 'example-project', kind: 'command', name: 'task.propose', requestId: 'example-intent', payload: { title: 'Change project', criteria: 'Checked', impact: 'Improve project' } } }
validate(spec.components.schemas.BotRequest, request, spec)
for (const mutate of [row => { delete row.payload.requestId }, row => { row.payload.kind = 'query' }, row => { row.payload.payload.projectId = 'other-project' }, row => { row.payload.payload.claim = { role: 'chief' } }]) {
const row = structuredClone(request); mutate(row)
assert.throws(() => validate(spec.components.schemas.BotRequest, row, spec))
}
validate(spec.components.schemas.BotResponse, { ok: false, error: { code: 'bot/forbidden', message: 'Refused' } }, spec)
assert.throws(() => validate(spec.components.schemas.DomainError, { ok: true, error: { code: 'bot/forbidden', message: 'Refused' } }, spec))
assert.throws(() => validate(spec.components.schemas.AuthRevokeSuccess, { ok: true, value: { revoked: true } }, spec))
validate(spec.components.schemas.AuthRevokeSuccess, { ok: true, value: true }, spec)
validate(spec.components.schemas.AuthWhoamiSuccess, { ok: true, value: null }, spec)
})

test('every onboarding example validates against exactly one actual request branch', () => {
const spec = readSpec()
for (const example of spec['x-flybot-examples']) {
const { endpoint, payload } = example.request
const operation = operationFor(spec, endpoint, payload)
validate(operation.requestSchema, example.request, spec, example.id)
validate(spec.components.schemas.BotRequest, example.request, spec, example.id)
}
})

test('third-party guide links and examples remain usable and its inventory is current', () => {
const file = resolve(import.meta.dirname, '../../docs/third-party-bot-integration.md'), text = readFileSync(file, 'utf8'), spec = readSpec(), inv = spec['x-flybot-inventory']
assert.ok(text.includes(`${inv.nativeEndpointCount}个`))
assert.ok(text.includes(`${inv.versionedQueryCount}个 v1 查询`))
assert.ok(text.includes(`${inv.versionedCommandCount}个 v1 命令`))
assert.ok(text.includes(`${inv.dispatchVariantCount}种请求分支`))
assert.ok(text.includes(`${spec['x-flybot-examples'].length}个经过 Schema`))
for (const match of text.matchAll(/\]\(([^)]+)\)/g)) {
const target = match[1].split('#')[0]
if (target && !/^[a-z]+:/i.test(target)) assert.ok(existsSync(resolve(dirname(file), target)), target)
}
for (const match of text.matchAll(/```json\n([\s\S]*?)\n```/g)) JSON.parse(match[1])
for (const match of text.matchAll(/```bash\n([\s\S]*?)\n```/g)) execFileSync('bash', ['-n'], { input: match[1] })
})
6 changes: 6 additions & 0 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ Current stage and priorities: [ROADMAP.md](ROADMAP.md). How decisions are made:

## Agent connection authorization — 2026-11-03

#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,
safe/watch rights, same-principal receipts and host-only role execution remain distinct.
This adds contract/docs and isolated HTTP conformance checks, not runtime authority,
vendor callback adapters, service deployment or the deferred cross-repository loop.

#2183 adds scoped agent credentials over the existing member-session HTTP channel,
reuses per-participation safe/watch grants, and persists Task-origin provenance.
Owner's closure confirmation remains personal; native/external personal agents may
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ Project 管理项目、成员、角色与仓库;Chief 常驻协调,Dev/Revie
本交付分支包含后端收敛工作,尚未合入主仓 main 或部署到现有实例。
它复用 FlyBot/DSH、原账号服务、任务池和执行器;没有另建一套调度系统。

第三方 Bot 接入:[OpenAPI 3.1 JSON](docs/openapi/flybot.openapi.json) ·
[登录、授权与自主协作指南](docs/third-party-bot-integration.md)。规范按实际公开入口描述请求/响应、
权限、前置条件、状态变化和重试规则;外部代理使用人类签发的项目范围 Token。

## 启动持久服务

需要 **Node.js22.22+、npm、Git**,首次安装需要访问 npm。Linux 是当前验证环境。

```bash
git clone --branch feat/2189-backend-quickstart https://github.com/ThinkFlowLab/FlyBot.git
git clone --branch delivery/ai-centric-development-service https://github.com/ThinkFlowLab/FlyBot.git
cd FlyBot
node scripts/backend.mjs init
node scripts/backend.mjs start
Expand Down
6 changes: 5 additions & 1 deletion README.zh.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# FlyBot:AI 协作开发后端

第三方 Bot 接入:[OpenAPI 3.1 JSON](docs/openapi/flybot.openapi.json) ·
[登录、授权与自主协作指南](docs/third-party-bot-integration.md)。规范覆盖公开网关的请求/响应、
权限、前置条件、状态变化和恢复语义;外部代理使用人类签发的项目范围 Token。

Project 管理项目、成员、角色与仓库;Chief 常驻协调,Dev/Reviewer/Tester
按 Task 使用独立角色 Session,通过持久化 Handoff 自主接手、打回和转交。
普通提问直接咨询 Chief,不创建 Task。个人 Chief、个人 Bot、群聊与私有记忆继续保留。
Expand All @@ -12,7 +16,7 @@ Project 管理项目、成员、角色与仓库;Chief 常驻协调,Dev/Revie
需要 **Node.js22.22+、npm、Git**,首次安装需要访问 npm。Linux 是当前验证环境。

```bash
git clone --branch feat/2189-backend-quickstart https://github.com/ThinkFlowLab/FlyBot.git
git clone --branch delivery/ai-centric-development-service https://github.com/ThinkFlowLab/FlyBot.git
cd FlyBot
node scripts/backend.mjs init
node scripts/backend.mjs start
Expand Down
7 changes: 5 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ execution ancestor through collaboration/context/Task/state/API branches.
## Active fork roadmap — 2026-10-31

The earlier delivery snapshot at1811e3b8f consolidated S1–S6. Current persistent
source deployment and API use follow the root README and #2189; OpenAPI remains
unprovided. Backend delivery does not complete original frontend #41 acceptance
source deployment and API use follow the root README and #2189. #2300 adds the
machine-readable public OpenAPI contract and third-party onboarding guide, guarded
by source inventory/schema and representative isolated HTTP conformance tests.
Vendor-specific adapters and live external-provider acceptance remain separate.
Backend delivery does not complete original frontend #41 acceptance
or merge staged PRs.

S6/#55 integrates current service stages with original native frontend work and
Expand Down
1 change: 1 addition & 0 deletions changelog.d/2300.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Publish a source-matched OpenAPI 3.1 contract and third-party Bot onboarding guide for the public backend gateway, including independent agent credentials, per-operation rights, prerequisites, state effects and recovery. Validate the contract against representative isolated HTTP exchanges.
3 changes: 3 additions & 0 deletions docs/development-service-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

[快速开始](../README.md) · [协作语义](development-service-guide.md) · [部署](backend-operations.md)

[OpenAPI 3.1 JSON](openapi/flybot.openapi.json) 给出全部公开分派请求/响应 Schema、逐操作权限、
状态影响和前置条件;第三方个人代理的凭据签发、登录和自主协作见[接入指南](third-party-bot-integration.md)。

本页对应当前分支实际路由:**25个v1查询、28个v1命令**,以及对象管理使用的原生接口。
咨询和Task主动权交接是两种契约;角色执行回调不是公开HTTP命令。没有Swagger页面;以下请求均可由CLI或HTTP调用。

Expand Down
Loading
Loading