diff --git a/.github/scripts/backend-docs.test.mjs b/.github/scripts/backend-docs.test.mjs index f8c85a2dd..ebc3adf19 100644 --- a/.github/scripts/backend-docs.test.mjs +++ b/.github/scripts/backend-docs.test.mjs @@ -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', () => { diff --git a/PROJECT.md b/PROJECT.md index 3a40d57ea..ed5663801 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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, diff --git a/README.md b/README.md index 1e2c406dd..f1ea5a41d 100644 --- a/README.md +++ b/README.md @@ -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) | @@ -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)。 diff --git a/README.zh.md b/README.zh.md index a4acb869c..0466beeb9 100644 --- a/README.zh.md +++ b/README.zh.md @@ -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) | @@ -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)。 diff --git a/ROADMAP.md b/ROADMAP.md index 87fe26b72..73eb4a8ab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/changelog.d/2311.added.md b/changelog.d/2311.added.md new file mode 100644 index 000000000..4181f513a --- /dev/null +++ b/changelog.d/2311.added.md @@ -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. diff --git a/core/src/host/personal-agent-auth.js b/core/src/host/personal-agent-auth.js index 34a9e6f75..b6acda0a5 100644 --- a/core/src/host/personal-agent-auth.js +++ b/core/src/host/personal-agent-auth.js @@ -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 } } diff --git a/core/src/host/project-credentials.js b/core/src/host/project-credentials.js index 5e40b1ab3..0b958d7d7 100644 --- a/core/src/host/project-credentials.js +++ b/core/src/host/project-credentials.js @@ -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 } = {}) { @@ -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 } @@ -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') @@ -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) } diff --git a/core/src/host/project-delivery.js b/core/src/host/project-delivery.js index bf110100d..70eed652a 100644 --- a/core/src/host/project-delivery.js +++ b/core/src/host/project-delivery.js @@ -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' @@ -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) @@ -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) @@ -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) { @@ -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, @@ -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 diff --git a/core/src/host/project-development-service.js b/core/src/host/project-development-service.js index 34b52a1f7..1f50ce1c8 100644 --- a/core/src/host/project-development-service.js +++ b/core/src/host/project-development-service.js @@ -14,6 +14,7 @@ const stableId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,100}$/. const sameTask = (row, task) => Boolean(row && row.projectId === task.projectId && row.taskId === task.id) const roles = new Set(['chief', 'developer', 'tester', 'reviewer']) const operations = Object.freeze({ + 'github.connection': 'project-github-connection', 'github.connection.configure': 'project-github-connection-configure', 'inbox.list': 'project-agent-inbox-list', 'inbox.get': 'project-agent-inbox-get', 'inbox.connection': 'project-agent-inbox-connection', 'inbox.claim': 'project-agent-inbox-claim', 'inbox.renew': 'project-agent-inbox-renew', 'inbox.complete': 'project-agent-inbox-complete', 'inbox.fail': 'project-agent-inbox-fail', 'inbox.connection.configure': 'project-agent-inbox-connection-configure', 'consultation.reply': 'project-consultation-reply', 'consultation.ask': 'project-consultation-ask', 'consultation.get': 'project-consultation-get', 'consultation.list': 'project-consultation-list', @@ -34,7 +35,7 @@ const operations = Object.freeze({ 'execution.boundary': 'project-execution-boundary', 'pools': 'project-task-pools', 'effect.retry': 'project-effect-retry', 'comment.publish': 'project-github-comment', }) -const queries = new Set(['inbox.list', 'inbox.get', 'inbox.connection', 'release.status', 'compatibility', 'roles', 'role.proposals', 'consultation.get', 'consultation.list', 'handoff.get', 'handoff.list', 'task.history', 'project.snapshot', 'events', 'command.receipt', 'command.result', 'tasks', 'task', 'task.detail', 'inputs', 'sources', 'execution.boundary', 'effects', 'attempt.context', 'attempt.integration-state', 'attempt.log', 'chief.state', 'pools']) +const queries = new Set(['github.connection', 'inbox.list', 'inbox.get', 'inbox.connection', 'release.status', 'compatibility', 'roles', 'role.proposals', 'consultation.get', 'consultation.list', 'handoff.get', 'handoff.list', 'task.history', 'project.snapshot', 'events', 'command.receipt', 'command.result', 'tasks', 'task', 'task.detail', 'inputs', 'sources', 'execution.boundary', 'effects', 'attempt.context', 'attempt.integration-state', 'attempt.log', 'chief.state', 'pools']) const legacy = new Set(Object.values(operations)) const readEndpoints = new Set([...queries].map(name => operations[name]).filter(Boolean)) export const developmentQuery = body => record(body) && body.version === DEVELOPMENT_PROTOCOL && body.kind === 'query' && queries.has(body.name) @@ -102,7 +103,7 @@ export function taskView(state, task, { activity = {}, roleCatalog, isIntentComm export function createDevelopmentService(rt) { const receipts = rt.projectEffects ? createCommandReceipts(rt) : null - const management = new Set(['release.request', 'release.reconcile', 'role.decide', 'attempt.stop', 'attempt.cleanup', 'queue.retry', 'chief.inspect', 'chief.configure', 'schedule.configure', 'effect.retry', 'attempt.integration-bind', 'attempt.publication-retry', 'source.subscribe', 'source.disable', 'source.poll']) + const management = new Set(['github.connection.configure', 'release.request', 'release.reconcile', 'role.decide', 'attempt.stop', 'attempt.cleanup', 'queue.retry', 'chief.inspect', 'chief.configure', 'schedule.configure', 'effect.retry', 'attempt.integration-bind', 'attempt.publication-retry', 'source.subscribe', 'source.disable', 'source.poll']) const readCommand = new Set(['inbox.claim', 'inbox.renew', 'inbox.complete', 'inbox.fail', 'inbox.connection.configure', 'agent.mode', 'consultation.ask', 'consultation.reply']) const actionFor = name => ['task.propose', 'proposal.publish', 'comment.publish'].includes(name) ? 'create' : management.has(name) ? 'configure' : readCommand.has(name) ? 'read' : name === 'task.accept' || name === 'task.reject' ? 'accept' : 'task-write' @@ -175,6 +176,7 @@ export function createDevelopmentService(rt) { return { run: await rt.projectChiefControl.request(body.projectId, 'inspection', { requestId: body.requestId, requestedBy: actor.memberId, ...(task ? { blockedTaskId: task.id } : {}) }) } } async function invoke(endpoint, body, actor) { + if (endpoint === 'project-github-connection' || endpoint === 'project-github-connection-configure') return rt.projectGithubConnections.endpoint(endpoint.endsWith('-configure') ? 'configure' : 'status', body, actor) if (endpoint.startsWith('project-agent-inbox-')) return rt.projectAgentInbox.endpoint(endpoint.slice('project-agent-inbox-'.length).replace('connection-configure', 'connection.configure'), body, actor) if (endpoint === 'task-create') { let botId = body.botId diff --git a/core/src/host/project-execution-boundary.js b/core/src/host/project-execution-boundary.js index 0ce00a3d4..096165f85 100644 --- a/core/src/host/project-execution-boundary.js +++ b/core/src/host/project-execution-boundary.js @@ -1,9 +1,9 @@ // Trust metadata is not an authorization token or an OS sandbox claim. -import { isAbsolute } from 'node:path' +import { projectGithubStatus, githubConfigurationStatus } from './project-github-connections.js' import { ownEntry } from './store.js' import { repositoryReadiness } from './project-repository-policy.js' import { executionProfile } from './project-execution-profile.js' -const credential = settings => ({ configured: Boolean(settings?.credential === 'gh-cli' && isAbsolute(settings.configDir ?? '') && settings.login), login: settings?.login ?? null, verification: 'configuration-only; broker revalidates exact identity at invocation' }) +const credential = githubConfigurationStatus export async function executionBoundary(rt, body, actor) { const project = rt.requireProjectAccess(actor, body.projectId) const binding = [project.repository, project.ownerUserId] @@ -25,14 +25,14 @@ export async function executionBoundary(rt, body, actor) { try { rt.projectDelegations.validateGrant({ ...actor, watchGrantId: delegation.grant.id, watchGeneration: delegation.grant.generation }); delegation.effective = true } catch { delegation.reason = 'Recorded watch grant no longer matches the current human principal' } } - const settings = ownEntry(rt.config.projectRepositories ?? {}, project.repository) + const settings = projectGithubStatus(rt, project) const personal = ownEntry(ownEntry(rt.config.personalGithub ?? {}, actor.memberId) ?? {}, project.id) return { version: 'project-execution-boundary/v1', projectId: project.id, memberId: actor.memberId, profile, host: { uid: process.getuid?.() ?? null, identityScope: 'host runtime only; resource transport may select a different explicit UID', dedicatedAccount: 'operator responsibility, not inferred from numeric UID' }, permissions: Object.fromEntries(['read', 'create', 'configure', 'manage', 'invite', 'run-control', 'accept', 'task-write'].map(action => [action, rt.projectCan(actor, project.id, action, task)])), taskScope: task?.id ?? null, delegation, resources: rt.projectResources.list(project.id, actor).map(row => ({ id: row.id, type: row.type, capacity: row.capacity, harnesses: row.harnesses, health: { status: row.health.status, at: row.health.at }, harnessStatus: row.harnessStatus.map(({ harness, status, at }) => ({ harness, status, at: at ?? null })) })), - credentials: { project: credential(settings?.github), personal: credential(personal), fallback: 'none', harnessPrincipalId: project.ownerUserId }, + credentials: { project: settings, personal: credential(personal?.credential === 'github-app' ? undefined : personal), fallback: 'none', harnessPrincipalId: project.ownerUserId }, preflight: { ...readiness, tests: 'not-run', sdk: 'not-run', credentials: 'configuration-only', backgroundPrincipal: !rt.state.members[actor.memberId]?.platform ? 'local-member' : rt.config.controlPlane?.principalCheckCommand ? 'configured-checker; validated per background operation' : 'live-session-only; unattended execution needs operator principal checker' }, memory: { public: 'explicit shared Project/public Bot memory', private: 'original current-human private-agent APIs only', contentsRead: false, inheritedIntoPublicContext: false }, invariants: ['current Project/delegation/resource authorization', 'exclusive registered workspace ownership', 'lease and generation fencing', 'confirmed previous worker exit', 'exact candidate host tests and independent role Review', 'Chief adjudication'], diff --git a/core/src/host/project-github-app.js b/core/src/host/project-github-app.js new file mode 100644 index 000000000..fcb1af2a7 --- /dev/null +++ b/core/src/host/project-github-app.js @@ -0,0 +1,68 @@ +// Short-lived installation credentials are host-only and never serialized into state. +import { createHash, createPrivateKey, sign } from 'node:crypto' +import { privateCredentialFile } from './project-private-file.js' +const hash = value => createHash('sha256').update(value).digest('hex') +const denied = message => Object.assign(new Error(message), { code: 'bot/forbidden' }) +const validId = value => Number.isSafeInteger(value) && value > 0 +export function githubCredentialBinding(settings = {}, repository) { + return hash(JSON.stringify(settings.credential === 'github-app' + ? ['github-app', settings.appId, settings.installationId, repository, settings.connectionId ?? null, settings.connectionRevision ?? null] + : ['gh-cli', settings.configDir, settings.login?.toLowerCase(), repository, settings.connectionId ?? null, settings.connectionRevision ?? null])) +} +export function githubIdentityMetadata(identity) { + return { provider: identity.provider ?? 'gh-cli', login: identity.login, appId: identity.appId ?? null, installationId: identity.installationId ?? null, credentialIdentity: identity.credentialIdentity ?? null } +} +export function createGithubAppProvider({ request = fetch, now = Date.now, readKey = privateCredentialFile } = {}) { + const cache = new Map(), refreshing = new Map() + async function resolve(settings, { repository, permissions = { contents: 'read' } } = {}) { + if (!validId(settings.appId) || !validId(settings.installationId) || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository ?? '') || !Object.keys(permissions).length || Object.entries(permissions).some(([key, value]) => !['contents', 'pull_requests', 'issues', 'checks'].includes(key) || !['read', 'write'].includes(value))) throw denied('GitHub App installation scope is not configured') + let key, material + try { + material = await readKey(settings.privateKeyFile) + key = createPrivateKey(material) + if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails.modulusLength < 2048) throw Error('Invalid key') + } catch { throw denied('GitHub App private key is unavailable; operator must repair its private file') } + const encode = value => Buffer.from(JSON.stringify(value)).toString('base64url') + const seconds = Math.floor(now() / 1000), unsigned = encode({ alg: 'RS256', typ: 'JWT' }) + '.' + encode({ iat: seconds - 60, exp: seconds + 540, iss: String(settings.appId) }) + const jwt = unsigned + '.' + sign('RSA-SHA256', Buffer.from(unsigned), key).toString('base64url') + const api = async (path, body) => { + let response + try { response = await request('https://api.github.com' + path, { method: body ? 'POST' : 'GET', redirect: 'error', signal: AbortSignal.timeout(15000), headers: { authorization: `Bearer ${jwt}`, accept: 'application/vnd.github+json', 'content-type': 'application/json', 'x-github-api-version': '2022-11-28' }, ...(body ? { body: JSON.stringify(body) } : {}) }) } + catch { throw denied('GitHub App installation verification is unavailable') } + if (!response.ok) throw denied('GitHub App installation is unavailable; check installation, key and repository permissions') + try { return await response.json() } catch { throw denied('GitHub App installation returned invalid metadata') } + } + // Always verify current installation, even when a token is cached. + const installation = await api(`/repos/${repository}/installation`) + if (installation.id !== settings.installationId || installation.app_id !== settings.appId || installation.suspended_at || installation.account?.login?.toLowerCase() !== repository.split('/')[0].toLowerCase() || !/^[a-z0-9-]+$/.test(installation.app_slug ?? '')) throw denied('GitHub App installation or repository scope does not match') + for (const [permission, level] of Object.entries(permissions)) if (!['read', 'write'].includes(installation.permissions?.[permission]) || level === 'write' && installation.permissions[permission] !== 'write') throw denied('GitHub App installation permission is insufficient') + const login = installation.app_slug + '[bot]' + if (settings.login && settings.login.toLowerCase() !== login.toLowerCase()) throw denied('GitHub App installation login does not match') + const binding = githubCredentialBinding(settings, repository), cacheKey = binding + hash(material) + JSON.stringify(Object.entries(permissions).sort()) + const cached = cache.get(cacheKey) + if (cached && cached.expiresAt - now() > 60000 && cached.login === login) return { ...cached } + if (refreshing.has(cacheKey)) return { ...await refreshing.get(cacheKey) } + const pending = (async () => { + const result = await api(`/app/installations/${settings.installationId}/access_tokens`, { repositories: [repository.split('/')[1]], permissions }) + const expiresAt = Date.parse(result.expires_at) + if (typeof result.token !== 'string' || !/^[A-Za-z0-9_.-]{10,8192}$/.test(result.token) || !Number.isFinite(expiresAt) || expiresAt <= now() + 60000 || expiresAt > now() + 3660000 || !Array.isArray(result.repositories) || result.repositories.length !== 1 || result.repositories[0].full_name?.toLowerCase() !== repository.toLowerCase()) throw denied('GitHub App token scope or expiry is invalid') + for (const [permission, level] of Object.entries(permissions)) if (result.permissions?.[permission] !== level) throw denied('GitHub App token permission does not match') + const value = { token: result.token, login, provider: 'github-app', appId: settings.appId, installationId: settings.installationId, repository, credentialIdentity: binding, expiresAt } + if (cache.size >= 128) cache.delete(cache.keys().next().value) + cache.set(cacheKey, value) + return value + })() + refreshing.set(cacheKey, pending) + try { return { ...await pending } } finally { refreshing.delete(cacheKey) } + } + return { resolve, invalidate(settings, repository) { const binding = githubCredentialBinding(settings, repository); for (const [key, value] of cache) if (value.credentialIdentity === binding) cache.delete(key) } } +} +const providers = new WeakMap() +export function resolveGithubApp(settings, options = {}) { + const request = options.request ?? fetch + let provider = providers.get(request) + if (!provider) { provider = createGithubAppProvider({ request }); providers.set(request, provider) } + return provider.resolve(settings, options) +} + +export function invalidateGithubApp(settings, repository, request = fetch) { providers.get(request)?.invalidate(settings, repository) } diff --git a/core/src/host/project-github-connections.js b/core/src/host/project-github-connections.js new file mode 100644 index 000000000..3fb3b667a --- /dev/null +++ b/core/src/host/project-github-connections.js @@ -0,0 +1,59 @@ +// Humans select pre-provisioned connections; models never supply keys or URLs. +import { ownEntry, setEntry } from './store.js' +import { githubCredentialBinding, githubIdentityMetadata } from './project-github-app.js' +import { resolveGithubIdentity } from './project-github.js' +const refused = message => Object.assign(new Error(message), { code: 'bot/forbidden' }) +function provisioned(rt, project, id) { + const value = ownEntry(rt.config.projectGithubConnections ?? {}, id) + if (!value || value.enabled === false || !value.projectIds?.includes(project.id) || !value.repositories?.includes(project.repository)) throw refused('GitHub connection is not provisioned for this Project repository') + return value +} +export function projectGithubSettings(rt, project) { + const selection = ownEntry(project.githubConnections ?? {}, project.repository) + if (!selection) return ownEntry(rt.config.projectRepositories ?? {}, project.repository)?.github + if (!selection.connectionId) throw refused('Project GitHub connection is explicitly disabled') + if (selection.effect && !rt.projectEffects?.intentCommitted(selection.effect)) throw refused('GitHub connection selection is not committed') + const settings = provisioned(rt, project, selection.connectionId) + if (githubCredentialBinding(settings, project.repository) !== selection.binding) throw refused('GitHub connection identity changed; human verification is required') + return { ...settings, connectionId: selection.connectionId, connectionRevision: selection.revision } +} +export function githubConfigurationStatus(settings) { + return { configured: Boolean(settings?.credential === 'github-app' ? settings.appId && settings.installationId && settings.privateKeyFile : settings?.credential === 'gh-cli' && settings.configDir), provider: settings?.credential ?? null, login: settings?.login ?? null, appId: settings?.credential === 'github-app' ? settings.appId ?? null : null, installationId: settings?.credential === 'github-app' ? settings.installationId ?? null : null, verification: 'configuration-only; broker verifies current authorization per operation', recovery: 'Ask an authorized human to verify the selected connection; operator repairs keys/installations' } +} +export function projectGithubStatus(rt, project) { + try { return githubConfigurationStatus(projectGithubSettings(rt, project)) } + catch { return { ...githubConfigurationStatus(null), verification: 'unavailable; selected connection disabled or changed' } } +} +export function createGithubConnections(rt, { identity = resolveGithubIdentity } = {}) { + let tail = Promise.resolve() + rt.projectEffects?.register('github.connection', { run: row => ({ revision: row.payload.revision }) }, { replace: true }) + async function endpoint(operation, body, actor) { + const configure = operation === 'configure' + if (configure && (actor.agentId || actor.personalAgentCredentialId || actor.watchGrantId || actor.chiefJobId || actor.consultationProxyAgentId)) throw refused('Only the signed-in human may select project GitHub credentials') + const project = rt.requireProjectAccess(actor, body.projectId, configure ? 'configure' : 'read') + const repository = body.repository ?? project.repository + if (repository !== project.repository && !Object.values(rt.state.projectRepositories ?? {}).some(row => row.projectId === project.id && row.repository === repository)) throw refused('Repository is not associated with this Project') + const scoped = { ...project, repository }, previous = ownEntry(project.githubConnections ?? {}, repository) + const view = () => { + let status + try { status = githubConfigurationStatus(projectGithubSettings(rt, scoped)) } catch { status = { ...githubConfigurationStatus(null), recovery: 'Selection disabled or changed; human must reselect a provisioned connection' } } + return { repository, connectionId: previous?.connectionId ?? null, revision: previous?.revision ?? 0, verifiedAt: previous?.verifiedAt ?? null, identity: previous?.identity ?? null, ...status, options: Object.entries(rt.config.projectGithubConnections ?? {}).filter(([, row]) => row.enabled !== false && row.projectIds?.includes(project.id) && row.repositories?.includes(repository)).slice(0, 100).map(([id, row]) => ({ id, provider: row.credential, appId: row.appId ?? null, installationId: row.installationId ?? null, login: row.login ?? null })) } + } + if (!configure) return { connection: view() } + if (body.expectedRevision !== (previous?.revision ?? 0) || body.connectionId !== null && (typeof body.connectionId !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(body.connectionId))) throw Object.assign(Error('GitHub connection selection revision or ID is invalid'), { code: 'bot/conflict' }) + if (rt.validateHumanActor && !await rt.validateHumanActor(actor)) throw refused('Human session is unavailable') + const settings = body.connectionId === null ? null : provisioned(rt, scoped, body.connectionId) + const binding = settings ? githubCredentialBinding(settings, repository) : null + const selected = settings ? await identity(settings, { repository, permissions: { contents: 'read' } }) : null + const currentProject = rt.requireProjectAccess(actor, body.projectId, 'configure') + if (repository !== currentProject.repository && !Object.values(rt.state.projectRepositories ?? {}).some(row => row.projectId === currentProject.id && row.repository === repository)) throw refused('Project repository association changed during verification') + if (rt.validateHumanActor && !await rt.validateHumanActor(actor)) throw refused('Human session was revoked') + if (settings && githubCredentialBinding(provisioned(rt, scoped, body.connectionId), repository) !== binding || ownEntry(project.githubConnections ?? {}, repository) !== previous) throw refused('GitHub connection changed during verification') + const next = { connectionId: body.connectionId, revision: (previous?.revision ?? 0) + 1, binding, verifiedAt: Date.now(), selectedBy: actor.memberId, identity: selected ? githubIdentityMetadata(selected) : null } + if (rt.projectEffects) next.effect = rt.projectEffects.stage({ kind: 'github.connection', key: `${repository}:${body.requestId}`, projectId: project.id, payload: { repository, revision: next.revision, initiatedBy: actor.memberId, identity: next.identity } }) + project.githubConnections ??= {}; setEntry(project.githubConnections, repository, next) + try { await rt.save() } catch (error) { if (previous) setEntry(project.githubConnections, repository, previous); else delete project.githubConnections[repository]; throw error } + return { connection: { ...view(), connectionId: next.connectionId, revision: next.revision, verifiedAt: next.verifiedAt, ...(settings ? githubConfigurationStatus(settings) : githubConfigurationStatus(null)), identity: next.identity } } + } + return { endpoint(operation, body, actor) { if (operation !== 'configure') return endpoint(operation, body, actor); const result = tail.then(() => endpoint(operation, body, actor)); tail = result.catch(() => {}); return result } } +} diff --git a/core/src/host/project-github-read.js b/core/src/host/project-github-read.js index 2d08c1418..3d898dcde 100644 --- a/core/src/host/project-github-read.js +++ b/core/src/host/project-github-read.js @@ -1,6 +1,8 @@ // Bounded, repository-scoped GitHub reads. Never hand credentials or arbitrary URLs to models. import { ownEntry } from './store.js' import { resolveGithubIdentity } from './project-github.js' +import { projectGithubSettings } from './project-github-connections.js' +import { invalidateGithubApp, githubCredentialBinding, githubIdentityMetadata } from './project-github-app.js' const refuse = message => Object.assign(new Error(message), { code: 'bot/forbidden' }) const bounded = value => typeof value === 'string' ? value.slice(0, 1200) : null export function createProjectGithubRead(rt, { identity = resolveGithubIdentity, request = fetch } = {}) { @@ -26,17 +28,25 @@ export function createProjectGithubRead(rt, { identity = resolveGithubIdentity, // Explicit automation identity is appropriate for shared Project reads. Personal reads // require an explicit personal selection; there is no ambient-token fallback. const scope = body.scope === 'personal' ? 'personal' : 'project' - const settings = scope === 'personal' ? ownEntry(ownEntry(rt.config.personalGithub ?? {}, actor.memberId) ?? {}, project.id) : ownEntry(rt.config.projectRepositories ?? {}, project.repository)?.github - if (!settings?.login) throw refuse('Selected scoped GitHub read identity is not configured') - const selected = await identity(settings) - if (selected.login.toLowerCase() !== settings.login.toLowerCase()) throw refuse('Scoped GitHub read identity mismatch') + const settings = scope === 'personal' ? ownEntry(ownEntry(rt.config.personalGithub ?? {}, actor.memberId) ?? {}, project.id) : projectGithubSettings(rt, project) + if (scope === 'personal' && settings?.credential === 'github-app') throw refuse('Personal reads require independent user credentials') + if (!settings?.login && settings?.credential !== 'github-app') throw refuse('Selected scoped GitHub read identity is not configured') + const permissions = operation === 'issues' || operation === 'thread-events' && body.threadKind === 'issue' ? { issues: 'read' } : operation === 'checks' || operation === 'thread-events' ? operation === 'checks' ? { pull_requests: 'read', checks: 'read' } : { pull_requests: 'read', issues: 'read', checks: 'read' } : { pull_requests: 'read' } + const binding = githubCredentialBinding(settings, project.repository) + const selected = await identity(settings, { request, repository: project.repository, permissions }) + if (settings.login && selected.login.toLowerCase() !== settings.login.toLowerCase()) throw refuse('Scoped GitHub read identity mismatch') const validate = async () => { if ((rt.platformIdentityEnabled || backgroundActors.has(actor)) && !await validActor(actor, project)) throw refuse('Human platform session was revoked') if (rt.requireProjectAccess(actor, project.id).repository !== project.repository) throw refuse('Project repository changed during the read') + if (scope === 'project' && githubCredentialBinding(projectGithubSettings(rt, rt.requireProjectAccess(actor, project.id)), project.repository) !== binding) throw refuse('GitHub connection changed during read') } await validate() const get = async suffix => { - const response = await request(`https://api.github.com/repos/${project.repository}${suffix}`, { redirect: 'error', signal: AbortSignal.timeout(20_000), headers: { authorization: `Bearer ${selected.token}`, accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28' } }) + await validate() + const current = settings.credential === 'github-app' ? await identity(settings, { request, repository: project.repository, permissions }) : selected + await validate() + const response = await request(`https://api.github.com/repos/${project.repository}${suffix}`, { redirect: 'error', signal: AbortSignal.timeout(20_000), headers: { authorization: `Bearer ${current.token}`, accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28' } }) + if ([401, 403].includes(response.status)) invalidateGithubApp(settings, project.repository, request) if (!response.ok) throw new Error(`Scoped GitHub read failed (${response.status})`) const text = await response.text() if (Buffer.byteLength(text) > 2 * 1024 * 1024) throw new Error('Scoped GitHub response exceeds its byte budget') @@ -82,7 +92,7 @@ export function createProjectGithubRead(rt, { identity = resolveGithubIdentity, data.checks = (checks.check_runs ?? []).slice(0, 20).map(row => ({ name: row.name, status: row.status, conclusion: row.conclusion, url: row.html_url })) } } - return { repository: project.repository, operation, scope, executorLogin: selected.login, at: Date.now(), limit: 20, pagination: 'bounded-first-page', data } + return { repository: project.repository, operation, scope, executorLogin: selected.login, identity: githubIdentityMetadata(selected), at: Date.now(), limit: 20, pagination: 'bounded-first-page', data } } read.background = async (body, actor) => { if (body.scope === 'personal') throw refuse('Background Source reads require Project automation credentials') diff --git a/core/src/host/project-github.js b/core/src/host/project-github.js index 5f724c103..d080bd0c9 100644 --- a/core/src/host/project-github.js +++ b/core/src/host/project-github.js @@ -2,10 +2,12 @@ import { execFile } from 'node:child_process' import { isAbsolute } from 'node:path' import { promisify } from 'node:util' +import { resolveGithubApp, invalidateGithubApp, githubCredentialBinding, githubIdentityMetadata } from './project-github-app.js' const exec = promisify(execFile) const repositoryName = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/ -export async function resolveGithubIdentity(settings = {}, { run = exec, request = fetch } = {}) { +export async function resolveGithubIdentity(settings = {}, { run = exec, request = fetch, repository, permissions } = {}) { + if (settings.credential === 'github-app') return resolveGithubApp(settings, { request, repository, permissions }) if (settings.credential !== 'gh-cli' || !isAbsolute(settings.configDir ?? '')) throw new Error('Project GitHub automation credential is not explicitly configured') const env = { ...process.env, GH_CONFIG_DIR: settings.configDir } for (const key of ['GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN']) delete env[key] @@ -19,17 +21,27 @@ export async function resolveGithubIdentity(settings = {}, { run = exec, request if (!response.ok) throw new Error('Selected GitHub identity could not be verified') const user = await response.json() if (typeof user.login !== 'string' || !user.login || settings.login && user.login.toLowerCase() !== settings.login.toLowerCase()) throw new Error('Selected GitHub identity does not match the configured login') - return { token, login: user.login } + return { token, login: user.login, provider: 'gh-cli', credentialIdentity: githubCredentialBinding(settings, repository) } } -export function githubPublisher(settings = {}, { run = exec, request = fetch } = {}) { +export function githubPublisher(settings = {}, { run = exec, request = fetch, authorize = async () => {} } = {}) { + const original = { ...settings } + const check = async repository => { + if (githubCredentialBinding(settings, repository) !== githubCredentialBinding(original, repository)) throw new Error('GitHub publication connection changed; reconcile with original identity') + await authorize() + } async function api(repository, path, token, body) { if (!repositoryName.test(repository)) throw new Error('Invalid Project repository') - const response = await request(`https://api.github.com/repos/${repository}${path}`, { + await check(repository) + const selected = await resolveGithubIdentity(settings, { run, request, repository, permissions: body ? { contents: 'write', pull_requests: 'write' } : { contents: 'read', pull_requests: 'read' } }) + await check(repository) + let response + try { response = await request(`https://api.github.com/repos/${repository}${path}`, { method: body ? 'POST' : 'GET', redirect: 'error', signal: AbortSignal.timeout(30_000), - headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json', 'content-type': 'application/json', 'x-github-api-version': '2022-11-28' }, + headers: { authorization: `Bearer ${selected.token}`, accept: 'application/vnd.github+json', 'content-type': 'application/json', 'x-github-api-version': '2022-11-28' }, ...(body ? { body: JSON.stringify(body) } : {}), - }) + }) } catch { throw new Error('GitHub operation outcome is unavailable; reconcile before retry') } + if ([401, 403].includes(response.status)) invalidateGithubApp(settings, repository, request) if (!response.ok) throw new Error(`GitHub operation failed (${response.status})`) return response.json() } @@ -41,14 +53,17 @@ export function githubPublisher(settings = {}, { run = exec, request = fetch } = } return { async publish(attempt, project) { - const identity = await resolveGithubIdentity(settings, { run, request }) - const token = identity.token const repository = project.repository + await check(repository) + const identity = await resolveGithubIdentity(settings, { run, request, repository, permissions: { contents: 'write', pull_requests: 'write' } }) + const token = identity.token if (!repositoryName.test(repository)) throw new Error('Invalid Project repository') + const verifyCreator = pr => { if (identity.provider === 'github-app' && pr.user?.login?.toLowerCase() !== identity.login.toLowerCase()) throw new Error('Published PR creator does not match the selected App; reconcile existing PR') } const binding = attempt.publicationBinding if (binding) { if (binding.repository !== repository || !Number.isSafeInteger(binding.number) || binding.number < 1 || typeof binding.branch !== 'string' || !/^[a-f0-9]{40}$/.test(binding.previousSha)) throw new Error('Existing delivery PR binding is invalid') const bound = await api(repository, `/pulls/${binding.number}`, token) + verifyCreator(bound) if (bound.state !== 'open' || bound.number !== binding.number || bound.head?.repo?.full_name !== repository || bound.head?.ref !== binding.branch || ![binding.previousSha, attempt.candidateSha].includes(bound.head?.sha)) throw new Error('Existing delivery PR changed; reconcile before publication') if (!/^[a-f0-9]{40}$/.test(binding.upstreamSha ?? '') || typeof binding.upstreamRef !== 'string') throw new Error('Existing PR upstream binding is missing') const upstream = await upstreamOf(repository, bound, token) @@ -60,27 +75,34 @@ export function githubPublisher(settings = {}, { run = exec, request = fetch } = const publicationBranch = binding?.branch ?? attempt.branch const existing = binding ? [] : await api(repository, `/pulls?state=all&head=${encodeURIComponent(repository.split('/')[0] + ':' + publicationBranch)}`, token) if (!Array.isArray(existing) || existing.length > 1 || existing[0]?.state === 'closed') throw new Error('Publication branch has a closed or ambiguous PR; reconcile without creating another PR') + if (existing[0]) verifyCreator(existing[0]) if (existing[0] && existing[0].head?.sha !== attempt.candidateSha) throw new Error('Existing publication PR changed; reconcile before pushing') - const authorization = Buffer.from(`x-access-token:${token}`).toString('base64') + await check(repository) + const pushIdentity = await resolveGithubIdentity(settings, { run, request, repository, permissions: { contents: 'write', pull_requests: 'write' } }) + await check(repository) + const authorization = Buffer.from(`x-access-token:${pushIdentity.token}`).toString('base64') + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/TOKEN|PASSWORD|SECRET|API_KEY|GIT_CONFIG|GIT_TRACE|GIT_ASKPASS|SSH_ASKPASS/.test(name))) // A fixed URL avoids inheriting a model-edited remote; the credential is environment-only. try { await run('git', ['-c', 'core.hooksPath=/dev/null', 'push', `https://github.com/${repository}.git`, `${attempt.candidateSha}:refs/heads/${publicationBranch}`], { cwd: attempt.publicationRepository ?? attempt.workspaces.developer.path, timeout: 120_000, maxBuffer: 64 * 1024, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_COUNT: '2', + env: { ...env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_COUNT: '4', + GIT_CONFIG_KEY_2: 'credential.helper', GIT_CONFIG_VALUE_2: '', GIT_CONFIG_KEY_3: 'core.askPass', GIT_CONFIG_VALUE_3: '', GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader', GIT_CONFIG_VALUE_0: '', GIT_CONFIG_KEY_1: 'http.https://github.com/.extraheader', GIT_CONFIG_VALUE_1: `AUTHORIZATION: basic ${authorization}` }, }) - } catch { throw new Error('Project branch could not be pushed') } + } catch { invalidateGithubApp(settings, repository, request); throw new Error('Project branch could not be pushed') } const pr = binding ? await api(repository, `/pulls/${binding.number}`, token) : existing[0] ?? await api(repository, '/pulls', token, { title: attempt.requirement.title.slice(0, 120), head: attempt.branch, base: project.defaultBranch ?? 'main', draft: true, body: `Project delivery artifact.\n\nTask: ${attempt.taskId}\nAttempt: ${attempt.id}\nCommit: ${attempt.candidateSha}\nTests: passed at this commit\nReview: approved at this commit\n\n${attempt.requirement.criteria ?? ''}\n\nGenerated for human acceptance; no automatic merge.\n\n` }) if (binding && (pr.number !== binding.number || pr.state !== 'open' || pr.head?.ref !== publicationBranch || pr.head?.repo?.full_name !== repository)) throw new Error('Existing delivery PR changed during publication') if (binding) { const upstream = await upstreamOf(repository, pr, token); if (upstream.baseSha !== binding.upstreamSha || upstream.baseRef !== binding.upstreamRef) throw new Error('Existing PR upstream changed during publication') } if (pr.head?.sha !== attempt.candidateSha) throw new Error('PR head does not match the tested candidate') - return { number: pr.number, url: pr.html_url, sha: pr.head.sha, repository, executorLogin: identity.login, credentialScope: 'project-automation' } + verifyCreator(pr) + return { number: pr.number, url: pr.html_url, sha: pr.head.sha, repository, executorLogin: identity.login, credentialScope: 'project-automation', identity: githubIdentityMetadata(identity), initiatedBy: attempt.initiatedBy ?? null, taskId: attempt.taskId, attemptId: attempt.id ?? null, creatorLogin: pr.user?.login ?? null, operation: 'pull-request-publish', executionRole: 'project-delivery-host', candidateSha: attempt.candidateSha } }, async read(pr, { includeBase = false } = {}) { - const identity = await resolveGithubIdentity(settings, { run, request }) + const identity = await resolveGithubIdentity(settings, { run, request, repository: pr.repository, permissions: { contents: 'read', pull_requests: 'read' } }) const token = identity.token const data = await api(pr.repository, `/pulls/${pr.number}`, token) return { sha: data.head?.sha, state: data.state, url: data.html_url, ...(includeBase ? await upstreamOf(pr.repository, data, token) : {}) } diff --git a/core/src/host/project-release-github.js b/core/src/host/project-release-github.js index 117624bb0..955af7327 100644 --- a/core/src/host/project-release-github.js +++ b/core/src/host/project-release-github.js @@ -1,9 +1,10 @@ // Typed GitHub release adapter; credentials stay in the existing selected profile. import { resolveGithubIdentity } from './project-github.js' +import { invalidateGithubApp, githubIdentityMetadata } from './project-github-app.js' export function githubReleaseProvider(settings, { request = fetch, identity = resolveGithubIdentity } = {}) { async function api(spec, path, body, authorize) { if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(spec.repository)) throw new Error('Invalid release repository') - const selected = await identity(settings, { request }) + const selected = await identity(settings, { request, repository: spec.repository, permissions: { contents: body ? 'write' : 'read' } }) if (authorize) await authorize() let response try { response = await request(`https://api.github.com/repos/${spec.repository}${path}`, { @@ -11,9 +12,10 @@ export function githubReleaseProvider(settings, { request = fetch, identity = re headers: { authorization: `Bearer ${selected.token}`, accept: 'application/vnd.github+json', 'content-type': 'application/json', 'x-github-api-version': '2022-11-28' }, ...(body ? { body: JSON.stringify(body) } : {}), }) } catch { throw new Error('Release outcome is unavailable; reconcile before any retry') } + if ([401, 403].includes(response.status)) invalidateGithubApp(settings, spec.repository, request) if (response.status === 404 && !body) return null if (!response.ok) throw Object.assign(new Error(`Release provider refused (${response.status})`), { code: body && response.status >= 400 && response.status < 500 ? 'release/rejected' : 'release/unknown' }) - return { value: await response.json(), executorLogin: selected.login } + return { value: await response.json(), executorLogin: selected.login, identity: githubIdentityMetadata(selected) } } return { async inspect(spec, authorize) { @@ -27,7 +29,7 @@ export function githubReleaseProvider(settings, { request = fetch, identity = re } if (ref?.type !== 'commit' || !/^[a-f0-9]{40}$/.test(ref.sha ?? '') || !Number.isSafeInteger(row.id) || row.id < 1 || row.tag_name !== spec.tag || row.draft || typeof row.published_at !== 'string') throw new Error('Release is not a verifiable published commit') return { provider: 'github', repository: spec.repository, tag: row.tag_name, sha: ref.sha, releaseId: row.id, publishedAt: row.published_at, - url: `https://github.com/${spec.repository}/releases/tag/${encodeURIComponent(spec.tag)}`, executorLogin: result.executorLogin, marker: typeof row.body === 'string' && row.body.includes(spec.marker) } + url: `https://github.com/${spec.repository}/releases/tag/${encodeURIComponent(spec.tag)}`, executorLogin: result.executorLogin, identity: result.identity, marker: typeof row.body === 'string' && row.body.includes(spec.marker) } }, async publish(spec, authorize) { const existing = (await api(spec, `/git/ref/tags/${encodeURIComponent(spec.tag)}`, undefined, authorize))?.value?.object diff --git a/core/src/host/project-releases.js b/core/src/host/project-releases.js index 342d44ba5..d936e4eb9 100644 --- a/core/src/host/project-releases.js +++ b/core/src/host/project-releases.js @@ -3,11 +3,13 @@ import { createHash } from 'node:crypto' import { ownEntry } from './store.js' import { repositorySettings } from './project-repository-policy.js' import { githubReleaseProvider } from './project-release-github.js' +import { projectGithubSettings } from './project-github-connections.js' +import { githubCredentialBinding } from './project-github-app.js' import { EffectCancelled } from './project-effects.js' const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') const fail = message => Object.assign(new Error(message), { code: 'bot/release' }) const reference = actor => Object.fromEntries(['memberId', 'sessionId', 'agentId', 'watchGrantId', 'watchGeneration', 'personalAgentCredentialId', 'personalAgentCredentialGeneration'].filter(key => actor[key] !== undefined).map(key => [key, actor[key]])) -const view = row => row ? { id: row.id, status: row.status, repository: row.spec.repository, tag: row.spec.tag, sha: row.spec.sha, mode: row.spec.mode, authorizedBy: row.actor.memberId, createdAt: row.createdAt, observedAt: row.observedAt ?? null, proof: row.proof ? structuredClone(row.proof) : null, effect: structuredClone(row.effect), result: row.result ?? null } : null +const view = row => row ? { id: row.id, status: row.status, repository: row.spec.repository, tag: row.spec.tag, sha: row.spec.sha, mode: row.spec.mode, authorizedBy: row.actor.memberId, authorizedAgentId: row.actor.agentId ?? null, taskId: row.taskId ?? null, attemptId: row.attemptId ?? null, createdAt: row.createdAt, observedAt: row.observedAt ?? null, proof: row.proof ? structuredClone(row.proof) : null, effect: structuredClone(row.effect), result: row.result ?? null } : null export function createProjectReleases(rt, { projectOf, providerFactory = settings => githubReleaseProvider(settings.github) }) { let tail = Promise.resolve() const serial = fn => { const result = tail.then(fn, fn); tail = result.catch(() => {}); return result } @@ -19,9 +21,9 @@ export function createProjectReleases(rt, { projectOf, providerFactory = setting function settingsOf(attempt) { const project = projectOf(attempt.projectId, attempt), settings = repositorySettings(rt, project) if (settings.release?.enabled !== true || settings.release.provider !== 'github') throw fail('Release provider is not explicitly configured') - return settings + return { ...settings, github: projectGithubSettings(rt, project), githubRepository: project.repository } } - const policyHash = settings => hash({ release: settings.release, github: settings.github }) + const policyHash = settings => hash({ release: settings.release, github: settings.github?.credential === 'github-app' ? githubCredentialBinding(settings.github, settings.githubRepository) : settings.github }) async function authority(attempt, task, row) { if (rt.readOnly?.() !== undefined) throw new EffectCancelled('Release host is read-only') await rt.projectDelegations?.background(row.actor.memberId, row.actor.sessionId) @@ -91,7 +93,7 @@ export function createProjectReleases(rt, { projectOf, providerFactory = setting if (prior.requestId !== body.requestId || prior.requestHash !== requestHash || prior.actor.memberId !== actor.memberId) throw fail('Release request already exists; reconcile it instead of replacing authorization') return { release: view(prior), already: true } } - const row = { id: `release-${attempt.id}`, requestId: body.requestId, requestHash, spec, actor: reference(actor), policySha256: policyHash(settings), requirementsRevision: task.projectTask?.requirements.revision ?? null, status: 'authorized', createdAt: Date.now() } + const row = { id: `release-${attempt.id}`, taskId: task.id, attemptId: attempt.id, requestId: body.requestId, requestHash, spec, actor: reference(actor), policySha256: policyHash(settings), requirementsRevision: task.projectTask?.requirements.revision ?? null, status: 'authorized', createdAt: Date.now() } attempt.release = row let token try { diff --git a/core/test/fixtures/github-app.mjs b/core/test/fixtures/github-app.mjs new file mode 100644 index 000000000..f4d6c58ee --- /dev/null +++ b/core/test/fixtures/github-app.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { generateKeyPairSync, verify } from 'node:crypto' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +export function appFixture(t, handle, repository = 'org/repo') { + const directory = mkdtempSync(join(tmpdir(), 'flybot-app-contract-')), privateKeyFile = join(directory, 'app.pem'), pair = generateKeyPairSync('rsa', { modulusLength: 2048 }) + writeFileSync(privateKeyFile, pair.privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 }) + t.after(() => rmSync(directory, { recursive: true, force: true })) + const settings = { credential: 'github-app', appId: 7, installationId: 12, privateKeyFile }, tokens = new Map(), minted = [] + let suspended = false + const request = async (url, options = {}) => { + assert(url.startsWith('https://api.github.com/')); assert.equal(options.redirect, 'error') + const authorization = options.headers.authorization.slice(7) + if (url.endsWith('/installation') || url.endsWith('/access_tokens')) { + const parts = authorization.split('.') + assert(verify('RSA-SHA256', Buffer.from(parts[0] + '.' + parts[1]), pair.publicKey, Buffer.from(parts[2], 'base64url'))) + if (url.endsWith('/installation')) return Response.json({ id: 12, app_id: 7, app_slug: 'contract-app', account: { login: repository.split('/')[0] }, suspended_at: suspended ? 'now' : null, permissions: { contents: 'write', issues: 'write', pull_requests: 'write', checks: 'read' } }) + const body = JSON.parse(options.body); assert.deepEqual(body.repositories, [repository.split('/')[1]]) + minted.push(body.permissions); const token = 'ghs_7_contract.' + minted.length; tokens.set(token, body.permissions) + return Response.json({ token, expires_at: new Date(Date.now() + 3600000).toISOString(), permissions: body.permissions, repositories: [{ full_name: repository }] }) + } + assert(tokens.has(authorization), 'External operation uses a minted installation token, never JWT/ambient credentials') + return handle(url, options, tokens.get(authorization)) + } + return { settings, request, minted, suspend() { suspended = true } } +} diff --git a/core/test/project-credentials.test.js b/core/test/project-credentials.test.js index b70322957..c6ad3c368 100644 --- a/core/test/project-credentials.test.js +++ b/core/test/project-credentials.test.js @@ -65,3 +65,11 @@ test('malformed credential output is refused without reflecting it or sending a }), error => /unavailable/.test(error.message) && !error.message.includes('private-value')) assert.equal(called, false) }) + +test('project App credentials cannot be selected for personal comments', async () => { + let calls = 0 + const rt = { state: {}, config: { personalGithub: { alice: { p: { credential: 'github-app', appId: 7, installationId: 12, login: 'app[bot]' } } } }, requireProjectAccess: () => ({ id: 'p', repository: 'org/repo' }) } + const service = createProjectCredentials(rt, { identity: async () => { calls++; return { token: 'never', login: 'app[bot]' } } }) + await assert.rejects(service.endpoint('project-github-comment', { projectId: 'p', issueNumber: 1, text: 'As user' }, { memberId: 'alice' }), /independent user/) + assert.equal(calls, 0) +}) diff --git a/core/test/project-delivery.test.js b/core/test/project-delivery.test.js index f3b6d3d6c..0dd9c3106 100644 --- a/core/test/project-delivery.test.js +++ b/core/test/project-delivery.test.js @@ -7,6 +7,7 @@ import { join } from 'node:path' import { test } from 'node:test' import { createRuntime } from '../index.js' import { roleExecutionPrompt } from '../src/host/project-role-policy.js' +import { githubCredentialBinding } from '../src/host/project-github-app.js' import { createProjectDelivery } from '../src/host/project-delivery.js' import { createTaskContract, admitTask, addTaskDependencies } from '../src/host/project-task-lifecycle.js' import { childResultSources } from '../src/host/project-child-inputs.js' @@ -1676,3 +1677,23 @@ test('Release Task closes only after explicit durable authorization and current assert.equal(a.acceptedViaChief, 'release-close') assert.equal(posts, 1) }) + +test('publication recovery pins the original App identity and preserves evidence after connection changes', async t => { + const f = await setup(t); await f.start('app-fence'); await complete(f) + const attempt = f.attempt(), original = { credential: 'github-app', appId: 7, installationId: 12, privateKeyFile: '/never-read.pem' } + attempt.publicationCredential = githubCredentialBinding(original, 'example/demo') + f.rt.config.projectRepositories['example/demo'].github = { ...original, installationId: 99 } + f.rt.projectDelivery = createProjectDelivery(f.rt) + const count = f.published.length + await assert.rejects(f.rt.projectEffects.perform({ kind: 'github.publish', key: 'connection-change-probe', projectId: 'p1', taskId: f.task.id, payload: { attemptId: attempt.id, candidateSha: attempt.candidateSha, initiatedBy: 'owner-1' } }), /connection changed/) + assert.equal(f.published.length, count); assert.equal(attempt.evidence.tests.passed, true); assert.equal(attempt.pr.sha, attempt.candidateSha) +}) + +test('publication revalidates operator repository capability before any provider operation', async t => { + const f = await setup(t); await f.start('app-repository-fence'); await complete(f) + const attempt = f.attempt() + f.rt.config.projectRepositories['example/demo'].allowedProjectOwnerIds = [] + f.rt.projectDelivery = createProjectDelivery(f.rt) + await assert.rejects(f.rt.projectEffects.perform({ kind: 'github.publish', key: 'repository-revoked-probe', projectId: 'p1', taskId: f.task.id, payload: { attemptId: attempt.id, candidateSha: attempt.candidateSha, initiatedBy: 'owner-1' } }), /not provisioned/) + assert.equal(attempt.evidence.tests.passed, true); assert.equal(f.published.length, 1) +}) diff --git a/core/test/project-development-service.test.js b/core/test/project-development-service.test.js index 284293084..77bc59470 100644 --- a/core/test/project-development-service.test.js +++ b/core/test/project-development-service.test.js @@ -306,3 +306,14 @@ test('attempt lists reference large frozen definitions instead of copying their assert.equal(result.attempts[0].roleSessions.developer.definitionRevision, 1) assert.equal(state.projectAttempts.a.roleSessions.developer.definition.instructions, text) }) + +test('GitHub connection operations use the v1 facade with current manager authority', async () => { + const { rt, service, actor } = fixture(), calls = [] + rt.projectGithubConnections = { endpoint: async (...args) => { rt.requireProjectAccess(args[2], args[1].projectId, args[0] === 'configure' ? 'configure' : 'read'); calls.push(args); return { connection: { configured: false } } } } + const envelope = { version: DEVELOPMENT_PROTOCOL, projectId: 'p', kind: 'command', name: 'github.connection.configure', requestId: 'select-one', payload: { connectionId: 'app', expectedRevision: 0 } } + await service.endpoint('project-development', envelope, actor) + assert.equal(calls[0][0], 'configure'); assert.equal(calls[0][2], actor) + await assert.rejects(service.endpoint('project-development', envelope, { memberId: 'bob' }), /denied/) + const result = await service.endpoint('project-development', { ...envelope, kind: 'query', name: 'github.connection' }, { memberId: 'bob' }) + assert.equal(result.result.connection.configured, false); assert.equal(calls.length, 2) +}) diff --git a/core/test/project-github-app.test.js b/core/test/project-github-app.test.js new file mode 100644 index 000000000..1b1d0425b --- /dev/null +++ b/core/test/project-github-app.test.js @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { generateKeyPairSync, verify } from 'node:crypto' +import { createGithubAppProvider } from '../src/host/project-github-app.js' +const keys = () => generateKeyPairSync('rsa', { modulusLength: 2048 }) +function fixture() { + let time = 1800000000000, pair = keys(), mints = 0, suspended = false, permissions = { contents: 'write', pull_requests: 'write' }, installed = 12, tokenOverride = {} + const settings = { credential: 'github-app', appId: 7, installationId: 12, privateKeyFile: '/private/test.pem' } + const request = async (url, options) => { + const jwt = options.headers.authorization.slice(7), [header, payload, signature] = jwt.split('.') + assert.equal(JSON.parse(Buffer.from(header, 'base64url')).alg, 'RS256') + assert(verify('RSA-SHA256', Buffer.from(header + '.' + payload), pair.publicKey, Buffer.from(signature, 'base64url'))) + const claims = JSON.parse(Buffer.from(payload, 'base64url')) + assert.equal(claims.iss, '7'); assert.equal(claims.iat, Math.floor(time / 1000) - 60); assert(claims.exp <= time / 1000 + 600) + if (url.endsWith('/installation')) return Response.json({ id: installed, app_id: 7, app_slug: 'flybot-test', account: { login: 'example' }, suspended_at: suspended ? 'yesterday' : null, permissions }) + assert.equal(url, 'https://api.github.com/app/installations/12/access_tokens') + const body = JSON.parse(options.body); assert.deepEqual(body.repositories, ['demo']) + mints++ + return Response.json({ token: 'ghs_7_new.format.' + mints, expires_at: new Date(time + 3600000).toISOString(), permissions: body.permissions, repositories: [{ full_name: 'example/demo' }], ...tokenOverride }) + } + const makeProvider = () => createGithubAppProvider({ request, now: () => time, readKey: async () => pair.privateKey.export({ type: 'pkcs8', format: 'pem' }) }) + const provider = makeProvider() + return { settings, provider, restart: makeProvider, count: () => mints, token(value) { tokenOverride = value }, advance() { time += 3600000 }, rotate() { pair = keys() }, suspend() { suspended = true }, wrong() { installed = 99 }, reduce() { permissions = { contents: 'read' } } } +} +test('App JWT, scoped tokens, concurrent refresh, expiry, rotation and restart preserve logical identity', async () => { + const f = fixture(), options = { repository: 'example/demo', permissions: { contents: 'write', pull_requests: 'write' } } + const rows = await Promise.all(Array.from({ length: 8 }, () => f.provider.resolve(f.settings, options))) + assert.equal(f.count(), 1); assert.equal(rows[0].login, 'flybot-test[bot]'); assert.equal(rows[0].provider, 'github-app') + assert(rows.every(row => row.token === rows[0].token)); const binding = rows[0].credentialIdentity + f.advance(); assert.notEqual((await f.provider.resolve(f.settings, options)).token, rows[0].token); assert.equal(f.count(), 2) + f.rotate(); assert.equal((await f.provider.resolve(f.settings, options)).credentialIdentity, binding); assert.equal(f.count(), 3) + await assert.rejects(f.provider.resolve(f.settings, { ...options, repository: 'other/demo' }), /installation|scope/) + await f.restart().resolve(f.settings, options); assert.equal(f.count(), 4, 'restart remints instead of restoring tokens from state') + f.suspend(); await assert.rejects(f.provider.resolve(f.settings, options), /installation/); assert.equal(f.count(), 4) +}) +test('App installation and permissions fail closed even with a cached token', async () => { + for (const action of ['wrong', 'reduce']) { + const f = fixture(), options = { repository: 'example/demo', permissions: { contents: 'write' } } + await f.provider.resolve(f.settings, options); f[action]() + await assert.rejects(f.provider.resolve(f.settings, options), /installation|permission/); assert.equal(f.count(), 1) + } +}) + +test('App provider sanitizes key/network failures and refuses oversized, widened or stale tokens', async () => { + const f = fixture() + await assert.rejects(f.provider.resolve({ ...f.settings, appId: '7' }, { repository: 'example/demo' }), /scope/) + const provider = createGithubAppProvider({ readKey: async () => { throw Error('PRIVATE-KEY-SENTINEL') } }) + await assert.rejects(provider.resolve(f.settings, { repository: 'example/demo' }), error => !error.message.includes('SENTINEL') && /private key/.test(error.message)) +}) + +test('widened repository scope, missing permission, stale expiry and malformed tokens are rejected', async () => { + for (const bad of [{ repositories: [{ full_name: 'other/repo' }] }, { repositories: [{ full_name: 'example/demo' }, { full_name: 'other/repo' }] }, { permissions: {} }, { expires_at: '2000-01-01T00:00:00Z' }, { token: 'injected\r\nheader' }, { token: 'x'.repeat(8193) }]) { + const f = fixture(); f.token(bad) + await assert.rejects(f.provider.resolve(f.settings, { repository: 'example/demo' }), /scope|expiry|permission/) + } +}) diff --git a/core/test/project-github-connections.test.js b/core/test/project-github-connections.test.js new file mode 100644 index 000000000..4fffc1eed --- /dev/null +++ b/core/test/project-github-connections.test.js @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createGithubConnections, projectGithubSettings } from '../src/host/project-github-connections.js' +function fixture() { + const project = { id: 'p', repository: 'example/demo' }, actor = { memberId: 'owner' } + const settings = { credential: 'github-app', appId: 7, installationId: 12, privateKeyFile: '/private/test.pem', projectIds: ['p'], repositories: ['example/demo'] } + let fail = false, calls = 0, during = () => {} + const rt = { state: {}, config: { projectGithubConnections: { app: settings }, projectRepositories: { 'example/demo': { github: { credential: 'gh-cli', configDir: '/owner' } } } }, requireProjectAccess(a, id, permission) { assert.equal(id, 'p'); if (permission === 'configure' && a.memberId !== 'owner') throw Error('Forbidden'); return project }, save: async () => { if (fail) throw Error('Disk failure') }, validateHumanActor: async () => true } + const service = createGithubConnections(rt, { identity: async () => { calls++; during(); return { provider: 'github-app', appId: 7, installationId: 12, login: 'test[bot]', token: 'PRIVATE-SENTINEL' } } }) + const configure = (body, a = actor) => service.endpoint('configure', { projectId: 'p', connectionId: 'app', expectedRevision: 0, requestId: 'select', ...body }, a) + return { rt, project, service, configure, settings, calls: () => calls, fail() { fail = true }, during(fn) { during = fn } } +} +test('human selection binds repository/install identity; disable and changed selection never fall back', async () => { + const f = fixture() + const result = await f.configure({}) + assert.equal(result.connection.provider, 'github-app'); assert.equal(result.connection.revision, 1) + assert.equal(projectGithubSettings(f.rt, f.project).installationId, 12) + f.settings.privateKeyFile = '/private/rotated.pem' + assert.equal(projectGithubSettings(f.rt, f.project).privateKeyFile, '/private/rotated.pem') + assert(!JSON.stringify(result).includes('/private/')); assert(!JSON.stringify(f.project).includes('SENTINEL')) + f.settings.installationId = 99 + assert.throws(() => projectGithubSettings(f.rt, f.project), /changed/) + await f.configure({ expectedRevision: 1, connectionId: null }) + assert.throws(() => projectGithubSettings(f.rt, f.project), /disabled/) + assert.equal((await f.service.endpoint('status', { projectId: 'p' }, { memberId: 'member' })).connection.configured, false) +}) +test('models, external full-watch agents, foreign projects/repositories and stale revisions cannot select credentials', async () => { + const f = fixture() + for (const extra of [{ agentId: 'a' }, { watchGrantId: 'w' }, { personalAgentCredentialId: 'c' }, { chiefJobId: 'j' }]) await assert.rejects(f.configure({}, { memberId: 'owner', ...extra }), /human/) + await assert.rejects(f.configure({}, { memberId: 'member' }), /Forbidden/) + await assert.rejects(f.configure({ repository: 'other/repo' }), /associated/) + f.settings.repositories = ['other/repo']; await assert.rejects(f.configure({}), /provisioned/) + assert.equal(f.calls(), 0) +}) +test('failed save and mid-verification change cannot commit selection', async () => { + const f = fixture(); f.fail(); await assert.rejects(f.configure({}), /Disk/) + assert.equal(f.project.githubConnections['example/demo'], undefined) + const g = fixture(); g.during(() => { g.settings.installationId = 99 }); await assert.rejects(g.configure({}), /changed/) + assert.equal(g.project.githubConnections, undefined) +}) + +test('real runtime v1 receipts persist human connection selection and survive restart without credentials', async t => { + const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import('node:fs') + const { tmpdir } = await import('node:os'), { join } = await import('node:path') + const { createRuntime } = await import('../index.js'), { fakeContext } = await import('./fake-ctx.js') + const home = mkdtempSync(join(tmpdir(), 'flybot-app-selection-')) + const config = { home, projectWorkbench: true, projectGithubConnections: { app: { credential: 'github-app', appId: 7, installationId: 12, privateKeyFile: '/private/not-read.pem', projectIds: ['p'], repositories: ['org/repo'] } } } + writeFileSync(join(home, 'state.json'), JSON.stringify({ version: 2, members: { 'owner-1': { id: 'owner-1' }, member: { id: 'member' } }, projects: { p: { id: 'p', ownerUserId: 'owner-1', repository: 'org/repo', status: 'active', roles: { maintainers: [], members: ['member'] } } }, bots: {}, rooms: {}, mainBotIds: [], tasks: {} })) + const contexts = []; t.after(async () => { for (const ctx of contexts) await ctx.dispose(); rmSync(home, { recursive: true, force: true }) }) + const ctx = fakeContext(); contexts.push(ctx); const rt = createRuntime(ctx, config); await rt.load() + let calls = 0 + rt.projectGithubConnections = createGithubConnections(rt, { identity: async () => { calls++; return { provider: 'github-app', appId: 7, installationId: 12, login: 'app[bot]', token: 'PRIVATE-INSTALLATION-SENTINEL' } } }) + const body = { version: 'project-development/v1', kind: 'command', projectId: 'p', name: 'github.connection.configure', requestId: 'select-one', payload: { connectionId: 'app', expectedRevision: 0 } } + const result = await rt.projectEndpoint('project-development', body, { memberId: 'owner-1' }) + assert.equal(result.result.connection.revision, 1) + assert.equal((await rt.projectEndpoint('project-development', body, { memberId: 'owner-1' })).replayed, true); assert.equal(calls, 1) + await assert.rejects(rt.projectEndpoint('project-development', { ...body, requestId: 'member-try' }, { memberId: 'member' }), /denied/) + const stored = readFileSync(join(home, 'state.json'), 'utf8'); assert(!stored.includes('SENTINEL')); assert(!stored.includes('not-read.pem')) + await ctx.dispose(); contexts.pop() + const next = fakeContext(); contexts.push(next); const restarted = createRuntime(next, config); await restarted.load() + assert.equal(projectGithubSettings(restarted, restarted.requireProjectAccess({ memberId: 'owner-1' }, 'p')).installationId, 12) + const status = await restarted.projectEndpoint('project-development', { version: 'project-development/v1', kind: 'query', projectId: 'p', name: 'github.connection', payload: {} }, { memberId: 'member' }) + assert.equal(status.result.connection.identity.installationId, 12) +}) diff --git a/core/test/project-github-publish.test.js b/core/test/project-github-publish.test.js index f937b96fe..ee9f80152 100644 --- a/core/test/project-github-publish.test.js +++ b/core/test/project-github-publish.test.js @@ -3,10 +3,11 @@ import { execFileSync } from 'node:child_process' import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { generateKeyPairSync } from 'node:crypto' import { test } from 'node:test' import { githubPublisher } from '../src/host/project-github.js' const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() -function fixture(t) { +function fixture(t, provider = 'gh-cli') { const path = mkdtempSync(join(tmpdir(), 'flybot-publish-')) git(path, 'init', '-b', 'main'); git(path, 'config', 'user.name', 'Fixture'); git(path, 'config', 'user.email', 'fixture@localhost') writeFileSync(join(path, 'code.txt'), 'first'); git(path, 'add', '.'); git(path, 'commit', '-m', 'Published candidate') @@ -19,24 +20,29 @@ function fixture(t) { let override = {}, postPushOverride = {}, beforePush = () => {}, upstream = previous, unbound = [], loseReply = false const request = async (url, options = {}) => { calls.push({ url, method: options.method ?? 'GET' }) + if (url.endsWith('/installation')) return Response.json({ id: 12, app_id: 7, app_slug: 'automation', account: { login: 'example' }, suspended_at: null, permissions: { contents: 'write', pull_requests: 'write' } }) + if (url.endsWith('/access_tokens')) return Response.json({ token: 'ghs_7_fixture.token', expires_at: new Date(Date.now() + 3600000).toISOString(), permissions: JSON.parse(options.body).permissions, repositories: [{ full_name: 'example/demo' }] }) if (url.endsWith('/user')) return Response.json({ login: 'automation' }) if (options.method === 'POST') { creates++ - const pr = { number: 99, state: 'open', head: { sha: candidate }, html_url: 'https://example.invalid/pr/99', body: JSON.parse(options.body).body } + const pr = { user: { login: 'automation[bot]' }, number: 99, state: 'open', head: { sha: candidate }, html_url: 'https://example.invalid/pr/99', body: JSON.parse(options.body).body } unbound = [pr] if (loseReply) { loseReply = false; throw Error('Lost response after PR creation') } return Response.json(pr) } if (url.includes('/git/ref/heads/')) return Response.json({ object: { type: 'commit', sha: upstream } }) if (url.includes('/pulls?')) return Response.json(unbound) - return Response.json({ number: 48, state: 'open', draft: true, base: { ref: 'main', sha: 'f'.repeat(40), repo: { full_name: 'example/demo' } }, head: { ref: 'existing', repo: { full_name: 'example/demo' }, sha: git(path, '--git-dir', remote, 'rev-parse', 'existing') }, html_url: 'https://example.invalid/pr/48', ...(pushes ? postPushOverride : override) }) + return Response.json({ user: { login: 'automation[bot]' }, number: 48, state: 'open', draft: true, base: { ref: 'main', sha: 'f'.repeat(40), repo: { full_name: 'example/demo' } }, head: { ref: 'existing', repo: { full_name: 'example/demo' }, sha: git(path, '--git-dir', remote, 'rev-parse', 'existing') }, html_url: 'https://example.invalid/pr/48', ...(pushes ? postPushOverride : override) }) } const run = async (command, args, options) => { if (command === 'gh') return { stdout: 'fixture-token' } if (args.includes('push')) { beforePush(); pushes++; const rewritten = args.map(arg => arg.startsWith('https://github.com/') ? remote : arg); return { stdout: git(options.cwd, ...rewritten) } } return { stdout: git(options.cwd, ...args) } } - const publisher = githubPublisher({ credential: 'gh-cli', configDir: '/fixture/explicit-gh', login: 'automation' }, { request, run }) + const keyFile = join(path, 'app.pem') + if (provider === 'github-app') writeFileSync(keyFile, generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 }) + const settings = provider === 'github-app' ? { credential: 'github-app', appId: 7, installationId: 12, privateKeyFile: keyFile } : { credential: 'gh-cli', configDir: '/fixture/explicit-gh', login: 'automation' } + const publisher = githubPublisher(settings, { request, run }) t.after(() => rmSync(path, { recursive: true, force: true })) return { publisher, attempt, previous, candidate, remote, path, calls, counters: () => ({ pushes, creates }), override(value) { override = value }, postPushOverride(value) { postPushOverride = value }, beforePush(value) { beforePush = value }, upstream(value) { upstream = value }, loseReply() { loseReply = true }, unbound(value) { unbound = value } } } @@ -92,7 +98,7 @@ test('first-PR response loss reuses its marker/branch receipt; closed or changed const f = fixture(t); delete f.attempt.publicationBinding f.attempt.publicationEffectId = 'effect-stable' f.loseReply() - await assert.rejects(f.publisher.publish(f.attempt, { repository: 'example/demo', defaultBranch: 'main' }), /Lost response/) + await assert.rejects(f.publisher.publish(f.attempt, { repository: 'example/demo', defaultBranch: 'main' }), /outcome is unavailable/) assert.equal(f.counters().creates, 1) const recovered = await f.publisher.publish(f.attempt, { repository: 'example/demo', defaultBranch: 'main' }) assert.equal(recovered.number, 99) @@ -151,3 +157,20 @@ test('same-PR continuation requires trusted upstream ancestry and rechecks targe await assert.rejects(f.publisher.publish(f.attempt, { repository: 'example/demo' }), /upstream changed during publication/) assert.deepEqual(f.counters(), { pushes: 1, creates: 0 }) }) + +for (const provider of ['gh-cli', 'github-app']) test(provider + ' shares push, existing PR, read and lost-create-response reconciliation', async t => { + const f = fixture(t, provider) + const pr = await f.publisher.publish(f.attempt, { repository: 'example/demo' }) + assert.equal(pr.identity.provider, provider); assert.equal(pr.sha, f.candidate) + assert.equal((await f.publisher.read(pr)).sha, f.candidate) + delete f.attempt.publicationBinding; f.loseReply() + await assert.rejects(f.publisher.publish(f.attempt, { repository: 'example/demo' }), /outcome is unavailable/) + assert.equal((await f.publisher.publish(f.attempt, { repository: 'example/demo' })).number, 99) + assert.equal(f.counters().creates, 1) +}) + +test('App publication rejects a different PR creator before any push', async t => { + const f = fixture(t, 'github-app'); f.override({ user: { login: 'another-app[bot]' } }) + await assert.rejects(f.publisher.publish(f.attempt, { repository: 'example/demo' }), /creator does not match/) + assert.deepEqual(f.counters(), { pushes: 0, creates: 0 }) +}) diff --git a/core/test/project-github-read.test.js b/core/test/project-github-read.test.js index 1f6d8b502..bf2f54d24 100644 --- a/core/test/project-github-read.test.js +++ b/core/test/project-github-read.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' import { test } from 'node:test' +import { appFixture } from './fixtures/github-app.mjs' import { createProjectGithubRead } from '../src/host/project-github-read.js' function setup() { const calls = [] @@ -103,3 +104,15 @@ test('a mutable Project cannot retarget its captured repository while credential await assert.rejects(read({projectId:'p'},{memberId:'owner'}),/repository changed/) assert.equal(calls,0) }) + +test('App project reads use minimal scoped installation tokens and never impersonate a personal user', async t => { + let calls = 0 + const f = appFixture(t, (_url, _options, permissions) => { calls++; assert.deepEqual(permissions, { issues: 'read' }); return Response.json([{ number: 1, title: 'App issue', state: 'open' }]) }) + const rt = { state: {}, config: { projectRepositories: { 'org/repo': { github: f.settings } }, personalGithub: { alice: { p: f.settings } } }, requireProjectAccess: () => ({ id: 'p', repository: 'org/repo' }) } + const read = createProjectGithubRead(rt, { request: f.request }) + const result = await read({ projectId: 'p', operation: 'issues' }, { memberId: 'alice' }) + assert.equal(result.executorLogin, 'contract-app[bot]'); assert.equal(result.identity.installationId, 12); assert.equal(calls, 1) + assert(!JSON.stringify(result).includes('ghs_')); assert(!JSON.stringify(result).includes('.pem')) + await assert.rejects(read({ projectId: 'p', scope: 'personal' }, { memberId: 'alice' }), /independent user/) + f.suspend(); await assert.rejects(read({ projectId: 'p' }, { memberId: 'alice' }), /installation/); assert.equal(calls, 1) +}) diff --git a/core/test/project-release-github.test.js b/core/test/project-release-github.test.js index af8b751ec..e28186033 100644 --- a/core/test/project-release-github.test.js +++ b/core/test/project-release-github.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' import { test } from 'node:test' +import { appFixture } from './fixtures/github-app.mjs' import { githubReleaseProvider } from '../src/host/project-release-github.js' const sha = 'a'.repeat(40), spec = { repository: 'org/repo', tag: 'v1', sha, name: 'One', notes: 'Public notes', marker: '' } const response = (value, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => value }) @@ -42,3 +43,20 @@ test('missing artifacts, rejected POSTs, lost responses and malformed published const draft = githubReleaseProvider({}, { identity, request: async url => url.includes('/releases/') ? response({ id: 2, tag_name: 'v1', draft: true }) : response({ object: { type: 'commit', sha } }) }) await assert.rejects(draft.inspect(spec), /not a verifiable published commit/) }) + +test('App Release publication and observation share exact SHA and typed external-identity proofs', async t => { + let release, creates = 0 + const f = appFixture(t, (url, options, permissions) => { + assert.deepEqual(permissions, { contents: options.method === 'POST' ? 'write' : 'read' }) + if (options.method === 'POST') { const body = JSON.parse(options.body); assert.equal(body.target_commitish, sha); creates++; release = { id: 8, tag_name: 'v1', draft: false, published_at: new Date().toISOString(), body: body.body }; return Response.json(release) } + if (url.includes('/releases/tags/')) return release ? Response.json(release) : Response.json({}, { status: 404 }) + return Response.json({ object: { type: 'commit', sha } }) + }) + const provider = githubReleaseProvider(f.settings, { request: f.request }) + assert.equal(await provider.inspect(spec), null) + await provider.publish(spec) + const proof = await provider.inspect(spec) + assert.equal(proof.sha, sha); assert.equal(proof.identity.provider, 'github-app'); assert.equal(proof.identity.installationId, 12); assert.equal(proof.executorLogin, 'contract-app[bot]'); assert.equal(creates, 1) + assert(!JSON.stringify(proof).includes('ghs_')); f.suspend() + await assert.rejects(provider.publish(spec), /installation/); assert.equal(creates, 1) +}) diff --git a/docs/backend-operations.md b/docs/backend-operations.md index ceebe9d6f..ceb6be1f6 100644 --- a/docs/backend-operations.md +++ b/docs/backend-operations.md @@ -108,3 +108,5 @@ JSON/SQLite回退保留新代码格式守卫、ID和已完成Effects,**不是 - 上下文接近预算:用限定来源分页/摘要/长期记忆引用;同Task打回恢复原角色Session,不用“重新开一个”绕过限制。 [测试分层](testing-layers.md)区分单元、真实DSH/本机HTTP持久化、真实SDK与完整模型开发闭环。确定性测试供应商只出现在测试里,启动工具不创建mock模型或样例仓库。 + +项目自动化可选择 gh-cli 或 App installation,个人操作保留独立用户授权;配置步骤、最小权限和恢复方法见 [GitHub自动化](github-automation.md)。 diff --git a/docs/development-service-api.md b/docs/development-service-api.md index ddbf88dfb..4fbdf362b 100644 --- a/docs/development-service-api.md +++ b/docs/development-service-api.md @@ -5,7 +5,7 @@ [OpenAPI 3.1 JSON](openapi/flybot.openapi.json) 给出全部公开分派请求/响应 Schema、逐操作权限、 状态影响和前置条件;第三方个人代理的凭据签发、登录和自主协作见[接入指南](third-party-bot-integration.md)。 -本页对应当前分支实际路由:**28个v1查询、33个v1命令**,以及对象管理使用的原生接口。 +本页对应当前分支实际路由:**29个v1查询、34个v1命令**,以及对象管理使用的原生接口。 咨询和Task主动权交接是两种契约;角色执行回调不是公开HTTP命令。没有Swagger页面;以下请求均可由CLI或HTTP调用。 ## 传输、身份与统一返回 @@ -105,6 +105,7 @@ SDK角色worker使用宿主内部绑定,不可拿“Dev”字符串调用角 | 查询名 | payload | result / 权限与分页 | | --- | --- | --- | +| github.connection | repository? | 已选项目自动化连接、revision、配置状态和已授权选项;不返回凭据或私钥路径 | | inbox.list | agentId?、limit?:1..100、after? | 本人消息元数据/通知/处理状态;私人,不进入公开SSE | | inbox.get | messageId、agentId?、offset?、limit?:1..4000、expectedSha256? | 本人咨询正文源页;UTF-16偏移,含message元数据 | | inbox.connection | agentId? | 本人通知连接非敏感状态 | @@ -144,6 +145,7 @@ Worker已登记不等于已运行;进程或Bot消失不等于退出;角色 | 命令名 | payload主要必填字段(其余可选字段见对应子系统) | 结果与权限 | | --- | --- | --- | +| github.connection.configure | connectionId:string/null、expectedRevision;repository? | 人类Owner/Maintainer选择已预置连接;只读认证后持久化,null显式禁用且不回退 | | inbox.claim | messageId;agentId?、expectedVersion? | 本人有效参与;领取消息租约,不取得Task主动权,安全代理可用 | | inbox.renew | messageId、claimId、generation | 原身份当前有效租约续期;安全代理可用 | | inbox.complete | messageId、claimId、generation;text/publish或acknowledge | 同身份/代次/源版本;安全建议等待人类审批,watch授权后显式发布原Task输入 | @@ -244,3 +246,5 @@ project-agent-history保留messages字段,默认最近50条,limit为1..100 | release.status | query;attemptId | 项目读;返回状态、目标、已观察证据,不调用模型或外部发布 | 原生对应project-release-request / project-release-reconcile / project-release-status。需显式配置仓库release.provider=github、release.enabled=true及已有选定GitHub凭据;创建请求不等于发布成功,发布成功也不等于Task关闭。Chief仍需当前外部证明及原验收门禁。详见[契约、配置与例子](subsystems/project-releases.md)。测试不会创建真实Release。 + +GitHub App 的操作权限、Token生命周期、故障恢复及审计见 [GitHub自动化](github-automation.md)。连接选择与Task执行权限独立,代理不得替自己更换凭据。 diff --git a/docs/github-automation.md b/docs/github-automation.md new file mode 100644 index 000000000..a6314fd9c --- /dev/null +++ b/docs/github-automation.md @@ -0,0 +1,141 @@ +# Project GitHub automation + +FlyBot supports `gh-cli` and GitHub App installation credentials through the same +host brokers, Effects, candidate checks and recovery records. Personal comments +and personal reads retain independent user credentials. Installing FlyBot enables +neither external writes nor a GitHub App. No ambient/Owner credential fallback exists. + +## Operator provisioning + +Existing `projectRepositories["org/repo"].github` settings continue to work: + +```json +{"credential":"gh-cli","configDir":"/private/project-gh","login":"automation-login"} +``` + +An App can be provisioned in that same private operator configuration location, +or as a named connection that human project managers can select: + +```json +{ + "projectGithubConnections": { + "team-app": { + "credential": "github-app", + "appId": 12345, + "installationId": 67890, + "privateKeyFile": "/private/flybot-app.pem", + "projectIds": ["PROJECT_ID"], + "repositories": ["org/repo"] + } + } +} +``` + +IDs are positive integers. The RSA private key must be at least 2048 bits, in an +absolute, current-service-user-owned regular file, mode0600, at most16KiB; symlinks +are refused. Store it outside repositories/workspaces and grant the service only +the required access. App registration, installation approval, repository scope and +key rotation are operator/human actions; neither model nor FBA Token can perform them. +The App slug is verified through the installation and recorded as `slug[bot]`; +optional `login` pins that expected login. Multiple projects may share an App, +but each connection must explicitly list each permitted project and repository. +App IDs are identities, not secrets; keys/JWTs/installation Tokens remain private. + +Required repository permissions depend on the operation: + +| Broker operation | Token permissions | +| --- | --- | +| Select/verify connection; Release observation | contents:read | +| Push candidate and create/reuse PR | contents:write, pull_requests:write | +| Read PR/publication upstream | contents:read, pull_requests:read | +| List issues / issue thread | issues:read | +| List/read PR, Reviews | pull_requests:read | +| Read PR checks | pull_requests:read, checks:read | +| Pull thread including comments, Reviews, checks | pull_requests:read, issues:read, checks:read | +| Explicit Release publication | contents:write | + +GitHub's implicit metadata permission also applies. Grant only operations the +project needs. Workflow-file updates may require GitHub permissions beyond this +first provider's fixed operations; such writes fail closed rather than escalating. + +## Human selection through the existing service + +Use the normal login and `/api/bot` `project-development` transport documented in +[API reference](development-service-api.md). Query `github.connection` with optional +`repository` (default project repository). It reports non-sensitive connection +options, selected revision and configuration status; it does not perform a probe. +An Owner/Maintainer personally selects an already provisioned connection: + +```json +{"version":"project-development/v1","kind":"command","name":"github.connection.configure","projectId":"PROJECT_ID","requestId":"select-team-app-1","payload":{"connectionId":"team-app","expectedRevision":0}} +``` + +The command verifies the installation/login using a read-only operation, rechecks +human authority and project/repository binding, and commits the selection with +original Effects and a normal command receipt. Repeat identical requests with the +same ID after a lost response; a later selection requires the returned revision. +`connectionId:null` explicitly disables project automation for that repository; +it never falls back to the legacy profile. Bots, including full-watch agents and +Chief, cannot select, replace or expand credentials. Normal bot operation still +uses their current Project/Task rights. There is no new control-plane/UI requirement. + +Legacy repository settings apply only until a human makes an explicit selection. +One Project may have different selected connections for associated repositories. +Changing an installation/App identity requires human verification again. Removing +an allowed project/repository, disabling the connection, or disabling automation +immediately causes subsequent broker operations to refuse. + +## Token lifecycle and failures + +The host signs RS256 JWTs (iat60s in the past; exp9min in the future), verifies the +current repository installation, App ID, installation ID, suspension, account and +operation permissions, and requests a Token restricted to that single repository +and required permissions. Installation authentication never uses `/user`. + +Tokens stay in a bounded in-process cache, refresh60s before expiry, and merge +concurrent refreshes. They are never persisted. Restart remints; rotating the +private key changes the cache key while retaining the logical operation identity. +Token formats containing dots are supported. Every operation verifies the current +installation even on a cache hit. Authentication refusals invalidate cached Tokens; +no external write is automatically retried under another credential. + +Status distinguishes configured settings from per-operation verification. On an +installation/key/scope failure, an authorized human checks `github.connection` and +the operator repairs the installation, permissions or private file. Re-verify a +changed connection with a new command/revision. Do not replace an unresolved PR or +Release intent with a new request ID: inspect its original Effect/receipt and +external object first. An identity change stops the old operation for reconciliation. + +## Identity, audit and recovery + +Publication pins the logical provider, installation/profile, repository and human +selection revision before external I/O. Token refresh and same-App key rotation do +not change that identity. Provider/installation changes cancel old work without +silently rewriting its PR or discarding candidate/test/Review evidence. Existing +PR branch/head/upstream/ancestry checks, unknown-result reconciliation and Release +submission fencing remain authoritative. No merge or repository gate is bypassed. + +PR receipts include provider/login, App/installation IDs, credential fingerprint, +repository, Task/Attempt/candidate SHA, initiator and host publication role. Release +records retain authorizing human/agent, Task/Attempt, pinned SHA and typed external +identity proof. Reads report the actual selected identity. No audit field contains +credentials. Current personal comment audit retains its independent user identity. + +Host-created local/resource Git commits keep `FlyBot project automation +` as author/committer. Push, PR and Release identity is the +verified gh login or installation bot; these are separate facts. One App is one +GitHub identity, regardless of FlyBot Dev/Reviewer/Tester roles. It does not supply +independent GitHub approvals or waive FlyBot role Review, Chief adjudication or +repository protection. + +## Validation scope + +Regression coverage uses real RSA JWT verification, isolated private key files, +local Git pushes and deterministic GitHub responses. Both providers exercise +publication/read/reuse/lost-response contracts. Real FlyBot runtime receipts and +restart preserve selections without persisting credentials. This is not an actual +GitHub App installation/write acceptance: that requires separately authorized test +App/repository resources. Existing operator credentials and live services are untouched. + +References: [JWT authentication](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app), +[installation Tokens](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app). diff --git a/docs/openapi/flybot.openapi.json b/docs/openapi/flybot.openapi.json index 5fb10024a..cf7874019 100644 --- a/docs/openapi/flybot.openapi.json +++ b/docs/openapi/flybot.openapi.json @@ -1404,7 +1404,14 @@ "$ref": "#/components/schemas/JsonValue" }, "pr": { - "$ref": "#/components/schemas/JsonValue" + "anyOf": [ + { + "$ref": "#/components/schemas/GithubPublication" + }, + { + "type": "null" + } + ] }, "release": { "$ref": "#/components/schemas/JsonValue" @@ -2475,6 +2482,9 @@ "executorLogin": { "type": "string" }, + "identity": { + "$ref": "#/components/schemas/GithubAutomationIdentity" + }, "marker": { "type": "boolean" } @@ -2535,6 +2545,45 @@ "maxLength": 200, "description": "Opaque server-issued ID; never infer authority from it." }, + "authorizedAgentId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] + }, + "taskId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] + }, + "attemptId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] + }, "createdAt": { "type": "integer", "minimum": 0 @@ -3602,6 +3651,153 @@ } ] }, + "GithubAutomationIdentity": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "gh-cli", + "github-app" + ] + }, + "login": { + "type": "string" + }, + "appId": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "installationId": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "credentialIdentity": { + "anyOf": [ + { + "type": "string", + "maxLength": 64 + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true, + "required": [ + "provider", + "login", + "appId", + "installationId", + "credentialIdentity" + ] + }, + "GithubPublication": { + "type": "object", + "properties": { + "number": { + "type": "integer", + "minimum": 1 + }, + "url": { + "type": "string" + }, + "sha": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", + "maxLength": 200 + }, + "executorLogin": { + "type": "string" + }, + "credentialScope": { + "const": "project-automation", + "type": "string" + }, + "identity": { + "$ref": "#/components/schemas/GithubAutomationIdentity" + }, + "initiatedBy": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] + }, + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "attemptId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] + }, + "candidateSha": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "operation": { + "const": "pull-request-publish", + "type": "string" + }, + "executionRole": { + "const": "project-delivery-host", + "type": "string" + }, + "creatorLogin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true, + "required": [ + "number", + "sha", + "repository" + ] + }, "Delegation": { "type": "object", "properties": { @@ -4028,19 +4224,18 @@ "value" ] }, - "DevelopmentQueryInboxConnectionPayload": { + "DevelopmentQueryGithubConnectionPayload": { "type": "object", "properties": { - "agentId": { + "repository": { "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Opaque server-issued ID; never infer authority from it." + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", + "maxLength": 200 } }, "additionalProperties": false }, - "DevelopmentQueryInboxConnectionRequest": { + "DevelopmentQueryGithubConnectionRequest": { "type": "object", "properties": { "endpoint": { @@ -4059,7 +4254,7 @@ "type": "string" }, "name": { - "const": "inbox.connection", + "const": "github.connection", "type": "string" }, "projectId": { @@ -4069,7 +4264,7 @@ "description": "Opaque server-issued ID; never infer authority from it." }, "payload": { - "$ref": "#/components/schemas/DevelopmentQueryInboxConnectionPayload" + "$ref": "#/components/schemas/DevelopmentQueryGithubConnectionPayload" }, "expectedGeneration": { "type": "integer", @@ -4110,17 +4305,181 @@ "payload" ] }, - "DevelopmentQueryInboxConnectionResult": { + "DevelopmentQueryGithubConnectionResult": { "type": "object", "properties": { "connection": { - "anyOf": [ - { - "$ref": "#/components/schemas/InboxConnection" + "type": "object", + "properties": { + "configured": { + "type": "boolean" }, - { - "type": "null" + "provider": { + "anyOf": [ + { + "type": "string", + "enum": [ + "gh-cli", + "github-app" + ] + }, + { + "type": "null" + } + ] + }, + "login": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "appId": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "installationId": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "verification": { + "type": "string" + }, + "recovery": { + "type": "string" + }, + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", + "maxLength": 200 + }, + "connectionId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] + }, + "revision": { + "type": "integer", + "minimum": 0 + }, + "verifiedAt": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "identity": { + "anyOf": [ + { + "$ref": "#/components/schemas/GithubAutomationIdentity" + }, + { + "type": "null" + } + ] + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "provider": { + "type": "string", + "enum": [ + "gh-cli", + "github-app" + ] + }, + "appId": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "installationId": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "login": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true, + "required": [ + "id", + "provider", + "appId", + "installationId", + "login" + ] + }, + "maxItems": 100 } + }, + "additionalProperties": true, + "required": [ + "repository", + "connectionId", + "revision", + "configured", + "provider", + "options" ] } }, @@ -4129,7 +4488,7 @@ "connection" ] }, - "DevelopmentQueryInboxConnectionSuccess": { + "DevelopmentQueryGithubConnectionSuccess": { "type": "object", "properties": { "ok": { @@ -4148,7 +4507,7 @@ "pattern": "^[A-Za-z0-9_-]{1,100}$" }, "result": { - "$ref": "#/components/schemas/DevelopmentQueryInboxConnectionResult" + "$ref": "#/components/schemas/DevelopmentQueryGithubConnectionResult" }, "receipt": { "$ref": "#/components/schemas/CommandReceipt" @@ -4173,24 +4532,19 @@ "value" ] }, - "DevelopmentQueryTasksPayload": { + "DevelopmentQueryInboxConnectionPayload": { "type": "object", "properties": { - "after": { + "agentId": { "type": "string", "minLength": 1, "maxLength": 200, "description": "Opaque server-issued ID; never infer authority from it." - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200 } }, "additionalProperties": false }, - "DevelopmentQueryTasksRequest": { + "DevelopmentQueryInboxConnectionRequest": { "type": "object", "properties": { "endpoint": { @@ -4209,7 +4563,7 @@ "type": "string" }, "name": { - "const": "tasks", + "const": "inbox.connection", "type": "string" }, "projectId": { @@ -4219,7 +4573,7 @@ "description": "Opaque server-issued ID; never infer authority from it." }, "payload": { - "$ref": "#/components/schemas/DevelopmentQueryTasksPayload" + "$ref": "#/components/schemas/DevelopmentQueryInboxConnectionPayload" }, "expectedGeneration": { "type": "integer", @@ -4260,22 +4614,13 @@ "payload" ] }, - "DevelopmentQueryTasksResult": { + "DevelopmentQueryInboxConnectionResult": { "type": "object", "properties": { - "tasks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TaskView" - } - }, - "next": { + "connection": { "anyOf": [ { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Opaque server-issued ID; never infer authority from it." + "$ref": "#/components/schemas/InboxConnection" }, { "type": "null" @@ -4285,11 +4630,10 @@ }, "additionalProperties": true, "required": [ - "tasks", - "next" + "connection" ] }, - "DevelopmentQueryTasksSuccess": { + "DevelopmentQueryInboxConnectionSuccess": { "type": "object", "properties": { "ok": { @@ -4308,7 +4652,7 @@ "pattern": "^[A-Za-z0-9_-]{1,100}$" }, "result": { - "$ref": "#/components/schemas/DevelopmentQueryTasksResult" + "$ref": "#/components/schemas/DevelopmentQueryInboxConnectionResult" }, "receipt": { "$ref": "#/components/schemas/CommandReceipt" @@ -4333,22 +4677,24 @@ "value" ] }, - "DevelopmentQueryTaskPayload": { + "DevelopmentQueryTasksPayload": { "type": "object", "properties": { - "taskId": { + "after": { "type": "string", "minLength": 1, "maxLength": 200, "description": "Opaque server-issued ID; never infer authority from it." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200 } }, - "additionalProperties": false, - "required": [ - "taskId" - ] + "additionalProperties": false }, - "DevelopmentQueryTaskRequest": { + "DevelopmentQueryTasksRequest": { "type": "object", "properties": { "endpoint": { @@ -4367,7 +4713,7 @@ "type": "string" }, "name": { - "const": "task", + "const": "tasks", "type": "string" }, "projectId": { @@ -4377,7 +4723,7 @@ "description": "Opaque server-issued ID; never infer authority from it." }, "payload": { - "$ref": "#/components/schemas/DevelopmentQueryTaskPayload" + "$ref": "#/components/schemas/DevelopmentQueryTasksPayload" }, "expectedGeneration": { "type": "integer", @@ -4418,19 +4764,36 @@ "payload" ] }, - "DevelopmentQueryTaskResult": { + "DevelopmentQueryTasksResult": { "type": "object", "properties": { - "task": { - "$ref": "#/components/schemas/TaskView" + "tasks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskView" + } + }, + "next": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] } }, "additionalProperties": true, "required": [ - "task" + "tasks", + "next" ] }, - "DevelopmentQueryTaskSuccess": { + "DevelopmentQueryTasksSuccess": { "type": "object", "properties": { "ok": { @@ -4449,7 +4812,7 @@ "pattern": "^[A-Za-z0-9_-]{1,100}$" }, "result": { - "$ref": "#/components/schemas/DevelopmentQueryTaskResult" + "$ref": "#/components/schemas/DevelopmentQueryTasksResult" }, "receipt": { "$ref": "#/components/schemas/CommandReceipt" @@ -4474,7 +4837,7 @@ "value" ] }, - "DevelopmentQueryTaskDetailPayload": { + "DevelopmentQueryTaskPayload": { "type": "object", "properties": { "taskId": { @@ -4482,17 +4845,6 @@ "minLength": 1, "maxLength": 200, "description": "Opaque server-issued ID; never infer authority from it." - }, - "attemptLimit": { - "type": "integer", - "minimum": 1, - "maximum": 50 - }, - "beforeAttemptId": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Opaque server-issued ID; never infer authority from it." } }, "additionalProperties": false, @@ -4500,7 +4852,7 @@ "taskId" ] }, - "DevelopmentQueryTaskDetailRequest": { + "DevelopmentQueryTaskRequest": { "type": "object", "properties": { "endpoint": { @@ -4519,7 +4871,7 @@ "type": "string" }, "name": { - "const": "task.detail", + "const": "task", "type": "string" }, "projectId": { @@ -4529,7 +4881,7 @@ "description": "Opaque server-issued ID; never infer authority from it." }, "payload": { - "$ref": "#/components/schemas/DevelopmentQueryTaskDetailPayload" + "$ref": "#/components/schemas/DevelopmentQueryTaskPayload" }, "expectedGeneration": { "type": "integer", @@ -4570,64 +4922,19 @@ "payload" ] }, - "DevelopmentQueryTaskDetailResult": { + "DevelopmentQueryTaskResult": { "type": "object", "properties": { "task": { - "$ref": "#/components/schemas/TaskRecord" - }, - "view": { "$ref": "#/components/schemas/TaskView" - }, - "attempts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Attempt" - } - }, - "attemptsPage": { - "type": "object", - "properties": { - "total": { - "type": "integer", - "minimum": 0 - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 50 - }, - "before": { - "anyOf": [ - { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Opaque server-issued ID; never infer authority from it." - }, - { - "type": "null" - } - ] - } - }, - "additionalProperties": false, - "required": [ - "total", - "limit", - "before" - ] } }, "additionalProperties": true, "required": [ - "task", - "view", - "attempts", - "attemptsPage" + "task" ] }, - "DevelopmentQueryTaskDetailSuccess": { + "DevelopmentQueryTaskSuccess": { "type": "object", "properties": { "ok": { @@ -4646,7 +4953,7 @@ "pattern": "^[A-Za-z0-9_-]{1,100}$" }, "result": { - "$ref": "#/components/schemas/DevelopmentQueryTaskDetailResult" + "$ref": "#/components/schemas/DevelopmentQueryTaskResult" }, "receipt": { "$ref": "#/components/schemas/CommandReceipt" @@ -4671,7 +4978,7 @@ "value" ] }, - "DevelopmentQueryTaskHistoryPayload": { + "DevelopmentQueryTaskDetailPayload": { "type": "object", "properties": { "taskId": { @@ -4680,42 +4987,24 @@ "maxLength": 200, "description": "Opaque server-issued ID; never infer authority from it." }, - "section": { - "type": "string", - "enum": [ - "requirements", - "requirementsHistory", - "waitHistory", - "results", - "dependencies", - "admissionDecisions", - "note", - "result", - "actors", - "handoffs" - ] - }, - "offset": { - "type": "integer", - "minimum": 0 - }, - "limit": { + "attemptLimit": { "type": "integer", "minimum": 1, - "maximum": 16000 + "maximum": 50 }, - "expectedSha256": { + "beforeAttemptId": { "type": "string", - "pattern": "^[a-f0-9]{64}$" + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." } }, "additionalProperties": false, "required": [ - "taskId", - "section" + "taskId" ] }, - "DevelopmentQueryTaskHistoryRequest": { + "DevelopmentQueryTaskDetailRequest": { "type": "object", "properties": { "endpoint": { @@ -4734,7 +5023,7 @@ "type": "string" }, "name": { - "const": "task.history", + "const": "task.detail", "type": "string" }, "projectId": { @@ -4744,7 +5033,7 @@ "description": "Opaque server-issued ID; never infer authority from it." }, "payload": { - "$ref": "#/components/schemas/DevelopmentQueryTaskHistoryPayload" + "$ref": "#/components/schemas/DevelopmentQueryTaskDetailPayload" }, "expectedGeneration": { "type": "integer", @@ -4785,10 +5074,64 @@ "payload" ] }, - "DevelopmentQueryTaskHistoryResult": { - "$ref": "#/components/schemas/SourcePage" + "DevelopmentQueryTaskDetailResult": { + "type": "object", + "properties": { + "task": { + "$ref": "#/components/schemas/TaskRecord" + }, + "view": { + "$ref": "#/components/schemas/TaskView" + }, + "attempts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Attempt" + } + }, + "attemptsPage": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "before": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "required": [ + "total", + "limit", + "before" + ] + } + }, + "additionalProperties": true, + "required": [ + "task", + "view", + "attempts", + "attemptsPage" + ] }, - "DevelopmentQueryTaskHistorySuccess": { + "DevelopmentQueryTaskDetailSuccess": { "type": "object", "properties": { "ok": { @@ -4807,7 +5150,7 @@ "pattern": "^[A-Za-z0-9_-]{1,100}$" }, "result": { - "$ref": "#/components/schemas/DevelopmentQueryTaskHistoryResult" + "$ref": "#/components/schemas/DevelopmentQueryTaskDetailResult" }, "receipt": { "$ref": "#/components/schemas/CommandReceipt" @@ -4832,12 +5175,29 @@ "value" ] }, - "DevelopmentQueryRolesPayload": { + "DevelopmentQueryTaskHistoryPayload": { "type": "object", "properties": { - "role": { + "taskId": { "type": "string", - "pattern": "^[a-z][a-z0-9-]{0,31}$" + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "section": { + "type": "string", + "enum": [ + "requirements", + "requirementsHistory", + "waitHistory", + "results", + "dependencies", + "admissionDecisions", + "note", + "result", + "actors", + "handoffs" + ] }, "offset": { "type": "integer", @@ -4853,9 +5213,13 @@ "pattern": "^[a-f0-9]{64}$" } }, - "additionalProperties": false + "additionalProperties": false, + "required": [ + "taskId", + "section" + ] }, - "DevelopmentQueryRolesRequest": { + "DevelopmentQueryTaskHistoryRequest": { "type": "object", "properties": { "endpoint": { @@ -4874,7 +5238,7 @@ "type": "string" }, "name": { - "const": "roles", + "const": "task.history", "type": "string" }, "projectId": { @@ -4884,7 +5248,7 @@ "description": "Opaque server-issued ID; never infer authority from it." }, "payload": { - "$ref": "#/components/schemas/DevelopmentQueryRolesPayload" + "$ref": "#/components/schemas/DevelopmentQueryTaskHistoryPayload" }, "expectedGeneration": { "type": "integer", @@ -4925,76 +5289,10 @@ "payload" ] }, - "DevelopmentQueryRolesResult": { - "anyOf": [ - { - "type": "object", - "properties": { - "projectId": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Opaque server-issued ID; never infer authority from it." - }, - "revision": { - "type": "integer", - "minimum": 0 - }, - "roles": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RoleDefinition" - } - } - }, - "additionalProperties": true, - "required": [ - "projectId", - "revision", - "roles" - ] - }, - { - "type": "object", - "properties": { - "projectId": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Opaque server-issued ID; never infer authority from it." - }, - "revision": { - "type": "integer", - "minimum": 0 - }, - "text": { - "type": "string" - }, - "sha256": { - "type": "string", - "pattern": "^[a-f0-9]{64}$" - }, - "nextOffset": { - "anyOf": [ - { - "type": "integer", - "minimum": 0 - }, - { - "type": "null" - } - ] - } - }, - "additionalProperties": true, - "required": [ - "text", - "sha256" - ] - } - ] + "DevelopmentQueryTaskHistoryResult": { + "$ref": "#/components/schemas/SourcePage" }, - "DevelopmentQueryRolesSuccess": { + "DevelopmentQueryTaskHistorySuccess": { "type": "object", "properties": { "ok": { @@ -5013,7 +5311,7 @@ "pattern": "^[A-Za-z0-9_-]{1,100}$" }, "result": { - "$ref": "#/components/schemas/DevelopmentQueryRolesResult" + "$ref": "#/components/schemas/DevelopmentQueryTaskHistoryResult" }, "receipt": { "$ref": "#/components/schemas/CommandReceipt" @@ -5038,30 +5336,30 @@ "value" ] }, - "DevelopmentQueryRoleProposalsPayload": { + "DevelopmentQueryRolesPayload": { "type": "object", "properties": { - "proposalId": { + "role": { "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Opaque server-issued ID; never infer authority from it." + "pattern": "^[a-z][a-z0-9-]{0,31}$" }, - "after": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Opaque server-issued ID; never infer authority from it." + "offset": { + "type": "integer", + "minimum": 0 }, "limit": { "type": "integer", "minimum": 1, - "maximum": 50 + "maximum": 16000 + }, + "expectedSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" } }, "additionalProperties": false }, - "DevelopmentQueryRoleProposalsRequest": { + "DevelopmentQueryRolesRequest": { "type": "object", "properties": { "endpoint": { @@ -5080,7 +5378,7 @@ "type": "string" }, "name": { - "const": "role.proposals", + "const": "roles", "type": "string" }, "projectId": { @@ -5090,7 +5388,7 @@ "description": "Opaque server-issued ID; never infer authority from it." }, "payload": { - "$ref": "#/components/schemas/DevelopmentQueryRoleProposalsPayload" + "$ref": "#/components/schemas/DevelopmentQueryRolesPayload" }, "expectedGeneration": { "type": "integer", @@ -5131,47 +5429,60 @@ "payload" ] }, - "DevelopmentQueryRoleProposalsResult": { + "DevelopmentQueryRolesResult": { "anyOf": [ { "type": "object", "properties": { - "proposals": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "revision": { + "type": "integer", + "minimum": 0 + }, + "roles": { "type": "array", "items": { - "$ref": "#/components/schemas/RoleProposal" + "$ref": "#/components/schemas/RoleDefinition" } - }, - "next": { - "anyOf": [ - { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Opaque server-issued ID; never infer authority from it." - }, - { - "type": "null" - } - ] } }, "additionalProperties": true, "required": [ - "proposals", - "next" + "projectId", + "revision", + "roles" ] }, { "type": "object", "properties": { - "proposal": { - "$ref": "#/components/schemas/RoleProposal" + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." }, - "definition": { + "revision": { + "type": "integer", + "minimum": 0 + }, + "text": { + "type": "string" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "nextOffset": { "anyOf": [ { - "$ref": "#/components/schemas/RoleDefinition" + "type": "integer", + "minimum": 0 }, { "type": "null" @@ -5181,13 +5492,13 @@ }, "additionalProperties": true, "required": [ - "proposal", - "definition" + "text", + "sha256" ] } ] }, - "DevelopmentQueryRoleProposalsSuccess": { + "DevelopmentQueryRolesSuccess": { "type": "object", "properties": { "ok": { @@ -5206,7 +5517,7 @@ "pattern": "^[A-Za-z0-9_-]{1,100}$" }, "result": { - "$ref": "#/components/schemas/DevelopmentQueryRoleProposalsResult" + "$ref": "#/components/schemas/DevelopmentQueryRolesResult" }, "receipt": { "$ref": "#/components/schemas/CommandReceipt" @@ -5231,10 +5542,10 @@ "value" ] }, - "DevelopmentQueryConsultationListPayload": { + "DevelopmentQueryRoleProposalsPayload": { "type": "object", "properties": { - "agentId": { + "proposalId": { "type": "string", "minLength": 1, "maxLength": 200, @@ -5249,12 +5560,12 @@ "limit": { "type": "integer", "minimum": 1, - "maximum": 100 + "maximum": 50 } }, "additionalProperties": false }, - "DevelopmentQueryConsultationListRequest": { + "DevelopmentQueryRoleProposalsRequest": { "type": "object", "properties": { "endpoint": { @@ -5273,7 +5584,7 @@ "type": "string" }, "name": { - "const": "consultation.list", + "const": "role.proposals", "type": "string" }, "projectId": { @@ -5283,7 +5594,7 @@ "description": "Opaque server-issued ID; never infer authority from it." }, "payload": { - "$ref": "#/components/schemas/DevelopmentQueryConsultationListPayload" + "$ref": "#/components/schemas/DevelopmentQueryRoleProposalsPayload" }, "expectedGeneration": { "type": "integer", @@ -5324,36 +5635,63 @@ "payload" ] }, - "DevelopmentQueryConsultationListResult": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Consultation" - } + "DevelopmentQueryRoleProposalsResult": { + "anyOf": [ + { + "type": "object", + "properties": { + "proposals": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RoleProposal" + } + }, + "next": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true, + "required": [ + "proposals", + "next" + ] }, - "next": { - "anyOf": [ - { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Opaque server-issued ID; never infer authority from it." + { + "type": "object", + "properties": { + "proposal": { + "$ref": "#/components/schemas/RoleProposal" }, - { - "type": "null" + "definition": { + "anyOf": [ + { + "$ref": "#/components/schemas/RoleDefinition" + }, + { + "type": "null" + } + ] } + }, + "additionalProperties": true, + "required": [ + "proposal", + "definition" ] } - }, - "additionalProperties": true, - "required": [ - "items", - "next" ] }, - "DevelopmentQueryConsultationListSuccess": { + "DevelopmentQueryRoleProposalsSuccess": { "type": "object", "properties": { "ok": { @@ -5372,7 +5710,7 @@ "pattern": "^[A-Za-z0-9_-]{1,100}$" }, "result": { - "$ref": "#/components/schemas/DevelopmentQueryConsultationListResult" + "$ref": "#/components/schemas/DevelopmentQueryRoleProposalsResult" }, "receipt": { "$ref": "#/components/schemas/CommandReceipt" @@ -5397,7 +5735,7 @@ "value" ] }, - "DevelopmentQueryConsultationGetPayload": { + "DevelopmentQueryConsultationListPayload": { "type": "object", "properties": { "agentId": { @@ -5406,28 +5744,21 @@ "maxLength": 200, "description": "Opaque server-issued ID; never infer authority from it." }, - "consultationId": { + "after": { "type": "string", "minLength": 1, "maxLength": 200, "description": "Opaque server-issued ID; never infer authority from it." }, - "offset": { - "type": "integer", - "minimum": 0 - }, "limit": { "type": "integer", "minimum": 1, "maximum": 100 } }, - "additionalProperties": false, - "required": [ - "consultationId" - ] + "additionalProperties": false }, - "DevelopmentQueryConsultationGetRequest": { + "DevelopmentQueryConsultationListRequest": { "type": "object", "properties": { "endpoint": { @@ -5446,7 +5777,7 @@ "type": "string" }, "name": { - "const": "consultation.get", + "const": "consultation.list", "type": "string" }, "projectId": { @@ -5456,7 +5787,7 @@ "description": "Opaque server-issued ID; never infer authority from it." }, "payload": { - "$ref": "#/components/schemas/DevelopmentQueryConsultationGetPayload" + "$ref": "#/components/schemas/DevelopmentQueryConsultationListPayload" }, "expectedGeneration": { "type": "integer", @@ -5497,19 +5828,36 @@ "payload" ] }, - "DevelopmentQueryConsultationGetResult": { + "DevelopmentQueryConsultationListResult": { "type": "object", "properties": { - "consultation": { - "$ref": "#/components/schemas/Consultation" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Consultation" + } + }, + "next": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] } }, "additionalProperties": true, "required": [ - "consultation" + "items", + "next" ] }, - "DevelopmentQueryConsultationGetSuccess": { + "DevelopmentQueryConsultationListSuccess": { "type": "object", "properties": { "ok": { @@ -5528,7 +5876,7 @@ "pattern": "^[A-Za-z0-9_-]{1,100}$" }, "result": { - "$ref": "#/components/schemas/DevelopmentQueryConsultationGetResult" + "$ref": "#/components/schemas/DevelopmentQueryConsultationListResult" }, "receipt": { "$ref": "#/components/schemas/CommandReceipt" @@ -5553,21 +5901,25 @@ "value" ] }, - "DevelopmentQueryHandoffListPayload": { + "DevelopmentQueryConsultationGetPayload": { "type": "object", "properties": { - "taskId": { + "agentId": { "type": "string", "minLength": 1, "maxLength": 200, "description": "Opaque server-issued ID; never infer authority from it." }, - "after": { + "consultationId": { "type": "string", "minLength": 1, "maxLength": 200, "description": "Opaque server-issued ID; never infer authority from it." }, + "offset": { + "type": "integer", + "minimum": 0 + }, "limit": { "type": "integer", "minimum": 1, @@ -5576,10 +5928,10 @@ }, "additionalProperties": false, "required": [ - "taskId" + "consultationId" ] }, - "DevelopmentQueryHandoffListRequest": { + "DevelopmentQueryConsultationGetRequest": { "type": "object", "properties": { "endpoint": { @@ -5598,7 +5950,7 @@ "type": "string" }, "name": { - "const": "handoff.list", + "const": "consultation.get", "type": "string" }, "projectId": { @@ -5608,7 +5960,7 @@ "description": "Opaque server-issued ID; never infer authority from it." }, "payload": { - "$ref": "#/components/schemas/DevelopmentQueryHandoffListPayload" + "$ref": "#/components/schemas/DevelopmentQueryConsultationGetPayload" }, "expectedGeneration": { "type": "integer", @@ -5649,36 +6001,19 @@ "payload" ] }, - "DevelopmentQueryHandoffListResult": { + "DevelopmentQueryConsultationGetResult": { "type": "object", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Handoff" - } - }, - "next": { - "anyOf": [ - { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Opaque server-issued ID; never infer authority from it." - }, - { - "type": "null" - } - ] + "consultation": { + "$ref": "#/components/schemas/Consultation" } }, "additionalProperties": true, "required": [ - "items", - "next" + "consultation" ] }, - "DevelopmentQueryHandoffListSuccess": { + "DevelopmentQueryConsultationGetSuccess": { "type": "object", "properties": { "ok": { @@ -5697,7 +6032,7 @@ "pattern": "^[A-Za-z0-9_-]{1,100}$" }, "result": { - "$ref": "#/components/schemas/DevelopmentQueryHandoffListResult" + "$ref": "#/components/schemas/DevelopmentQueryConsultationGetResult" }, "receipt": { "$ref": "#/components/schemas/CommandReceipt" @@ -5722,7 +6057,7 @@ "value" ] }, - "DevelopmentQueryHandoffGetPayload": { + "DevelopmentQueryHandoffListPayload": { "type": "object", "properties": { "taskId": { @@ -5731,20 +6066,24 @@ "maxLength": 200, "description": "Opaque server-issued ID; never infer authority from it." }, - "handoffId": { + "after": { "type": "string", "minLength": 1, "maxLength": 200, "description": "Opaque server-issued ID; never infer authority from it." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100 } }, "additionalProperties": false, "required": [ - "taskId", - "handoffId" + "taskId" ] }, - "DevelopmentQueryHandoffGetRequest": { + "DevelopmentQueryHandoffListRequest": { "type": "object", "properties": { "endpoint": { @@ -5763,7 +6102,7 @@ "type": "string" }, "name": { - "const": "handoff.get", + "const": "handoff.list", "type": "string" }, "projectId": { @@ -5773,7 +6112,7 @@ "description": "Opaque server-issued ID; never infer authority from it." }, "payload": { - "$ref": "#/components/schemas/DevelopmentQueryHandoffGetPayload" + "$ref": "#/components/schemas/DevelopmentQueryHandoffListPayload" }, "expectedGeneration": { "type": "integer", @@ -5814,19 +6153,36 @@ "payload" ] }, - "DevelopmentQueryHandoffGetResult": { + "DevelopmentQueryHandoffListResult": { "type": "object", "properties": { - "handoff": { - "$ref": "#/components/schemas/Handoff" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Handoff" + } + }, + "next": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] } }, "additionalProperties": true, "required": [ - "handoff" + "items", + "next" ] }, - "DevelopmentQueryHandoffGetSuccess": { + "DevelopmentQueryHandoffListSuccess": { "type": "object", "properties": { "ok": { @@ -5845,7 +6201,7 @@ "pattern": "^[A-Za-z0-9_-]{1,100}$" }, "result": { - "$ref": "#/components/schemas/DevelopmentQueryHandoffGetResult" + "$ref": "#/components/schemas/DevelopmentQueryHandoffListResult" }, "receipt": { "$ref": "#/components/schemas/CommandReceipt" @@ -5870,12 +6226,29 @@ "value" ] }, - "DevelopmentQueryPoolsPayload": { + "DevelopmentQueryHandoffGetPayload": { "type": "object", - "properties": {}, - "additionalProperties": false + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "handoffId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + } + }, + "additionalProperties": false, + "required": [ + "taskId", + "handoffId" + ] }, - "DevelopmentQueryPoolsRequest": { + "DevelopmentQueryHandoffGetRequest": { "type": "object", "properties": { "endpoint": { @@ -5894,7 +6267,7 @@ "type": "string" }, "name": { - "const": "pools", + "const": "handoff.get", "type": "string" }, "projectId": { @@ -5904,7 +6277,7 @@ "description": "Opaque server-issued ID; never infer authority from it." }, "payload": { - "$ref": "#/components/schemas/DevelopmentQueryPoolsPayload" + "$ref": "#/components/schemas/DevelopmentQueryHandoffGetPayload" }, "expectedGeneration": { "type": "integer", @@ -5945,46 +6318,19 @@ "payload" ] }, - "DevelopmentQueryPoolsResult": { + "DevelopmentQueryHandoffGetResult": { "type": "object", "properties": { - "pools": { - "type": "array", - "items": { - "type": "object", - "properties": { - "botId": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Opaque server-issued ID; never infer authority from it." - }, - "role": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]{0,31}$" - }, - "tasks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/JsonValue" - } - } - }, - "additionalProperties": true, - "required": [ - "botId", - "role", - "tasks" - ] - } + "handoff": { + "$ref": "#/components/schemas/Handoff" } }, "additionalProperties": true, "required": [ - "pools" + "handoff" ] }, - "DevelopmentQueryPoolsSuccess": { + "DevelopmentQueryHandoffGetSuccess": { "type": "object", "properties": { "ok": { @@ -6003,7 +6349,7 @@ "pattern": "^[A-Za-z0-9_-]{1,100}$" }, "result": { - "$ref": "#/components/schemas/DevelopmentQueryPoolsResult" + "$ref": "#/components/schemas/DevelopmentQueryHandoffGetResult" }, "receipt": { "$ref": "#/components/schemas/CommandReceipt" @@ -6028,33 +6374,12 @@ "value" ] }, - "DevelopmentQueryInputsPayload": { + "DevelopmentQueryPoolsPayload": { "type": "object", - "properties": { - "taskId": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Opaque server-issued ID; never infer authority from it." - }, - "after": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Opaque server-issued ID; never infer authority from it." - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 200 - } - }, - "additionalProperties": false, - "required": [ - "taskId" - ] + "properties": {}, + "additionalProperties": false }, - "DevelopmentQueryInputsRequest": { + "DevelopmentQueryPoolsRequest": { "type": "object", "properties": { "endpoint": { @@ -6073,7 +6398,7 @@ "type": "string" }, "name": { - "const": "inputs", + "const": "pools", "type": "string" }, "projectId": { @@ -6083,7 +6408,7 @@ "description": "Opaque server-issued ID; never infer authority from it." }, "payload": { - "$ref": "#/components/schemas/DevelopmentQueryInputsPayload" + "$ref": "#/components/schemas/DevelopmentQueryPoolsPayload" }, "expectedGeneration": { "type": "integer", @@ -6124,32 +6449,46 @@ "payload" ] }, - "DevelopmentQueryInputsResult": { + "DevelopmentQueryPoolsResult": { "type": "object", "properties": { - "inputs": { + "pools": { "type": "array", "items": { - "$ref": "#/components/schemas/Input" - } - }, - "next": { - "anyOf": [ - { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Opaque server-issued ID; never infer authority from it." + "type": "object", + "properties": { + "botId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "role": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$" + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JsonValue" + } + } }, - { - "type": "null" - } - ] + "additionalProperties": true, + "required": [ + "botId", + "role", + "tasks" + ] + } } }, - "additionalProperties": true + "additionalProperties": true, + "required": [ + "pools" + ] }, - "DevelopmentQueryInputsSuccess": { + "DevelopmentQueryPoolsSuccess": { "type": "object", "properties": { "ok": { @@ -6168,7 +6507,172 @@ "pattern": "^[A-Za-z0-9_-]{1,100}$" }, "result": { - "$ref": "#/components/schemas/DevelopmentQueryInputsResult" + "$ref": "#/components/schemas/DevelopmentQueryPoolsResult" + }, + "receipt": { + "$ref": "#/components/schemas/CommandReceipt" + }, + "replayed": { + "type": "boolean" + }, + "reconstructed": { + "type": "boolean" + } + }, + "additionalProperties": true, + "required": [ + "version", + "result" + ] + } + }, + "additionalProperties": false, + "required": [ + "ok", + "value" + ] + }, + "DevelopmentQueryInputsPayload": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "after": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200 + } + }, + "additionalProperties": false, + "required": [ + "taskId" + ] + }, + "DevelopmentQueryInputsRequest": { + "type": "object", + "properties": { + "endpoint": { + "const": "project-development", + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "version": { + "const": "project-development/v1", + "type": "string" + }, + "kind": { + "const": "query", + "type": "string" + }, + "name": { + "const": "inputs", + "type": "string" + }, + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "payload": { + "$ref": "#/components/schemas/DevelopmentQueryInputsPayload" + }, + "expectedGeneration": { + "type": "integer", + "minimum": 0 + }, + "expectedCandidateSha": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "required": [ + "version", + "kind", + "name", + "projectId", + "payload" + ] + } + }, + "additionalProperties": false, + "required": [ + "endpoint", + "payload" + ] + }, + "DevelopmentQueryInputsResult": { + "type": "object", + "properties": { + "inputs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Input" + } + }, + "next": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + "DevelopmentQueryInputsSuccess": { + "type": "object", + "properties": { + "ok": { + "const": true, + "type": "boolean" + }, + "value": { + "type": "object", + "properties": { + "version": { + "const": "project-development/v1", + "type": "string" + }, + "requestId": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,100}$" + }, + "result": { + "$ref": "#/components/schemas/DevelopmentQueryInputsResult" }, "receipt": { "$ref": "#/components/schemas/CommandReceipt" @@ -8835,6 +9339,341 @@ "value" ] }, + "DevelopmentCommandGithubConnectionConfigurePayload": { + "type": "object", + "properties": { + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", + "maxLength": 200 + }, + "connectionId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] + }, + "expectedRevision": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "connectionId", + "expectedRevision" + ] + }, + "DevelopmentCommandGithubConnectionConfigureRequest": { + "type": "object", + "properties": { + "endpoint": { + "const": "project-development", + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "version": { + "const": "project-development/v1", + "type": "string" + }, + "kind": { + "const": "command", + "type": "string" + }, + "name": { + "const": "github.connection.configure", + "type": "string" + }, + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "payload": { + "$ref": "#/components/schemas/DevelopmentCommandGithubConnectionConfigurePayload" + }, + "requestId": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,100}$" + }, + "expectedGeneration": { + "type": "integer", + "minimum": 0 + }, + "expectedCandidateSha": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "required": [ + "version", + "kind", + "name", + "projectId", + "payload", + "requestId" + ] + } + }, + "additionalProperties": false, + "required": [ + "endpoint", + "payload" + ] + }, + "DevelopmentCommandGithubConnectionConfigureResult": { + "type": "object", + "properties": { + "connection": { + "type": "object", + "properties": { + "configured": { + "type": "boolean" + }, + "provider": { + "anyOf": [ + { + "type": "string", + "enum": [ + "gh-cli", + "github-app" + ] + }, + { + "type": "null" + } + ] + }, + "login": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "appId": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "installationId": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "verification": { + "type": "string" + }, + "recovery": { + "type": "string" + }, + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", + "maxLength": 200 + }, + "connectionId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] + }, + "revision": { + "type": "integer", + "minimum": 0 + }, + "verifiedAt": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "identity": { + "anyOf": [ + { + "$ref": "#/components/schemas/GithubAutomationIdentity" + }, + { + "type": "null" + } + ] + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "provider": { + "type": "string", + "enum": [ + "gh-cli", + "github-app" + ] + }, + "appId": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "installationId": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "login": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true, + "required": [ + "id", + "provider", + "appId", + "installationId", + "login" + ] + }, + "maxItems": 100 + } + }, + "additionalProperties": true, + "required": [ + "repository", + "connectionId", + "revision", + "configured", + "provider", + "options" + ] + } + }, + "additionalProperties": true, + "required": [ + "connection" + ] + }, + "DevelopmentCommandGithubConnectionConfigureSuccess": { + "type": "object", + "properties": { + "ok": { + "const": true, + "type": "boolean" + }, + "value": { + "type": "object", + "properties": { + "version": { + "const": "project-development/v1", + "type": "string" + }, + "requestId": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,100}$" + }, + "result": { + "$ref": "#/components/schemas/DevelopmentCommandGithubConnectionConfigureResult" + }, + "receipt": { + "$ref": "#/components/schemas/CommandReceipt" + }, + "replayed": { + "type": "boolean" + }, + "reconstructed": { + "type": "boolean" + } + }, + "additionalProperties": true, + "required": [ + "version", + "result", + "requestId" + ] + } + }, + "additionalProperties": false, + "required": [ + "ok", + "value" + ] + }, "DevelopmentCommandInboxClaimPayload": { "type": "object", "properties": { @@ -20414,12 +21253,61 @@ "properties": { "configured": { "type": "boolean" + }, + "provider": { + "anyOf": [ + { + "type": "string", + "enum": [ + "gh-cli", + "github-app" + ] + }, + { + "type": "null" + } + ] + }, + "login": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "appId": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "installationId": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "verification": { + "type": "string" + }, + "recovery": { + "type": "string" } }, - "additionalProperties": true, - "required": [ - "configured" - ] + "additionalProperties": true } }, "additionalProperties": true, @@ -20600,6 +21488,9 @@ "executorLogin": { "type": "string" }, + "identity": { + "$ref": "#/components/schemas/GithubAutomationIdentity" + }, "at": { "type": "integer", "minimum": 0 @@ -20622,6 +21513,7 @@ "operation", "scope", "executorLogin", + "identity", "at", "limit", "pagination", @@ -26660,6 +27552,9 @@ { "$ref": "#/components/schemas/DevelopmentQueryInboxGetRequest" }, + { + "$ref": "#/components/schemas/DevelopmentQueryGithubConnectionRequest" + }, { "$ref": "#/components/schemas/DevelopmentQueryInboxConnectionRequest" }, @@ -26738,6 +27633,9 @@ { "$ref": "#/components/schemas/DevelopmentQueryCompatibilityRequest" }, + { + "$ref": "#/components/schemas/DevelopmentCommandGithubConnectionConfigureRequest" + }, { "$ref": "#/components/schemas/DevelopmentCommandInboxClaimRequest" }, @@ -27084,6 +27982,84 @@ "sha256": "046ccf3aa9bb6c2f57c010ba3f798786f2de4acb603b4482e85bfee5ccac96f9" } }, + { + "operationId": "development.query.github.connection", + "transport": { + "method": "POST", + "path": "/api/bot", + "endpoint": "project-development", + "kind": "query", + "name": "github.connection", + "version": "project-development/v1" + }, + "requestSchema": { + "$ref": "#/components/schemas/DevelopmentQueryGithubConnectionRequest" + }, + "resultSchema": { + "$ref": "#/components/schemas/DevelopmentQueryGithubConnectionResult" + }, + "successSchema": { + "$ref": "#/components/schemas/DevelopmentQueryGithubConnectionSuccess" + }, + "errorSchema": { + "$ref": "#/components/schemas/DomainError" + }, + "security": [ + { + "memberSession": [] + }, + { + "personalAgentToken": [] + } + ], + "permission": "read", + "humanRoles": [ + "owner", + "maintainer", + "member" + ], + "condition": "current-project-membership", + "personalAgent": { + "safe": true, + "watch": true, + "checks": [ + "unexpired-unrevoked-token", + "enabled-current-owner-principal", + "active-agent", + "explicit-token-project-scope", + "active-agent-project-participation", + "current-owner-project-rights", + "writes-require-current-watch-grant-and-generation", + "all-operation-specific-domain-preconditions" + ] + }, + "effective": "intersection-of-current-human-rights-token-scope-participation-delegation-and-domain-fences; never-role-name", + "preconditions": [ + { + "code": "current-project-membership", + "enforcedBy": "server", + "clientCheckIsAuthority": false + } + ], + "stateChanges": { + "effects": [], + "effectsAreConditional": true, + "transitions": [], + "noTaskCreated": false, + "acknowledgementIsCompletion": false + }, + "idempotency": { + "mechanism": "read-only", + "retry": "read-again-with-current-authorization-and-cursor", + "unifiedCommandReceipt": false + }, + "invokesModel": false, + "notes": "Configuration metadata only; never returns private key path, JWT or installation token. No network probe.", + "implementation": { + "path": "core/src/host/project-github-connections.js", + "sha256": "471e5e07d424fa6a7b22b173545c2232836ebcf45b76f8b9e2f1a1af376bfd2d" + } + }, { "operationId": "development.query.inbox.connection", "transport": { @@ -27247,7 +28223,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-development-service.js", - "sha256": "ee1d9ee3cd4c15735ef9a8dd73497f3521c7379bd23828319d8cdffb8d47be87" + "sha256": "3738f0e5fc3c78c1c7d3fb98d57af6f0ddeacfa54ae9fe7cb3d738b937e6a117" } }, { @@ -27325,7 +28301,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-development-service.js", - "sha256": "ee1d9ee3cd4c15735ef9a8dd73497f3521c7379bd23828319d8cdffb8d47be87" + "sha256": "3738f0e5fc3c78c1c7d3fb98d57af6f0ddeacfa54ae9fe7cb3d738b937e6a117" } }, { @@ -27403,7 +28379,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-development-service.js", - "sha256": "ee1d9ee3cd4c15735ef9a8dd73497f3521c7379bd23828319d8cdffb8d47be87" + "sha256": "3738f0e5fc3c78c1c7d3fb98d57af6f0ddeacfa54ae9fe7cb3d738b937e6a117" } }, { @@ -27481,7 +28457,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-development-service.js", - "sha256": "ee1d9ee3cd4c15735ef9a8dd73497f3521c7379bd23828319d8cdffb8d47be87" + "sha256": "3738f0e5fc3c78c1c7d3fb98d57af6f0ddeacfa54ae9fe7cb3d738b937e6a117" } }, { @@ -28037,7 +29013,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-development-service.js", - "sha256": "ee1d9ee3cd4c15735ef9a8dd73497f3521c7379bd23828319d8cdffb8d47be87" + "sha256": "3738f0e5fc3c78c1c7d3fb98d57af6f0ddeacfa54ae9fe7cb3d738b937e6a117" } }, { @@ -28281,7 +29257,7 @@ "notes": "Reports effective current boundaries; it neither runs tests nor grants tools/credentials.", "implementation": { "path": "core/src/host/project-execution-boundary.js", - "sha256": "20fd2d7398c0e95f983da8b54635366864f233bf738add89122d7246473b3a65" + "sha256": "9e7afe568024e19517a3d5430a0483a6ea5e2c910a53de056d83db3896d3eb16" } }, { @@ -28359,7 +29335,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-development-service.js", - "sha256": "ee1d9ee3cd4c15735ef9a8dd73497f3521c7379bd23828319d8cdffb8d47be87" + "sha256": "3738f0e5fc3c78c1c7d3fb98d57af6f0ddeacfa54ae9fe7cb3d738b937e6a117" } }, { @@ -28441,7 +29417,7 @@ "notes": "Although a query, the downstream integration checkpoint gate requires manager authority and confirmed release.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "088f77fb00768eeaa7adc4c629f6c850a2b24e0669eed46fd6fb32ed5682b565" + "sha256": "521a6d7df3f7268a93ecfa9eb1d8c974cd9c15652329002facc6f2c2c5dab950" } }, { @@ -28519,7 +29495,7 @@ "notes": "UTF-8 byte offsets, not character offsets; nextOffset preserves character boundaries. Rechecks rights after source I/O. No caller path.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "088f77fb00768eeaa7adc4c629f6c850a2b24e0669eed46fd6fb32ed5682b565" + "sha256": "521a6d7df3f7268a93ecfa9eb1d8c974cd9c15652329002facc6f2c2c5dab950" } }, { @@ -28602,7 +29578,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-releases.js", - "sha256": "e16a79b23aeded23023762bcca1aba4595dd02da86475c545d270e340cc1d4d4" + "sha256": "ac588064b036f2273c7b61cf3389c1da720e6f2166c37d269afd9d9095eb6885" } }, { @@ -29166,6 +30142,117 @@ "sha256": "4ab65df4a237fb55ea2c68396bd180bb0a9ed4c42dc63444ce4ef0869c8988bc" } }, + { + "operationId": "development.command.github.connection.configure", + "transport": { + "method": "POST", + "path": "/api/bot", + "endpoint": "project-development", + "kind": "command", + "name": "github.connection.configure", + "version": "project-development/v1" + }, + "requestSchema": { + "$ref": "#/components/schemas/DevelopmentCommandGithubConnectionConfigureRequest" + }, + "resultSchema": { + "$ref": "#/components/schemas/DevelopmentCommandGithubConnectionConfigureResult" + }, + "successSchema": { + "$ref": "#/components/schemas/DevelopmentCommandGithubConnectionConfigureSuccess" + }, + "errorSchema": { + "$ref": "#/components/schemas/DomainError" + }, + "security": [ + { + "memberSession": [] + } + ], + "permission": "human-manager", + "humanRoles": [ + "owner", + "maintainer" + ], + "condition": "current-signed-in-human-manager; no-agent-or-watch-credential", + "personalAgent": { + "safe": false, + "watch": false, + "checks": [ + "unexpired-unrevoked-token", + "enabled-current-owner-principal", + "active-agent", + "explicit-token-project-scope", + "active-agent-project-participation", + "current-owner-project-rights", + "all-operation-specific-domain-preconditions" + ] + }, + "effective": "intersection-of-current-human-rights-token-scope-participation-delegation-and-domain-fences; never-role-name", + "preconditions": [ + { + "code": "current-project-membership", + "enforcedBy": "server", + "clientCheckIsAuthority": false + }, + { + "code": "active-project", + "enforcedBy": "server", + "clientCheckIsAuthority": false + }, + { + "code": "writable-store", + "enforcedBy": "server", + "clientCheckIsAuthority": false + }, + { + "code": "signed-in-human-owner-or-maintainer", + "enforcedBy": "server", + "clientCheckIsAuthority": false + }, + { + "code": "associated-repository", + "enforcedBy": "server", + "clientCheckIsAuthority": false + }, + { + "code": "operator-provisioned-project-repository-connection", + "enforcedBy": "server", + "clientCheckIsAuthority": false + }, + { + "code": "matching-selection-revision", + "enforcedBy": "server", + "clientCheckIsAuthority": false + }, + { + "code": "current-verified-installation-or-gh-login", + "enforcedBy": "server", + "clientCheckIsAuthority": false + } + ], + "stateChanges": { + "effects": [ + "project-repository-credential-selection-committed", + "null-explicitly-disables-without-fallback" + ], + "effectsAreConditional": true, + "transitions": [], + "noTaskCreated": false, + "acknowledgementIsCompletion": false + }, + "idempotency": { + "mechanism": "command-receipt", + "retry": "same-principal-project-name-requestId-and-identical-payload; read-command.receipt-on-uncertain-outcome", + "unifiedCommandReceipt": true + }, + "invokesModel": false, + "notes": "Selects an existing connection ID; never accepts keys, URLs or tokens. Bot/watch authority cannot select or replace credentials. May perform read-only authentication verification.", + "implementation": { + "path": "core/src/host/project-github-connections.js", + "sha256": "471e5e07d424fa6a7b22b173545c2232836ebcf45b76f8b9e2f1a1af376bfd2d" + } + }, { "operationId": "development.command.inbox.claim", "transport": { @@ -30457,7 +31544,7 @@ "notes": "New Project Task contracts require actual Chief adjudication; third-party clients cannot forge Chief credentials.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "088f77fb00768eeaa7adc4c629f6c850a2b24e0669eed46fd6fb32ed5682b565" + "sha256": "521a6d7df3f7268a93ecfa9eb1d8c974cd9c15652329002facc6f2c2c5dab950" } }, { @@ -30565,7 +31652,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "088f77fb00768eeaa7adc4c629f6c850a2b24e0669eed46fd6fb32ed5682b565" + "sha256": "521a6d7df3f7268a93ecfa9eb1d8c974cd9c15652329002facc6f2c2c5dab950" } }, { @@ -30672,7 +31759,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "088f77fb00768eeaa7adc4c629f6c850a2b24e0669eed46fd6fb32ed5682b565" + "sha256": "521a6d7df3f7268a93ecfa9eb1d8c974cd9c15652329002facc6f2c2c5dab950" } }, { @@ -30766,7 +31853,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "088f77fb00768eeaa7adc4c629f6c850a2b24e0669eed46fd6fb32ed5682b565" + "sha256": "521a6d7df3f7268a93ecfa9eb1d8c974cd9c15652329002facc6f2c2c5dab950" } }, { @@ -30860,7 +31947,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "088f77fb00768eeaa7adc4c629f6c850a2b24e0669eed46fd6fb32ed5682b565" + "sha256": "521a6d7df3f7268a93ecfa9eb1d8c974cd9c15652329002facc6f2c2c5dab950" } }, { @@ -30969,7 +32056,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "088f77fb00768eeaa7adc4c629f6c850a2b24e0669eed46fd6fb32ed5682b565" + "sha256": "521a6d7df3f7268a93ecfa9eb1d8c974cd9c15652329002facc6f2c2c5dab950" } }, { @@ -31091,7 +32178,7 @@ "notes": "Can perform irreversible external publication only when mode=publish is explicitly sent. Does not merge, upload assets or deploy. Unknown results must reconcile.", "implementation": { "path": "core/src/host/project-releases.js", - "sha256": "e16a79b23aeded23023762bcca1aba4595dd02da86475c545d270e340cc1d4d4" + "sha256": "ac588064b036f2273c7b61cf3389c1da720e6f2166c37d269afd9d9095eb6885" } }, { @@ -31207,7 +32294,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-releases.js", - "sha256": "e16a79b23aeded23023762bcca1aba4595dd02da86475c545d270e340cc1d4d4" + "sha256": "ac588064b036f2273c7b61cf3389c1da720e6f2166c37d269afd9d9095eb6885" } }, { @@ -32619,7 +33706,7 @@ "notes": "No ambient/project-owner credential fallback. External write; use the same requestId when a response is lost.", "implementation": { "path": "core/src/host/project-credentials.js", - "sha256": "17ba1d326787c47d8669d0d3ef269e50a97a67eb8413183ff940a3993daab5ff" + "sha256": "18fe61057fb920664fc5077d81eee0957f7eaf64821dd10d2d2a3cd463f5fdea" } }, { @@ -33893,7 +34980,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/personal-agent-auth.js", - "sha256": "cf1af02c2bf6dcf731210f1c279c346dcf2e2c4aba3a0df12a425a7022773ebb" + "sha256": "869802cc16eeab5af84b8fd281bc60ce8cf7677bd7cac17442057042dc333853" } }, { @@ -33964,7 +35051,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/personal-agent-auth.js", - "sha256": "cf1af02c2bf6dcf731210f1c279c346dcf2e2c4aba3a0df12a425a7022773ebb" + "sha256": "869802cc16eeab5af84b8fd281bc60ce8cf7677bd7cac17442057042dc333853" } }, { @@ -34048,7 +35135,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/personal-agent-auth.js", - "sha256": "cf1af02c2bf6dcf731210f1c279c346dcf2e2c4aba3a0df12a425a7022773ebb" + "sha256": "869802cc16eeab5af84b8fd281bc60ce8cf7677bd7cac17442057042dc333853" } }, { @@ -34136,7 +35223,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/personal-agent-auth.js", - "sha256": "cf1af02c2bf6dcf731210f1c279c346dcf2e2c4aba3a0df12a425a7022773ebb" + "sha256": "869802cc16eeab5af84b8fd281bc60ce8cf7677bd7cac17442057042dc333853" } }, { @@ -34216,7 +35303,7 @@ "notes": "Safe and watch modes may propose. A human approves with project-agent-proposal-apply; the Bot cannot approve its own proposal.", "implementation": { "path": "core/src/host/personal-agent-auth.js", - "sha256": "cf1af02c2bf6dcf731210f1c279c346dcf2e2c4aba3a0df12a425a7022773ebb" + "sha256": "869802cc16eeab5af84b8fd281bc60ce8cf7677bd7cac17442057042dc333853" } }, { @@ -38244,7 +39331,7 @@ "notes": "No token/password/config path is returned. Native FBA gate requires watch even though domain action is read.", "implementation": { "path": "core/src/host/project-credentials.js", - "sha256": "17ba1d326787c47d8669d0d3ef269e50a97a67eb8413183ff940a3993daab5ff" + "sha256": "18fe61057fb920664fc5077d81eee0957f7eaf64821dd10d2d2a3cd463f5fdea" } }, { @@ -38329,7 +39416,7 @@ "notes": "Only current default repository; bounded upstream GitHub projection. data varies by operation and is external untrusted JSON. Native FBA gate currently requires watch.", "implementation": { "path": "core/src/host/project-github-read.js", - "sha256": "9baf5fb71e80a55a9297592ba24327af7b48b373eddb7ee24a74b55075b66146" + "sha256": "dbbdbbc0d917bbf20c4fdf7306464cbb682b0191b98c36fddbe70e6837a57943" } }, { @@ -41289,7 +42376,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-development-service.js", - "sha256": "ee1d9ee3cd4c15735ef9a8dd73497f3521c7379bd23828319d8cdffb8d47be87" + "sha256": "3738f0e5fc3c78c1c7d3fb98d57af6f0ddeacfa54ae9fe7cb3d738b937e6a117" }, "aliasOf": "development.query.task.detail" }, @@ -41386,7 +42473,7 @@ "notes": "New Project Task contracts require actual Chief adjudication; third-party clients cannot forge Chief credentials.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "088f77fb00768eeaa7adc4c629f6c850a2b24e0669eed46fd6fb32ed5682b565" + "sha256": "521a6d7df3f7268a93ecfa9eb1d8c974cd9c15652329002facc6f2c2c5dab950" }, "aliasOf": "development.command.task.accept" }, @@ -41479,7 +42566,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "088f77fb00768eeaa7adc4c629f6c850a2b24e0669eed46fd6fb32ed5682b565" + "sha256": "521a6d7df3f7268a93ecfa9eb1d8c974cd9c15652329002facc6f2c2c5dab950" }, "aliasOf": "development.command.task.reject" }, @@ -41571,7 +42658,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "088f77fb00768eeaa7adc4c629f6c850a2b24e0669eed46fd6fb32ed5682b565" + "sha256": "521a6d7df3f7268a93ecfa9eb1d8c974cd9c15652329002facc6f2c2c5dab950" }, "aliasOf": "development.command.attempt.stop" }, @@ -41663,7 +42750,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "088f77fb00768eeaa7adc4c629f6c850a2b24e0669eed46fd6fb32ed5682b565" + "sha256": "521a6d7df3f7268a93ecfa9eb1d8c974cd9c15652329002facc6f2c2c5dab950" }, "aliasOf": "development.command.attempt.cleanup" }, @@ -41772,7 +42859,7 @@ "notes": "Can perform irreversible external publication only when mode=publish is explicitly sent. Does not merge, upload assets or deploy. Unknown results must reconcile.", "implementation": { "path": "core/src/host/project-releases.js", - "sha256": "e16a79b23aeded23023762bcca1aba4595dd02da86475c545d270e340cc1d4d4" + "sha256": "ac588064b036f2273c7b61cf3389c1da720e6f2166c37d269afd9d9095eb6885" }, "aliasOf": "development.command.release.request" }, @@ -41869,7 +42956,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-releases.js", - "sha256": "e16a79b23aeded23023762bcca1aba4595dd02da86475c545d270e340cc1d4d4" + "sha256": "ac588064b036f2273c7b61cf3389c1da720e6f2166c37d269afd9d9095eb6885" }, "aliasOf": "development.command.release.reconcile" }, @@ -41950,7 +43037,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-releases.js", - "sha256": "e16a79b23aeded23023762bcca1aba4595dd02da86475c545d270e340cc1d4d4" + "sha256": "ac588064b036f2273c7b61cf3389c1da720e6f2166c37d269afd9d9095eb6885" }, "aliasOf": "development.query.release.status" }, @@ -42042,7 +43129,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "088f77fb00768eeaa7adc4c629f6c850a2b24e0669eed46fd6fb32ed5682b565" + "sha256": "521a6d7df3f7268a93ecfa9eb1d8c974cd9c15652329002facc6f2c2c5dab950" }, "aliasOf": "development.command.attempt.publication-retry" }, @@ -42118,7 +43205,7 @@ "notes": "UTF-8 byte offsets, not character offsets; nextOffset preserves character boundaries. Rechecks rights after source I/O. No caller path.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "088f77fb00768eeaa7adc4c629f6c850a2b24e0669eed46fd6fb32ed5682b565" + "sha256": "521a6d7df3f7268a93ecfa9eb1d8c974cd9c15652329002facc6f2c2c5dab950" }, "aliasOf": "development.query.attempt.log" }, @@ -43045,7 +44132,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-development-service.js", - "sha256": "ee1d9ee3cd4c15735ef9a8dd73497f3521c7379bd23828319d8cdffb8d47be87" + "sha256": "3738f0e5fc3c78c1c7d3fb98d57af6f0ddeacfa54ae9fe7cb3d738b937e6a117" }, "aliasOf": "development.query.pools" }, @@ -43148,7 +44235,7 @@ "notes": "No ambient/project-owner credential fallback. External write; use the same requestId when a response is lost.", "implementation": { "path": "core/src/host/project-credentials.js", - "sha256": "17ba1d326787c47d8669d0d3ef269e50a97a67eb8413183ff940a3993daab5ff" + "sha256": "18fe61057fb920664fc5077d81eee0957f7eaf64821dd10d2d2a3cd463f5fdea" }, "aliasOf": "development.command.comment.publish" } @@ -43261,6 +44348,7 @@ "project-github-comment" ], "queries": [ + "github.connection", "inbox.list", "inbox.get", "inbox.connection", @@ -43291,6 +44379,7 @@ "pools" ], "commands": [ + "github.connection.configure", "inbox.claim", "inbox.renew", "inbox.complete", @@ -43326,6 +44415,8 @@ "comment.publish" ], "aliases": { + "github.connection": "project-github-connection", + "github.connection.configure": "project-github-connection-configure", "inbox.list": "project-agent-inbox-list", "inbox.get": "project-agent-inbox-get", "inbox.connection": "project-agent-inbox-connection", @@ -43407,9 +44498,9 @@ "project-close-confirm" ], "nativeEndpointCount": 104, - "versionedQueryCount": 28, - "versionedCommandCount": 33, - "dispatchVariantCount": 184, + "versionedQueryCount": 29, + "versionedCommandCount": 34, + "dispatchVariantCount": 186, "sources": { "core/src/host/agents.js": "79908a1904312d18b8ddac4bde534210eb1bc5f8e92136bbf35a3591042e9692", "core/src/host/approvals.js": "0892ca1bad7c996854d91f56c57068e407b508ba8d42c34868a4eae221e30e2a", @@ -43448,7 +44539,7 @@ "core/src/host/notify.js": "38d5a7d56fdc4f4d777211dda8bbd08685b8e97cfb210c77544a8d1c8e0903d9", "core/src/host/parts.js": "5966cf7601c75a13ec25a83f093eabb82734208fce13e778b7182635fc5b650a", "core/src/host/permissions.js": "829f2d2d639ec8652759824cf7a05f70ed62453009a86be9f31ecd1075f0ce8e", - "core/src/host/personal-agent-auth.js": "cf1af02c2bf6dcf731210f1c279c346dcf2e2c4aba3a0df12a425a7022773ebb", + "core/src/host/personal-agent-auth.js": "869802cc16eeab5af84b8fd281bc60ce8cf7677bd7cac17442057042dc333853", "core/src/host/personal-agents.js": "065e9a8bb9b686db1908c8ada67cd899709b28e574fdc74b5a361cad44c69651", "core/src/host/platform-identity.js": "551795f4251cc705582560274eccf68a8955a26f51c807a74b8948261508a29e", "core/src/host/plugin-model.js": "64477d6900bb6abea40c7ffe0e55d51e61e54bcfbe03e5d5a5e9647b40b69fc8", @@ -43468,20 +44559,22 @@ "core/src/host/project-consultations.js": "c7a1e9afa0ad3982e23c141926f017cfff13556607b5c39b2f10296b9fb835a0", "core/src/host/project-context-budget.js": "58f990182ec5efb2701603f024eec38749cbbfabd050e478e6def58312f460e1", "core/src/host/project-context-memory.js": "933893b3d6b7b2c7a79365991c1303fb03a665d9529f16ccf57429a753ad4cd0", - "core/src/host/project-credentials.js": "17ba1d326787c47d8669d0d3ef269e50a97a67eb8413183ff940a3993daab5ff", + "core/src/host/project-credentials.js": "18fe61057fb920664fc5077d81eee0957f7eaf64821dd10d2d2a3cd463f5fdea", "core/src/host/project-delegations.js": "d4d86192551994a3419e0d2ca0f64bc4d50a75960970d2b79ed9f97b82940903", "core/src/host/project-delivery-template.js": "daa5cc8185abc1ec84421ae3fcd5574a50573cfd1a7c4cb10941158e29c79a13", - "core/src/host/project-delivery.js": "088f77fb00768eeaa7adc4c629f6c850a2b24e0669eed46fd6fb32ed5682b565", - "core/src/host/project-development-service.js": "ee1d9ee3cd4c15735ef9a8dd73497f3521c7379bd23828319d8cdffb8d47be87", + "core/src/host/project-delivery.js": "521a6d7df3f7268a93ecfa9eb1d8c974cd9c15652329002facc6f2c2c5dab950", + "core/src/host/project-development-service.js": "3738f0e5fc3c78c1c7d3fb98d57af6f0ddeacfa54ae9fe7cb3d738b937e6a117", "core/src/host/project-domain.js": "6d6a20727905005f7a8de0247a429a10ffbd1436773ee96a5d7615b2fb763e82", "core/src/host/project-effects.js": "5557782333a0b3437dacde2b6924fe12ea54225098571fa8e404d79a432be549", "core/src/host/project-execution-authority.js": "9c5ffb4bc9153d8f9d9e7901f7aef2aee90cacc19f19d8a0726ac0adb93e3e36", - "core/src/host/project-execution-boundary.js": "20fd2d7398c0e95f983da8b54635366864f233bf738add89122d7246473b3a65", + "core/src/host/project-execution-boundary.js": "9e7afe568024e19517a3d5430a0483a6ea5e2c910a53de056d83db3896d3eb16", "core/src/host/project-execution-exit.js": "50f94881a40c2d0c6288fd081c885c7cabb71f8abec3a8c918e1c0bc852a04f7", "core/src/host/project-execution-profile.js": "7106593979d6e21b9e1dab567c8628a7d0252976ed47d42dace337944725da59", "core/src/host/project-git-sandbox.js": "b579b01dcce0708275e9f735d08932481369803382b8f7d24af69639608d724f", - "core/src/host/project-github-read.js": "9baf5fb71e80a55a9297592ba24327af7b48b373eddb7ee24a74b55075b66146", - "core/src/host/project-github.js": "35edca6a669d9004f920de30625a731964b62bb3f92c0399fa988158ece0c010", + "core/src/host/project-github-app.js": "d2cf077873271125c0ad78acde8c1cb039261a4a9229964a4117d1a72081c3ca", + "core/src/host/project-github-connections.js": "471e5e07d424fa6a7b22b173545c2232836ebcf45b76f8b9e2f1a1af376bfd2d", + "core/src/host/project-github-read.js": "dbbdbbc0d917bbf20c4fdf7306464cbb682b0191b98c36fddbe70e6837a57943", + "core/src/host/project-github.js": "bf6d3891a6fa9c0516cd330d3322a750fc6db8813752f7801d4d9760fca84061", "core/src/host/project-harness-turn.js": "a8ab111b8d5473a27413968e4ea5c2631ea472c992414e9387da245463f7b21a", "core/src/host/project-harness.js": "8ee1d4040cff7ede7b4c540bb0b020061406bea718d649a7a7b1792c63cc1553", "core/src/host/project-history-pages.js": "38958623f0aa98099e570559c55a207285fd0349689ffdcec47b965c40f4e1bc", @@ -43493,8 +44586,8 @@ "core/src/host/project-pools.js": "afab16b3df4d18a5bfab69c975e8d61346a7838438a634668f2b06425545f237", "core/src/host/project-private-file.js": "e8b51d8da7bcd72658eb6eb953dbf9d0054ea0ee519b0229785e8ea0aacef6df", "core/src/host/project-process-proof.js": "85f4ed03f5191b842ca899b712c99177c059658d43a66e8ddeedc1c06eafcdea", - "core/src/host/project-release-github.js": "05c4d93482d0600178951300b8e568a34687b552362d3278f57453c4d30a9ce3", - "core/src/host/project-releases.js": "e16a79b23aeded23023762bcca1aba4595dd02da86475c545d270e340cc1d4d4", + "core/src/host/project-release-github.js": "47c52d0057dd39f1ee627c619970efad36e0134bc0c7bc218d43187230d51d64", + "core/src/host/project-releases.js": "ac588064b036f2273c7b61cf3389c1da720e6f2166c37d269afd9d9095eb6885", "core/src/host/project-repository-policy.js": "6ccc5d682016335c4e780a420a48e3ecee02c17315a76f89bacc72d49dfc5040", "core/src/host/project-resource-workspaces.js": "4e505ca393f0741cdf781c63f14f0b064cef196553de3aa6caa07c1cba43ea5f", "core/src/host/project-resources.js": "64b2abadc7900ca005b1b90217e8f512e867932c92e4033904417878fa3112ce", diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index 79f5502ce..3c28ae17a 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -75,3 +75,5 @@ Persistent Project effects and GitHub receipts: [project-effects](project-effect - [Persistent backend entry](backend-service.md) — source init/start/login/CLI, original HTTP/SSE composition and deployment/documentation verification. [Release Tasks](project-releases.md) bind explicit publication authorization and observed external proof to the existing Attempt/Effects and Chief closure. + +Project automation credential providers: [GitHub automation](project-github-automation.md). diff --git a/docs/subsystems/backend-service.md b/docs/subsystems/backend-service.md index 70ba280e7..1e8b2625c 100644 --- a/docs/subsystems/backend-service.md +++ b/docs/subsystems/backend-service.md @@ -60,7 +60,7 @@ are described from current code, including explicit legacy log/feature limits. ## Verification backend.test covers actual profile composition/pin/private modes and HTTP client -behavior. backend-docs.test checks all24queries/26commands against actual source, +behavior. backend-docs.test checks all29queries/34commands against actual source, JSON, shell syntax and local links. dsh-pin retains its original npx pin assertions on relocated original README guides; generated production profiles separately prove the pinned DSH versions. @@ -109,3 +109,7 @@ personal/group privacy and trusted role execution remain separate. No new public credential endpoint, role-result ingress, runtime middleware, timer or deployment is introduced. An external descriptor remains unverified metadata, not a callback transport. Source API changes require deliberate catalog and guide review. + +## GitHub App credential adaptation — #2311 + +#2311 extends the source-matched specification to29queries/34commands/186dispatches, including human-only selection and sanitized App identity/audit metadata. Deployment remains unchanged; see [GitHub setup](../github-automation.md). diff --git a/docs/subsystems/personal-agent-auth.md b/docs/subsystems/personal-agent-auth.md index 0e9f0aadb..7143e1896 100644 --- a/docs/subsystems/personal-agent-auth.md +++ b/docs/subsystems/personal-agent-auth.md @@ -96,3 +96,7 @@ README/API publication stays deferred to #2189 after backend convergence. ## Canonical proposal requirements (#2189 integration) External safe proposals now preserve explicitly supplied impact and scope as bounded fields in the same private proposal and intent hash. The original publication path transfers them into canonical Task requirements; it cannot silently lose the human-approved Project impact before Chief admission. Missing optional legacy fields are not invented. The production-entry HTTP regression reproduced the loss before this adapter fix and separately verifies safe approval, watch publication and credential revoke using an actual FBA caller. This is a protocol fixture, not a vendor product adapter. + +## GitHub App credential adaptation — #2311 + +FBA safe/watch callers may read scoped connection metadata but cannot configure project GitHub credentials, including full-watch agents. Existing personal-user write authority stays separate. See [GitHub providers](project-github-automation.md). diff --git a/docs/subsystems/project-agents.md b/docs/subsystems/project-agents.md index a95f065f2..44c36611f 100644 --- a/docs/subsystems/project-agents.md +++ b/docs/subsystems/project-agents.md @@ -495,3 +495,7 @@ Versioned service aliases `roles`, `role.proposals` and `role.decide` reuse the ## Bounded legacy history (#2292) project-agent-history defaults to the latest50 messages (limit1–100), retaining messages/busy/agent/live keys. Offset/cursor pages use a scope-bound prefix hash; appends preserve the snapshot, edits invalidate it. Message arrays fit30kB, oversized records are previews with sourceRef; messageIndex/textOffset/textLimit retrieve exact JSON source in4000-character pages. Agent metadata and live previews are bounded; all original transcripts remain stored. Cursors grant no access and private histories always resolve from the current actor. + +## GitHub App credential adaptation — #2311 + +Personal GitHub comments explicitly reject installation credentials; project automation status reports sanitized provider metadata. Human connection selection cannot be delegated to Bots. See [GitHub providers](project-github-automation.md). diff --git a/docs/subsystems/project-delivery.md b/docs/subsystems/project-delivery.md index 9ae91ed83..cb684e5fe 100644 --- a/docs/subsystems/project-delivery.md +++ b/docs/subsystems/project-delivery.md @@ -558,3 +558,7 @@ Repository-free decision Tasks use registered local decision-directory Workspace #2292 log reads now use sealedLogPage: offset is a UTF-8 byte boundary, limit4–8192 bytes (default8192), expectedSha256 binds the immutable source. Streaming verification scans the source in64kB buffers without loading the whole log; only the requested page is retained. Hash/stat changes refuse, and current access is rechecked after I/O. Responses preserve text/sha/sha256 and add offset/nextOffset/totalBytes. Existing callers now receive a bounded first page; use nextOffset for complete logs. Arbitrary caller paths are ignored. This bounds memory/response, not total verification I/O. #2292 Release Tasks require the existing release service to verify a committed external result at Chief closure. Explicit human/watch authorization is separate from role outputs and Chief judgement; the non-code result transition additionally requires verified release proof. See [release contract](project-releases.md). + +## GitHub App credential adaptation — #2311 + +Publication now selects either credential provider through the existing broker, pins its logical connection before Effects I/O, rechecks current authority before each external operation and preserves candidate evidence when an identity changes. Same-App key rotation does not alter idempotency; see [GitHub automation](project-github-automation.md). diff --git a/docs/subsystems/project-development-service.md b/docs/subsystems/project-development-service.md index 92046441f..9f47dbddb 100644 --- a/docs/subsystems/project-development-service.md +++ b/docs/subsystems/project-development-service.md @@ -228,3 +228,7 @@ Query compatibility returns the same Project's committed original-work baseline, The [root README](../../README.md), [API reference](../development-service-api.md) and [operations guide](../backend-operations.md) replace old1811 snapshot instructions with the real persistent source launcher and actual24queries/26commands. The reference distinguishes canonical Chief completion from legacy acceptance, current vs frozen execution and private consultation from initiative transfer. Native log reads remain explicitly described as full sealed logs, not fictitious paging. CLI/HTTP reuse the existing facade. #2292 adds release.request and release.reconcile manager commands and the release.status query. Commands use existing receipts and idempotent reconciliation; status is a bounded projection. Publication authorization, external observation and Chief closure remain distinct. See [releases](project-releases.md). + +## GitHub App credential adaptation — #2311 + +v1 github.connection queries and human-only github.connection.configure commands compose the original broker/Effects/receipts. No new gateway endpoint or role-executor port is exposed. See [GitHub providers](project-github-automation.md). diff --git a/docs/subsystems/project-execution-boundary.md b/docs/subsystems/project-execution-boundary.md index 4ca852ca1..ee610adaa 100644 --- a/docs/subsystems/project-execution-boundary.md +++ b/docs/subsystems/project-execution-boundary.md @@ -89,3 +89,7 @@ No live configuration migration is part of S5. Operator profile/resource/credent changes use an idle, binding-aware deployment checkpoint. Do not change profiles under active workers and expect cleanup to bypass their recorded protections; preserve evidence and use supported exit reconciliation. + +## GitHub App credential adaptation — #2311 + +Automation metadata now identifies gh-cli/App provider configuration without reading keys or probing GitHub; no credential source is inherited by role SDKs. See [GitHub providers](project-github-automation.md). diff --git a/docs/subsystems/project-github-automation.md b/docs/subsystems/project-github-automation.md new file mode 100644 index 000000000..28d9f8a66 --- /dev/null +++ b/docs/subsystems/project-github-automation.md @@ -0,0 +1,32 @@ +--- +subsystem: project-github-automation +owned_paths: + - core/src/host/project-github-app.js + - core/src/host/project-github-connections.js + - core/test/project-github-app.test.js + - core/test/project-github-connections.test.js + - core/test/fixtures/github-app.mjs + - docs/github-automation.md +--- + +# Project GitHub credential providers (#2311) + +The App provider plugs into existing project GitHub read/publication/Release brokers; +no second workflow, client API, credential database or executor is introduced. +It signs RS256 JWTs with the existing private-file boundary, verifies current +installation/repository/permissions before each operation, and mints single-repo +short-lived Tokens with bounded/coalesced host-only caching and key rotation. +No Token/JWT/key enters persisted state, public responses, models or SDK environments. + +Human Owner/Maintainer selection uses v1 github.connection/configure, pre-provisioned +IDs and expected revisions. Persisted per-repository Project references carry an +original committed Effect proof. Explicit disable and removed scope fail closed; +no Owner/ambient or personal-user fallback. Identity changes stop outstanding +publication/release work; same-App key rotation preserves logical identity. +Shared App identity grants no independent GitHub Review or merge bypass. + +Tests cover RSA and modern Token formats, scopes, expiry, refresh coalescing, +rotation/restart, malformed responses, native/FBA/human selection authority, +durable runtime receipts and restart. Existing broker suites exercise both +providers with deterministic GitHub responses and real local Git. See the +[operator and identity contract](../github-automation.md); live App acceptance is separate. diff --git a/docs/subsystems/project-github-read.md b/docs/subsystems/project-github-read.md index 947c392ac..612c47918 100644 --- a/docs/subsystems/project-github-read.md +++ b/docs/subsystems/project-github-read.md @@ -52,3 +52,7 @@ so a mutable Project cannot retarget an in-flight read to another repository. ## Development service (S1) The compact task execution status projection now delegates to the shared development TaskView. Its regression fixture carries actual Project/Task/Attempt/active-claim bindings instead of an unscoped worker status stub. See [service contract](project-development-service.md). + +## GitHub App credential adaptation — #2311 + +Project reads use the selected gh-cli/App connection and operation-specific installation permissions, verify current authorization before every request and withhold results after scope changes. Personal reads reject App credentials. Responses include sanitized actual provider identity; see [GitHub providers](project-github-automation.md). diff --git a/docs/subsystems/project-releases.md b/docs/subsystems/project-releases.md index 357248b75..f3cebe088 100644 --- a/docs/subsystems/project-releases.md +++ b/docs/subsystems/project-releases.md @@ -86,3 +86,7 @@ Provider HTTP is mocked; no real release is created by tests. Actual publication real-provider role execution, artifact uploads and deployment remain separate authorized operational acceptance. A unit fixture isolates Chief identity/input callbacks; the existing dedicated suites retain those authorization checks. + +## GitHub App credential adaptation — #2311 + +Release adapters use the selected App or gh-cli provider and report typed external identity proofs. App logical identity, not short-lived Token or private key path, fences authorized requests; gh-cli policy hashes remain compatible. Authorization retains human/agent and Task/Attempt attribution. See [GitHub providers](project-github-automation.md). diff --git a/docs/third-party-bot-integration.md b/docs/third-party-bot-integration.md index 4456ff619..44d8ee592 100644 --- a/docs/third-party-bot-integration.md +++ b/docs/third-party-bot-integration.md @@ -4,8 +4,8 @@ 本规范描述当前交付分支的**实际公开网关**,供 grok-bot、dots 等个人代理的客户端实现使用。 OpenAPI 3.1 JSON 可直接下载解析;不需要启动 Swagger,也没有新增 `/openapi.json` HTTP 路由。 -它覆盖104个原生分派入口(其中包含 `project-development`)、28个 v1 查询、33个 v1 命令, -并展开个人会话和旧工作流的子操作,共184种请求分支。入口数量和操作名称由源码校验。 +它覆盖104个原生分派入口(其中包含 `project-development`)、29个 v1 查询、34个 v1 命令, +并展开个人会话和旧工作流的子操作,共186种请求分支。入口数量和操作名称由源码校验。 这份规范不等于部署证明,也不表示已实现特定厂商的回调或登录适配器。 #2308 增加[统一私人消息箱与通知协议](agent-inbox.md):原生/外部代理共用领取、续租、结果提交及恢复, diff --git a/patches/ds-bot/files/src/host/personal-agent-auth.js b/patches/ds-bot/files/src/host/personal-agent-auth.js index 34a9e6f75..b6acda0a5 100644 --- a/patches/ds-bot/files/src/host/personal-agent-auth.js +++ b/patches/ds-bot/files/src/host/personal-agent-auth.js @@ -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 } } diff --git a/patches/ds-bot/files/src/host/project-credentials.js b/patches/ds-bot/files/src/host/project-credentials.js index 5e40b1ab3..0b958d7d7 100644 --- a/patches/ds-bot/files/src/host/project-credentials.js +++ b/patches/ds-bot/files/src/host/project-credentials.js @@ -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 } = {}) { @@ -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 } @@ -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') @@ -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) } diff --git a/patches/ds-bot/files/src/host/project-delivery.js b/patches/ds-bot/files/src/host/project-delivery.js index bf110100d..70eed652a 100644 --- a/patches/ds-bot/files/src/host/project-delivery.js +++ b/patches/ds-bot/files/src/host/project-delivery.js @@ -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' @@ -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) @@ -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) @@ -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) { @@ -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, @@ -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 diff --git a/patches/ds-bot/files/src/host/project-development-service.js b/patches/ds-bot/files/src/host/project-development-service.js index 34b52a1f7..1f50ce1c8 100644 --- a/patches/ds-bot/files/src/host/project-development-service.js +++ b/patches/ds-bot/files/src/host/project-development-service.js @@ -14,6 +14,7 @@ const stableId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,100}$/. const sameTask = (row, task) => Boolean(row && row.projectId === task.projectId && row.taskId === task.id) const roles = new Set(['chief', 'developer', 'tester', 'reviewer']) const operations = Object.freeze({ + 'github.connection': 'project-github-connection', 'github.connection.configure': 'project-github-connection-configure', 'inbox.list': 'project-agent-inbox-list', 'inbox.get': 'project-agent-inbox-get', 'inbox.connection': 'project-agent-inbox-connection', 'inbox.claim': 'project-agent-inbox-claim', 'inbox.renew': 'project-agent-inbox-renew', 'inbox.complete': 'project-agent-inbox-complete', 'inbox.fail': 'project-agent-inbox-fail', 'inbox.connection.configure': 'project-agent-inbox-connection-configure', 'consultation.reply': 'project-consultation-reply', 'consultation.ask': 'project-consultation-ask', 'consultation.get': 'project-consultation-get', 'consultation.list': 'project-consultation-list', @@ -34,7 +35,7 @@ const operations = Object.freeze({ 'execution.boundary': 'project-execution-boundary', 'pools': 'project-task-pools', 'effect.retry': 'project-effect-retry', 'comment.publish': 'project-github-comment', }) -const queries = new Set(['inbox.list', 'inbox.get', 'inbox.connection', 'release.status', 'compatibility', 'roles', 'role.proposals', 'consultation.get', 'consultation.list', 'handoff.get', 'handoff.list', 'task.history', 'project.snapshot', 'events', 'command.receipt', 'command.result', 'tasks', 'task', 'task.detail', 'inputs', 'sources', 'execution.boundary', 'effects', 'attempt.context', 'attempt.integration-state', 'attempt.log', 'chief.state', 'pools']) +const queries = new Set(['github.connection', 'inbox.list', 'inbox.get', 'inbox.connection', 'release.status', 'compatibility', 'roles', 'role.proposals', 'consultation.get', 'consultation.list', 'handoff.get', 'handoff.list', 'task.history', 'project.snapshot', 'events', 'command.receipt', 'command.result', 'tasks', 'task', 'task.detail', 'inputs', 'sources', 'execution.boundary', 'effects', 'attempt.context', 'attempt.integration-state', 'attempt.log', 'chief.state', 'pools']) const legacy = new Set(Object.values(operations)) const readEndpoints = new Set([...queries].map(name => operations[name]).filter(Boolean)) export const developmentQuery = body => record(body) && body.version === DEVELOPMENT_PROTOCOL && body.kind === 'query' && queries.has(body.name) @@ -102,7 +103,7 @@ export function taskView(state, task, { activity = {}, roleCatalog, isIntentComm export function createDevelopmentService(rt) { const receipts = rt.projectEffects ? createCommandReceipts(rt) : null - const management = new Set(['release.request', 'release.reconcile', 'role.decide', 'attempt.stop', 'attempt.cleanup', 'queue.retry', 'chief.inspect', 'chief.configure', 'schedule.configure', 'effect.retry', 'attempt.integration-bind', 'attempt.publication-retry', 'source.subscribe', 'source.disable', 'source.poll']) + const management = new Set(['github.connection.configure', 'release.request', 'release.reconcile', 'role.decide', 'attempt.stop', 'attempt.cleanup', 'queue.retry', 'chief.inspect', 'chief.configure', 'schedule.configure', 'effect.retry', 'attempt.integration-bind', 'attempt.publication-retry', 'source.subscribe', 'source.disable', 'source.poll']) const readCommand = new Set(['inbox.claim', 'inbox.renew', 'inbox.complete', 'inbox.fail', 'inbox.connection.configure', 'agent.mode', 'consultation.ask', 'consultation.reply']) const actionFor = name => ['task.propose', 'proposal.publish', 'comment.publish'].includes(name) ? 'create' : management.has(name) ? 'configure' : readCommand.has(name) ? 'read' : name === 'task.accept' || name === 'task.reject' ? 'accept' : 'task-write' @@ -175,6 +176,7 @@ export function createDevelopmentService(rt) { return { run: await rt.projectChiefControl.request(body.projectId, 'inspection', { requestId: body.requestId, requestedBy: actor.memberId, ...(task ? { blockedTaskId: task.id } : {}) }) } } async function invoke(endpoint, body, actor) { + if (endpoint === 'project-github-connection' || endpoint === 'project-github-connection-configure') return rt.projectGithubConnections.endpoint(endpoint.endsWith('-configure') ? 'configure' : 'status', body, actor) if (endpoint.startsWith('project-agent-inbox-')) return rt.projectAgentInbox.endpoint(endpoint.slice('project-agent-inbox-'.length).replace('connection-configure', 'connection.configure'), body, actor) if (endpoint === 'task-create') { let botId = body.botId diff --git a/patches/ds-bot/files/src/host/project-execution-boundary.js b/patches/ds-bot/files/src/host/project-execution-boundary.js index 0ce00a3d4..096165f85 100644 --- a/patches/ds-bot/files/src/host/project-execution-boundary.js +++ b/patches/ds-bot/files/src/host/project-execution-boundary.js @@ -1,9 +1,9 @@ // Trust metadata is not an authorization token or an OS sandbox claim. -import { isAbsolute } from 'node:path' +import { projectGithubStatus, githubConfigurationStatus } from './project-github-connections.js' import { ownEntry } from './store.js' import { repositoryReadiness } from './project-repository-policy.js' import { executionProfile } from './project-execution-profile.js' -const credential = settings => ({ configured: Boolean(settings?.credential === 'gh-cli' && isAbsolute(settings.configDir ?? '') && settings.login), login: settings?.login ?? null, verification: 'configuration-only; broker revalidates exact identity at invocation' }) +const credential = githubConfigurationStatus export async function executionBoundary(rt, body, actor) { const project = rt.requireProjectAccess(actor, body.projectId) const binding = [project.repository, project.ownerUserId] @@ -25,14 +25,14 @@ export async function executionBoundary(rt, body, actor) { try { rt.projectDelegations.validateGrant({ ...actor, watchGrantId: delegation.grant.id, watchGeneration: delegation.grant.generation }); delegation.effective = true } catch { delegation.reason = 'Recorded watch grant no longer matches the current human principal' } } - const settings = ownEntry(rt.config.projectRepositories ?? {}, project.repository) + const settings = projectGithubStatus(rt, project) const personal = ownEntry(ownEntry(rt.config.personalGithub ?? {}, actor.memberId) ?? {}, project.id) return { version: 'project-execution-boundary/v1', projectId: project.id, memberId: actor.memberId, profile, host: { uid: process.getuid?.() ?? null, identityScope: 'host runtime only; resource transport may select a different explicit UID', dedicatedAccount: 'operator responsibility, not inferred from numeric UID' }, permissions: Object.fromEntries(['read', 'create', 'configure', 'manage', 'invite', 'run-control', 'accept', 'task-write'].map(action => [action, rt.projectCan(actor, project.id, action, task)])), taskScope: task?.id ?? null, delegation, resources: rt.projectResources.list(project.id, actor).map(row => ({ id: row.id, type: row.type, capacity: row.capacity, harnesses: row.harnesses, health: { status: row.health.status, at: row.health.at }, harnessStatus: row.harnessStatus.map(({ harness, status, at }) => ({ harness, status, at: at ?? null })) })), - credentials: { project: credential(settings?.github), personal: credential(personal), fallback: 'none', harnessPrincipalId: project.ownerUserId }, + credentials: { project: settings, personal: credential(personal?.credential === 'github-app' ? undefined : personal), fallback: 'none', harnessPrincipalId: project.ownerUserId }, preflight: { ...readiness, tests: 'not-run', sdk: 'not-run', credentials: 'configuration-only', backgroundPrincipal: !rt.state.members[actor.memberId]?.platform ? 'local-member' : rt.config.controlPlane?.principalCheckCommand ? 'configured-checker; validated per background operation' : 'live-session-only; unattended execution needs operator principal checker' }, memory: { public: 'explicit shared Project/public Bot memory', private: 'original current-human private-agent APIs only', contentsRead: false, inheritedIntoPublicContext: false }, invariants: ['current Project/delegation/resource authorization', 'exclusive registered workspace ownership', 'lease and generation fencing', 'confirmed previous worker exit', 'exact candidate host tests and independent role Review', 'Chief adjudication'], diff --git a/patches/ds-bot/files/src/host/project-github-app.js b/patches/ds-bot/files/src/host/project-github-app.js new file mode 100644 index 000000000..fcb1af2a7 --- /dev/null +++ b/patches/ds-bot/files/src/host/project-github-app.js @@ -0,0 +1,68 @@ +// Short-lived installation credentials are host-only and never serialized into state. +import { createHash, createPrivateKey, sign } from 'node:crypto' +import { privateCredentialFile } from './project-private-file.js' +const hash = value => createHash('sha256').update(value).digest('hex') +const denied = message => Object.assign(new Error(message), { code: 'bot/forbidden' }) +const validId = value => Number.isSafeInteger(value) && value > 0 +export function githubCredentialBinding(settings = {}, repository) { + return hash(JSON.stringify(settings.credential === 'github-app' + ? ['github-app', settings.appId, settings.installationId, repository, settings.connectionId ?? null, settings.connectionRevision ?? null] + : ['gh-cli', settings.configDir, settings.login?.toLowerCase(), repository, settings.connectionId ?? null, settings.connectionRevision ?? null])) +} +export function githubIdentityMetadata(identity) { + return { provider: identity.provider ?? 'gh-cli', login: identity.login, appId: identity.appId ?? null, installationId: identity.installationId ?? null, credentialIdentity: identity.credentialIdentity ?? null } +} +export function createGithubAppProvider({ request = fetch, now = Date.now, readKey = privateCredentialFile } = {}) { + const cache = new Map(), refreshing = new Map() + async function resolve(settings, { repository, permissions = { contents: 'read' } } = {}) { + if (!validId(settings.appId) || !validId(settings.installationId) || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository ?? '') || !Object.keys(permissions).length || Object.entries(permissions).some(([key, value]) => !['contents', 'pull_requests', 'issues', 'checks'].includes(key) || !['read', 'write'].includes(value))) throw denied('GitHub App installation scope is not configured') + let key, material + try { + material = await readKey(settings.privateKeyFile) + key = createPrivateKey(material) + if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails.modulusLength < 2048) throw Error('Invalid key') + } catch { throw denied('GitHub App private key is unavailable; operator must repair its private file') } + const encode = value => Buffer.from(JSON.stringify(value)).toString('base64url') + const seconds = Math.floor(now() / 1000), unsigned = encode({ alg: 'RS256', typ: 'JWT' }) + '.' + encode({ iat: seconds - 60, exp: seconds + 540, iss: String(settings.appId) }) + const jwt = unsigned + '.' + sign('RSA-SHA256', Buffer.from(unsigned), key).toString('base64url') + const api = async (path, body) => { + let response + try { response = await request('https://api.github.com' + path, { method: body ? 'POST' : 'GET', redirect: 'error', signal: AbortSignal.timeout(15000), headers: { authorization: `Bearer ${jwt}`, accept: 'application/vnd.github+json', 'content-type': 'application/json', 'x-github-api-version': '2022-11-28' }, ...(body ? { body: JSON.stringify(body) } : {}) }) } + catch { throw denied('GitHub App installation verification is unavailable') } + if (!response.ok) throw denied('GitHub App installation is unavailable; check installation, key and repository permissions') + try { return await response.json() } catch { throw denied('GitHub App installation returned invalid metadata') } + } + // Always verify current installation, even when a token is cached. + const installation = await api(`/repos/${repository}/installation`) + if (installation.id !== settings.installationId || installation.app_id !== settings.appId || installation.suspended_at || installation.account?.login?.toLowerCase() !== repository.split('/')[0].toLowerCase() || !/^[a-z0-9-]+$/.test(installation.app_slug ?? '')) throw denied('GitHub App installation or repository scope does not match') + for (const [permission, level] of Object.entries(permissions)) if (!['read', 'write'].includes(installation.permissions?.[permission]) || level === 'write' && installation.permissions[permission] !== 'write') throw denied('GitHub App installation permission is insufficient') + const login = installation.app_slug + '[bot]' + if (settings.login && settings.login.toLowerCase() !== login.toLowerCase()) throw denied('GitHub App installation login does not match') + const binding = githubCredentialBinding(settings, repository), cacheKey = binding + hash(material) + JSON.stringify(Object.entries(permissions).sort()) + const cached = cache.get(cacheKey) + if (cached && cached.expiresAt - now() > 60000 && cached.login === login) return { ...cached } + if (refreshing.has(cacheKey)) return { ...await refreshing.get(cacheKey) } + const pending = (async () => { + const result = await api(`/app/installations/${settings.installationId}/access_tokens`, { repositories: [repository.split('/')[1]], permissions }) + const expiresAt = Date.parse(result.expires_at) + if (typeof result.token !== 'string' || !/^[A-Za-z0-9_.-]{10,8192}$/.test(result.token) || !Number.isFinite(expiresAt) || expiresAt <= now() + 60000 || expiresAt > now() + 3660000 || !Array.isArray(result.repositories) || result.repositories.length !== 1 || result.repositories[0].full_name?.toLowerCase() !== repository.toLowerCase()) throw denied('GitHub App token scope or expiry is invalid') + for (const [permission, level] of Object.entries(permissions)) if (result.permissions?.[permission] !== level) throw denied('GitHub App token permission does not match') + const value = { token: result.token, login, provider: 'github-app', appId: settings.appId, installationId: settings.installationId, repository, credentialIdentity: binding, expiresAt } + if (cache.size >= 128) cache.delete(cache.keys().next().value) + cache.set(cacheKey, value) + return value + })() + refreshing.set(cacheKey, pending) + try { return { ...await pending } } finally { refreshing.delete(cacheKey) } + } + return { resolve, invalidate(settings, repository) { const binding = githubCredentialBinding(settings, repository); for (const [key, value] of cache) if (value.credentialIdentity === binding) cache.delete(key) } } +} +const providers = new WeakMap() +export function resolveGithubApp(settings, options = {}) { + const request = options.request ?? fetch + let provider = providers.get(request) + if (!provider) { provider = createGithubAppProvider({ request }); providers.set(request, provider) } + return provider.resolve(settings, options) +} + +export function invalidateGithubApp(settings, repository, request = fetch) { providers.get(request)?.invalidate(settings, repository) } diff --git a/patches/ds-bot/files/src/host/project-github-connections.js b/patches/ds-bot/files/src/host/project-github-connections.js new file mode 100644 index 000000000..3fb3b667a --- /dev/null +++ b/patches/ds-bot/files/src/host/project-github-connections.js @@ -0,0 +1,59 @@ +// Humans select pre-provisioned connections; models never supply keys or URLs. +import { ownEntry, setEntry } from './store.js' +import { githubCredentialBinding, githubIdentityMetadata } from './project-github-app.js' +import { resolveGithubIdentity } from './project-github.js' +const refused = message => Object.assign(new Error(message), { code: 'bot/forbidden' }) +function provisioned(rt, project, id) { + const value = ownEntry(rt.config.projectGithubConnections ?? {}, id) + if (!value || value.enabled === false || !value.projectIds?.includes(project.id) || !value.repositories?.includes(project.repository)) throw refused('GitHub connection is not provisioned for this Project repository') + return value +} +export function projectGithubSettings(rt, project) { + const selection = ownEntry(project.githubConnections ?? {}, project.repository) + if (!selection) return ownEntry(rt.config.projectRepositories ?? {}, project.repository)?.github + if (!selection.connectionId) throw refused('Project GitHub connection is explicitly disabled') + if (selection.effect && !rt.projectEffects?.intentCommitted(selection.effect)) throw refused('GitHub connection selection is not committed') + const settings = provisioned(rt, project, selection.connectionId) + if (githubCredentialBinding(settings, project.repository) !== selection.binding) throw refused('GitHub connection identity changed; human verification is required') + return { ...settings, connectionId: selection.connectionId, connectionRevision: selection.revision } +} +export function githubConfigurationStatus(settings) { + return { configured: Boolean(settings?.credential === 'github-app' ? settings.appId && settings.installationId && settings.privateKeyFile : settings?.credential === 'gh-cli' && settings.configDir), provider: settings?.credential ?? null, login: settings?.login ?? null, appId: settings?.credential === 'github-app' ? settings.appId ?? null : null, installationId: settings?.credential === 'github-app' ? settings.installationId ?? null : null, verification: 'configuration-only; broker verifies current authorization per operation', recovery: 'Ask an authorized human to verify the selected connection; operator repairs keys/installations' } +} +export function projectGithubStatus(rt, project) { + try { return githubConfigurationStatus(projectGithubSettings(rt, project)) } + catch { return { ...githubConfigurationStatus(null), verification: 'unavailable; selected connection disabled or changed' } } +} +export function createGithubConnections(rt, { identity = resolveGithubIdentity } = {}) { + let tail = Promise.resolve() + rt.projectEffects?.register('github.connection', { run: row => ({ revision: row.payload.revision }) }, { replace: true }) + async function endpoint(operation, body, actor) { + const configure = operation === 'configure' + if (configure && (actor.agentId || actor.personalAgentCredentialId || actor.watchGrantId || actor.chiefJobId || actor.consultationProxyAgentId)) throw refused('Only the signed-in human may select project GitHub credentials') + const project = rt.requireProjectAccess(actor, body.projectId, configure ? 'configure' : 'read') + const repository = body.repository ?? project.repository + if (repository !== project.repository && !Object.values(rt.state.projectRepositories ?? {}).some(row => row.projectId === project.id && row.repository === repository)) throw refused('Repository is not associated with this Project') + const scoped = { ...project, repository }, previous = ownEntry(project.githubConnections ?? {}, repository) + const view = () => { + let status + try { status = githubConfigurationStatus(projectGithubSettings(rt, scoped)) } catch { status = { ...githubConfigurationStatus(null), recovery: 'Selection disabled or changed; human must reselect a provisioned connection' } } + return { repository, connectionId: previous?.connectionId ?? null, revision: previous?.revision ?? 0, verifiedAt: previous?.verifiedAt ?? null, identity: previous?.identity ?? null, ...status, options: Object.entries(rt.config.projectGithubConnections ?? {}).filter(([, row]) => row.enabled !== false && row.projectIds?.includes(project.id) && row.repositories?.includes(repository)).slice(0, 100).map(([id, row]) => ({ id, provider: row.credential, appId: row.appId ?? null, installationId: row.installationId ?? null, login: row.login ?? null })) } + } + if (!configure) return { connection: view() } + if (body.expectedRevision !== (previous?.revision ?? 0) || body.connectionId !== null && (typeof body.connectionId !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(body.connectionId))) throw Object.assign(Error('GitHub connection selection revision or ID is invalid'), { code: 'bot/conflict' }) + if (rt.validateHumanActor && !await rt.validateHumanActor(actor)) throw refused('Human session is unavailable') + const settings = body.connectionId === null ? null : provisioned(rt, scoped, body.connectionId) + const binding = settings ? githubCredentialBinding(settings, repository) : null + const selected = settings ? await identity(settings, { repository, permissions: { contents: 'read' } }) : null + const currentProject = rt.requireProjectAccess(actor, body.projectId, 'configure') + if (repository !== currentProject.repository && !Object.values(rt.state.projectRepositories ?? {}).some(row => row.projectId === currentProject.id && row.repository === repository)) throw refused('Project repository association changed during verification') + if (rt.validateHumanActor && !await rt.validateHumanActor(actor)) throw refused('Human session was revoked') + if (settings && githubCredentialBinding(provisioned(rt, scoped, body.connectionId), repository) !== binding || ownEntry(project.githubConnections ?? {}, repository) !== previous) throw refused('GitHub connection changed during verification') + const next = { connectionId: body.connectionId, revision: (previous?.revision ?? 0) + 1, binding, verifiedAt: Date.now(), selectedBy: actor.memberId, identity: selected ? githubIdentityMetadata(selected) : null } + if (rt.projectEffects) next.effect = rt.projectEffects.stage({ kind: 'github.connection', key: `${repository}:${body.requestId}`, projectId: project.id, payload: { repository, revision: next.revision, initiatedBy: actor.memberId, identity: next.identity } }) + project.githubConnections ??= {}; setEntry(project.githubConnections, repository, next) + try { await rt.save() } catch (error) { if (previous) setEntry(project.githubConnections, repository, previous); else delete project.githubConnections[repository]; throw error } + return { connection: { ...view(), connectionId: next.connectionId, revision: next.revision, verifiedAt: next.verifiedAt, ...(settings ? githubConfigurationStatus(settings) : githubConfigurationStatus(null)), identity: next.identity } } + } + return { endpoint(operation, body, actor) { if (operation !== 'configure') return endpoint(operation, body, actor); const result = tail.then(() => endpoint(operation, body, actor)); tail = result.catch(() => {}); return result } } +} diff --git a/patches/ds-bot/files/src/host/project-github-read.js b/patches/ds-bot/files/src/host/project-github-read.js index 2d08c1418..3d898dcde 100644 --- a/patches/ds-bot/files/src/host/project-github-read.js +++ b/patches/ds-bot/files/src/host/project-github-read.js @@ -1,6 +1,8 @@ // Bounded, repository-scoped GitHub reads. Never hand credentials or arbitrary URLs to models. import { ownEntry } from './store.js' import { resolveGithubIdentity } from './project-github.js' +import { projectGithubSettings } from './project-github-connections.js' +import { invalidateGithubApp, githubCredentialBinding, githubIdentityMetadata } from './project-github-app.js' const refuse = message => Object.assign(new Error(message), { code: 'bot/forbidden' }) const bounded = value => typeof value === 'string' ? value.slice(0, 1200) : null export function createProjectGithubRead(rt, { identity = resolveGithubIdentity, request = fetch } = {}) { @@ -26,17 +28,25 @@ export function createProjectGithubRead(rt, { identity = resolveGithubIdentity, // Explicit automation identity is appropriate for shared Project reads. Personal reads // require an explicit personal selection; there is no ambient-token fallback. const scope = body.scope === 'personal' ? 'personal' : 'project' - const settings = scope === 'personal' ? ownEntry(ownEntry(rt.config.personalGithub ?? {}, actor.memberId) ?? {}, project.id) : ownEntry(rt.config.projectRepositories ?? {}, project.repository)?.github - if (!settings?.login) throw refuse('Selected scoped GitHub read identity is not configured') - const selected = await identity(settings) - if (selected.login.toLowerCase() !== settings.login.toLowerCase()) throw refuse('Scoped GitHub read identity mismatch') + const settings = scope === 'personal' ? ownEntry(ownEntry(rt.config.personalGithub ?? {}, actor.memberId) ?? {}, project.id) : projectGithubSettings(rt, project) + if (scope === 'personal' && settings?.credential === 'github-app') throw refuse('Personal reads require independent user credentials') + if (!settings?.login && settings?.credential !== 'github-app') throw refuse('Selected scoped GitHub read identity is not configured') + const permissions = operation === 'issues' || operation === 'thread-events' && body.threadKind === 'issue' ? { issues: 'read' } : operation === 'checks' || operation === 'thread-events' ? operation === 'checks' ? { pull_requests: 'read', checks: 'read' } : { pull_requests: 'read', issues: 'read', checks: 'read' } : { pull_requests: 'read' } + const binding = githubCredentialBinding(settings, project.repository) + const selected = await identity(settings, { request, repository: project.repository, permissions }) + if (settings.login && selected.login.toLowerCase() !== settings.login.toLowerCase()) throw refuse('Scoped GitHub read identity mismatch') const validate = async () => { if ((rt.platformIdentityEnabled || backgroundActors.has(actor)) && !await validActor(actor, project)) throw refuse('Human platform session was revoked') if (rt.requireProjectAccess(actor, project.id).repository !== project.repository) throw refuse('Project repository changed during the read') + if (scope === 'project' && githubCredentialBinding(projectGithubSettings(rt, rt.requireProjectAccess(actor, project.id)), project.repository) !== binding) throw refuse('GitHub connection changed during read') } await validate() const get = async suffix => { - const response = await request(`https://api.github.com/repos/${project.repository}${suffix}`, { redirect: 'error', signal: AbortSignal.timeout(20_000), headers: { authorization: `Bearer ${selected.token}`, accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28' } }) + await validate() + const current = settings.credential === 'github-app' ? await identity(settings, { request, repository: project.repository, permissions }) : selected + await validate() + const response = await request(`https://api.github.com/repos/${project.repository}${suffix}`, { redirect: 'error', signal: AbortSignal.timeout(20_000), headers: { authorization: `Bearer ${current.token}`, accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28' } }) + if ([401, 403].includes(response.status)) invalidateGithubApp(settings, project.repository, request) if (!response.ok) throw new Error(`Scoped GitHub read failed (${response.status})`) const text = await response.text() if (Buffer.byteLength(text) > 2 * 1024 * 1024) throw new Error('Scoped GitHub response exceeds its byte budget') @@ -82,7 +92,7 @@ export function createProjectGithubRead(rt, { identity = resolveGithubIdentity, data.checks = (checks.check_runs ?? []).slice(0, 20).map(row => ({ name: row.name, status: row.status, conclusion: row.conclusion, url: row.html_url })) } } - return { repository: project.repository, operation, scope, executorLogin: selected.login, at: Date.now(), limit: 20, pagination: 'bounded-first-page', data } + return { repository: project.repository, operation, scope, executorLogin: selected.login, identity: githubIdentityMetadata(selected), at: Date.now(), limit: 20, pagination: 'bounded-first-page', data } } read.background = async (body, actor) => { if (body.scope === 'personal') throw refuse('Background Source reads require Project automation credentials') diff --git a/patches/ds-bot/files/src/host/project-github.js b/patches/ds-bot/files/src/host/project-github.js index 5f724c103..d080bd0c9 100644 --- a/patches/ds-bot/files/src/host/project-github.js +++ b/patches/ds-bot/files/src/host/project-github.js @@ -2,10 +2,12 @@ import { execFile } from 'node:child_process' import { isAbsolute } from 'node:path' import { promisify } from 'node:util' +import { resolveGithubApp, invalidateGithubApp, githubCredentialBinding, githubIdentityMetadata } from './project-github-app.js' const exec = promisify(execFile) const repositoryName = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/ -export async function resolveGithubIdentity(settings = {}, { run = exec, request = fetch } = {}) { +export async function resolveGithubIdentity(settings = {}, { run = exec, request = fetch, repository, permissions } = {}) { + if (settings.credential === 'github-app') return resolveGithubApp(settings, { request, repository, permissions }) if (settings.credential !== 'gh-cli' || !isAbsolute(settings.configDir ?? '')) throw new Error('Project GitHub automation credential is not explicitly configured') const env = { ...process.env, GH_CONFIG_DIR: settings.configDir } for (const key of ['GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN']) delete env[key] @@ -19,17 +21,27 @@ export async function resolveGithubIdentity(settings = {}, { run = exec, request if (!response.ok) throw new Error('Selected GitHub identity could not be verified') const user = await response.json() if (typeof user.login !== 'string' || !user.login || settings.login && user.login.toLowerCase() !== settings.login.toLowerCase()) throw new Error('Selected GitHub identity does not match the configured login') - return { token, login: user.login } + return { token, login: user.login, provider: 'gh-cli', credentialIdentity: githubCredentialBinding(settings, repository) } } -export function githubPublisher(settings = {}, { run = exec, request = fetch } = {}) { +export function githubPublisher(settings = {}, { run = exec, request = fetch, authorize = async () => {} } = {}) { + const original = { ...settings } + const check = async repository => { + if (githubCredentialBinding(settings, repository) !== githubCredentialBinding(original, repository)) throw new Error('GitHub publication connection changed; reconcile with original identity') + await authorize() + } async function api(repository, path, token, body) { if (!repositoryName.test(repository)) throw new Error('Invalid Project repository') - const response = await request(`https://api.github.com/repos/${repository}${path}`, { + await check(repository) + const selected = await resolveGithubIdentity(settings, { run, request, repository, permissions: body ? { contents: 'write', pull_requests: 'write' } : { contents: 'read', pull_requests: 'read' } }) + await check(repository) + let response + try { response = await request(`https://api.github.com/repos/${repository}${path}`, { method: body ? 'POST' : 'GET', redirect: 'error', signal: AbortSignal.timeout(30_000), - headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json', 'content-type': 'application/json', 'x-github-api-version': '2022-11-28' }, + headers: { authorization: `Bearer ${selected.token}`, accept: 'application/vnd.github+json', 'content-type': 'application/json', 'x-github-api-version': '2022-11-28' }, ...(body ? { body: JSON.stringify(body) } : {}), - }) + }) } catch { throw new Error('GitHub operation outcome is unavailable; reconcile before retry') } + if ([401, 403].includes(response.status)) invalidateGithubApp(settings, repository, request) if (!response.ok) throw new Error(`GitHub operation failed (${response.status})`) return response.json() } @@ -41,14 +53,17 @@ export function githubPublisher(settings = {}, { run = exec, request = fetch } = } return { async publish(attempt, project) { - const identity = await resolveGithubIdentity(settings, { run, request }) - const token = identity.token const repository = project.repository + await check(repository) + const identity = await resolveGithubIdentity(settings, { run, request, repository, permissions: { contents: 'write', pull_requests: 'write' } }) + const token = identity.token if (!repositoryName.test(repository)) throw new Error('Invalid Project repository') + const verifyCreator = pr => { if (identity.provider === 'github-app' && pr.user?.login?.toLowerCase() !== identity.login.toLowerCase()) throw new Error('Published PR creator does not match the selected App; reconcile existing PR') } const binding = attempt.publicationBinding if (binding) { if (binding.repository !== repository || !Number.isSafeInteger(binding.number) || binding.number < 1 || typeof binding.branch !== 'string' || !/^[a-f0-9]{40}$/.test(binding.previousSha)) throw new Error('Existing delivery PR binding is invalid') const bound = await api(repository, `/pulls/${binding.number}`, token) + verifyCreator(bound) if (bound.state !== 'open' || bound.number !== binding.number || bound.head?.repo?.full_name !== repository || bound.head?.ref !== binding.branch || ![binding.previousSha, attempt.candidateSha].includes(bound.head?.sha)) throw new Error('Existing delivery PR changed; reconcile before publication') if (!/^[a-f0-9]{40}$/.test(binding.upstreamSha ?? '') || typeof binding.upstreamRef !== 'string') throw new Error('Existing PR upstream binding is missing') const upstream = await upstreamOf(repository, bound, token) @@ -60,27 +75,34 @@ export function githubPublisher(settings = {}, { run = exec, request = fetch } = const publicationBranch = binding?.branch ?? attempt.branch const existing = binding ? [] : await api(repository, `/pulls?state=all&head=${encodeURIComponent(repository.split('/')[0] + ':' + publicationBranch)}`, token) if (!Array.isArray(existing) || existing.length > 1 || existing[0]?.state === 'closed') throw new Error('Publication branch has a closed or ambiguous PR; reconcile without creating another PR') + if (existing[0]) verifyCreator(existing[0]) if (existing[0] && existing[0].head?.sha !== attempt.candidateSha) throw new Error('Existing publication PR changed; reconcile before pushing') - const authorization = Buffer.from(`x-access-token:${token}`).toString('base64') + await check(repository) + const pushIdentity = await resolveGithubIdentity(settings, { run, request, repository, permissions: { contents: 'write', pull_requests: 'write' } }) + await check(repository) + const authorization = Buffer.from(`x-access-token:${pushIdentity.token}`).toString('base64') + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/TOKEN|PASSWORD|SECRET|API_KEY|GIT_CONFIG|GIT_TRACE|GIT_ASKPASS|SSH_ASKPASS/.test(name))) // A fixed URL avoids inheriting a model-edited remote; the credential is environment-only. try { await run('git', ['-c', 'core.hooksPath=/dev/null', 'push', `https://github.com/${repository}.git`, `${attempt.candidateSha}:refs/heads/${publicationBranch}`], { cwd: attempt.publicationRepository ?? attempt.workspaces.developer.path, timeout: 120_000, maxBuffer: 64 * 1024, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_COUNT: '2', + env: { ...env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_COUNT: '4', + GIT_CONFIG_KEY_2: 'credential.helper', GIT_CONFIG_VALUE_2: '', GIT_CONFIG_KEY_3: 'core.askPass', GIT_CONFIG_VALUE_3: '', GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader', GIT_CONFIG_VALUE_0: '', GIT_CONFIG_KEY_1: 'http.https://github.com/.extraheader', GIT_CONFIG_VALUE_1: `AUTHORIZATION: basic ${authorization}` }, }) - } catch { throw new Error('Project branch could not be pushed') } + } catch { invalidateGithubApp(settings, repository, request); throw new Error('Project branch could not be pushed') } const pr = binding ? await api(repository, `/pulls/${binding.number}`, token) : existing[0] ?? await api(repository, '/pulls', token, { title: attempt.requirement.title.slice(0, 120), head: attempt.branch, base: project.defaultBranch ?? 'main', draft: true, body: `Project delivery artifact.\n\nTask: ${attempt.taskId}\nAttempt: ${attempt.id}\nCommit: ${attempt.candidateSha}\nTests: passed at this commit\nReview: approved at this commit\n\n${attempt.requirement.criteria ?? ''}\n\nGenerated for human acceptance; no automatic merge.\n\n` }) if (binding && (pr.number !== binding.number || pr.state !== 'open' || pr.head?.ref !== publicationBranch || pr.head?.repo?.full_name !== repository)) throw new Error('Existing delivery PR changed during publication') if (binding) { const upstream = await upstreamOf(repository, pr, token); if (upstream.baseSha !== binding.upstreamSha || upstream.baseRef !== binding.upstreamRef) throw new Error('Existing PR upstream changed during publication') } if (pr.head?.sha !== attempt.candidateSha) throw new Error('PR head does not match the tested candidate') - return { number: pr.number, url: pr.html_url, sha: pr.head.sha, repository, executorLogin: identity.login, credentialScope: 'project-automation' } + verifyCreator(pr) + return { number: pr.number, url: pr.html_url, sha: pr.head.sha, repository, executorLogin: identity.login, credentialScope: 'project-automation', identity: githubIdentityMetadata(identity), initiatedBy: attempt.initiatedBy ?? null, taskId: attempt.taskId, attemptId: attempt.id ?? null, creatorLogin: pr.user?.login ?? null, operation: 'pull-request-publish', executionRole: 'project-delivery-host', candidateSha: attempt.candidateSha } }, async read(pr, { includeBase = false } = {}) { - const identity = await resolveGithubIdentity(settings, { run, request }) + const identity = await resolveGithubIdentity(settings, { run, request, repository: pr.repository, permissions: { contents: 'read', pull_requests: 'read' } }) const token = identity.token const data = await api(pr.repository, `/pulls/${pr.number}`, token) return { sha: data.head?.sha, state: data.state, url: data.html_url, ...(includeBase ? await upstreamOf(pr.repository, data, token) : {}) } diff --git a/patches/ds-bot/files/src/host/project-release-github.js b/patches/ds-bot/files/src/host/project-release-github.js index 117624bb0..955af7327 100644 --- a/patches/ds-bot/files/src/host/project-release-github.js +++ b/patches/ds-bot/files/src/host/project-release-github.js @@ -1,9 +1,10 @@ // Typed GitHub release adapter; credentials stay in the existing selected profile. import { resolveGithubIdentity } from './project-github.js' +import { invalidateGithubApp, githubIdentityMetadata } from './project-github-app.js' export function githubReleaseProvider(settings, { request = fetch, identity = resolveGithubIdentity } = {}) { async function api(spec, path, body, authorize) { if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(spec.repository)) throw new Error('Invalid release repository') - const selected = await identity(settings, { request }) + const selected = await identity(settings, { request, repository: spec.repository, permissions: { contents: body ? 'write' : 'read' } }) if (authorize) await authorize() let response try { response = await request(`https://api.github.com/repos/${spec.repository}${path}`, { @@ -11,9 +12,10 @@ export function githubReleaseProvider(settings, { request = fetch, identity = re headers: { authorization: `Bearer ${selected.token}`, accept: 'application/vnd.github+json', 'content-type': 'application/json', 'x-github-api-version': '2022-11-28' }, ...(body ? { body: JSON.stringify(body) } : {}), }) } catch { throw new Error('Release outcome is unavailable; reconcile before any retry') } + if ([401, 403].includes(response.status)) invalidateGithubApp(settings, spec.repository, request) if (response.status === 404 && !body) return null if (!response.ok) throw Object.assign(new Error(`Release provider refused (${response.status})`), { code: body && response.status >= 400 && response.status < 500 ? 'release/rejected' : 'release/unknown' }) - return { value: await response.json(), executorLogin: selected.login } + return { value: await response.json(), executorLogin: selected.login, identity: githubIdentityMetadata(selected) } } return { async inspect(spec, authorize) { @@ -27,7 +29,7 @@ export function githubReleaseProvider(settings, { request = fetch, identity = re } if (ref?.type !== 'commit' || !/^[a-f0-9]{40}$/.test(ref.sha ?? '') || !Number.isSafeInteger(row.id) || row.id < 1 || row.tag_name !== spec.tag || row.draft || typeof row.published_at !== 'string') throw new Error('Release is not a verifiable published commit') return { provider: 'github', repository: spec.repository, tag: row.tag_name, sha: ref.sha, releaseId: row.id, publishedAt: row.published_at, - url: `https://github.com/${spec.repository}/releases/tag/${encodeURIComponent(spec.tag)}`, executorLogin: result.executorLogin, marker: typeof row.body === 'string' && row.body.includes(spec.marker) } + url: `https://github.com/${spec.repository}/releases/tag/${encodeURIComponent(spec.tag)}`, executorLogin: result.executorLogin, identity: result.identity, marker: typeof row.body === 'string' && row.body.includes(spec.marker) } }, async publish(spec, authorize) { const existing = (await api(spec, `/git/ref/tags/${encodeURIComponent(spec.tag)}`, undefined, authorize))?.value?.object diff --git a/patches/ds-bot/files/src/host/project-releases.js b/patches/ds-bot/files/src/host/project-releases.js index 342d44ba5..d936e4eb9 100644 --- a/patches/ds-bot/files/src/host/project-releases.js +++ b/patches/ds-bot/files/src/host/project-releases.js @@ -3,11 +3,13 @@ import { createHash } from 'node:crypto' import { ownEntry } from './store.js' import { repositorySettings } from './project-repository-policy.js' import { githubReleaseProvider } from './project-release-github.js' +import { projectGithubSettings } from './project-github-connections.js' +import { githubCredentialBinding } from './project-github-app.js' import { EffectCancelled } from './project-effects.js' const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') const fail = message => Object.assign(new Error(message), { code: 'bot/release' }) const reference = actor => Object.fromEntries(['memberId', 'sessionId', 'agentId', 'watchGrantId', 'watchGeneration', 'personalAgentCredentialId', 'personalAgentCredentialGeneration'].filter(key => actor[key] !== undefined).map(key => [key, actor[key]])) -const view = row => row ? { id: row.id, status: row.status, repository: row.spec.repository, tag: row.spec.tag, sha: row.spec.sha, mode: row.spec.mode, authorizedBy: row.actor.memberId, createdAt: row.createdAt, observedAt: row.observedAt ?? null, proof: row.proof ? structuredClone(row.proof) : null, effect: structuredClone(row.effect), result: row.result ?? null } : null +const view = row => row ? { id: row.id, status: row.status, repository: row.spec.repository, tag: row.spec.tag, sha: row.spec.sha, mode: row.spec.mode, authorizedBy: row.actor.memberId, authorizedAgentId: row.actor.agentId ?? null, taskId: row.taskId ?? null, attemptId: row.attemptId ?? null, createdAt: row.createdAt, observedAt: row.observedAt ?? null, proof: row.proof ? structuredClone(row.proof) : null, effect: structuredClone(row.effect), result: row.result ?? null } : null export function createProjectReleases(rt, { projectOf, providerFactory = settings => githubReleaseProvider(settings.github) }) { let tail = Promise.resolve() const serial = fn => { const result = tail.then(fn, fn); tail = result.catch(() => {}); return result } @@ -19,9 +21,9 @@ export function createProjectReleases(rt, { projectOf, providerFactory = setting function settingsOf(attempt) { const project = projectOf(attempt.projectId, attempt), settings = repositorySettings(rt, project) if (settings.release?.enabled !== true || settings.release.provider !== 'github') throw fail('Release provider is not explicitly configured') - return settings + return { ...settings, github: projectGithubSettings(rt, project), githubRepository: project.repository } } - const policyHash = settings => hash({ release: settings.release, github: settings.github }) + const policyHash = settings => hash({ release: settings.release, github: settings.github?.credential === 'github-app' ? githubCredentialBinding(settings.github, settings.githubRepository) : settings.github }) async function authority(attempt, task, row) { if (rt.readOnly?.() !== undefined) throw new EffectCancelled('Release host is read-only') await rt.projectDelegations?.background(row.actor.memberId, row.actor.sessionId) @@ -91,7 +93,7 @@ export function createProjectReleases(rt, { projectOf, providerFactory = setting if (prior.requestId !== body.requestId || prior.requestHash !== requestHash || prior.actor.memberId !== actor.memberId) throw fail('Release request already exists; reconcile it instead of replacing authorization') return { release: view(prior), already: true } } - const row = { id: `release-${attempt.id}`, requestId: body.requestId, requestHash, spec, actor: reference(actor), policySha256: policyHash(settings), requirementsRevision: task.projectTask?.requirements.revision ?? null, status: 'authorized', createdAt: Date.now() } + const row = { id: `release-${attempt.id}`, taskId: task.id, attemptId: attempt.id, requestId: body.requestId, requestHash, spec, actor: reference(actor), policySha256: policyHash(settings), requirementsRevision: task.projectTask?.requirements.revision ?? null, status: 'authorized', createdAt: Date.now() } attempt.release = row let token try { diff --git a/patches/ds-bot/files/test/fixtures/github-app.mjs b/patches/ds-bot/files/test/fixtures/github-app.mjs new file mode 100644 index 000000000..f4d6c58ee --- /dev/null +++ b/patches/ds-bot/files/test/fixtures/github-app.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { generateKeyPairSync, verify } from 'node:crypto' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +export function appFixture(t, handle, repository = 'org/repo') { + const directory = mkdtempSync(join(tmpdir(), 'flybot-app-contract-')), privateKeyFile = join(directory, 'app.pem'), pair = generateKeyPairSync('rsa', { modulusLength: 2048 }) + writeFileSync(privateKeyFile, pair.privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 }) + t.after(() => rmSync(directory, { recursive: true, force: true })) + const settings = { credential: 'github-app', appId: 7, installationId: 12, privateKeyFile }, tokens = new Map(), minted = [] + let suspended = false + const request = async (url, options = {}) => { + assert(url.startsWith('https://api.github.com/')); assert.equal(options.redirect, 'error') + const authorization = options.headers.authorization.slice(7) + if (url.endsWith('/installation') || url.endsWith('/access_tokens')) { + const parts = authorization.split('.') + assert(verify('RSA-SHA256', Buffer.from(parts[0] + '.' + parts[1]), pair.publicKey, Buffer.from(parts[2], 'base64url'))) + if (url.endsWith('/installation')) return Response.json({ id: 12, app_id: 7, app_slug: 'contract-app', account: { login: repository.split('/')[0] }, suspended_at: suspended ? 'now' : null, permissions: { contents: 'write', issues: 'write', pull_requests: 'write', checks: 'read' } }) + const body = JSON.parse(options.body); assert.deepEqual(body.repositories, [repository.split('/')[1]]) + minted.push(body.permissions); const token = 'ghs_7_contract.' + minted.length; tokens.set(token, body.permissions) + return Response.json({ token, expires_at: new Date(Date.now() + 3600000).toISOString(), permissions: body.permissions, repositories: [{ full_name: repository }] }) + } + assert(tokens.has(authorization), 'External operation uses a minted installation token, never JWT/ambient credentials') + return handle(url, options, tokens.get(authorization)) + } + return { settings, request, minted, suspend() { suspended = true } } +} diff --git a/patches/ds-bot/files/test/project-credentials.test.js b/patches/ds-bot/files/test/project-credentials.test.js index b70322957..c6ad3c368 100644 --- a/patches/ds-bot/files/test/project-credentials.test.js +++ b/patches/ds-bot/files/test/project-credentials.test.js @@ -65,3 +65,11 @@ test('malformed credential output is refused without reflecting it or sending a }), error => /unavailable/.test(error.message) && !error.message.includes('private-value')) assert.equal(called, false) }) + +test('project App credentials cannot be selected for personal comments', async () => { + let calls = 0 + const rt = { state: {}, config: { personalGithub: { alice: { p: { credential: 'github-app', appId: 7, installationId: 12, login: 'app[bot]' } } } }, requireProjectAccess: () => ({ id: 'p', repository: 'org/repo' }) } + const service = createProjectCredentials(rt, { identity: async () => { calls++; return { token: 'never', login: 'app[bot]' } } }) + await assert.rejects(service.endpoint('project-github-comment', { projectId: 'p', issueNumber: 1, text: 'As user' }, { memberId: 'alice' }), /independent user/) + assert.equal(calls, 0) +}) diff --git a/patches/ds-bot/files/test/project-delivery.test.js b/patches/ds-bot/files/test/project-delivery.test.js index f3b6d3d6c..0dd9c3106 100644 --- a/patches/ds-bot/files/test/project-delivery.test.js +++ b/patches/ds-bot/files/test/project-delivery.test.js @@ -7,6 +7,7 @@ import { join } from 'node:path' import { test } from 'node:test' import { createRuntime } from '../index.js' import { roleExecutionPrompt } from '../src/host/project-role-policy.js' +import { githubCredentialBinding } from '../src/host/project-github-app.js' import { createProjectDelivery } from '../src/host/project-delivery.js' import { createTaskContract, admitTask, addTaskDependencies } from '../src/host/project-task-lifecycle.js' import { childResultSources } from '../src/host/project-child-inputs.js' @@ -1676,3 +1677,23 @@ test('Release Task closes only after explicit durable authorization and current assert.equal(a.acceptedViaChief, 'release-close') assert.equal(posts, 1) }) + +test('publication recovery pins the original App identity and preserves evidence after connection changes', async t => { + const f = await setup(t); await f.start('app-fence'); await complete(f) + const attempt = f.attempt(), original = { credential: 'github-app', appId: 7, installationId: 12, privateKeyFile: '/never-read.pem' } + attempt.publicationCredential = githubCredentialBinding(original, 'example/demo') + f.rt.config.projectRepositories['example/demo'].github = { ...original, installationId: 99 } + f.rt.projectDelivery = createProjectDelivery(f.rt) + const count = f.published.length + await assert.rejects(f.rt.projectEffects.perform({ kind: 'github.publish', key: 'connection-change-probe', projectId: 'p1', taskId: f.task.id, payload: { attemptId: attempt.id, candidateSha: attempt.candidateSha, initiatedBy: 'owner-1' } }), /connection changed/) + assert.equal(f.published.length, count); assert.equal(attempt.evidence.tests.passed, true); assert.equal(attempt.pr.sha, attempt.candidateSha) +}) + +test('publication revalidates operator repository capability before any provider operation', async t => { + const f = await setup(t); await f.start('app-repository-fence'); await complete(f) + const attempt = f.attempt() + f.rt.config.projectRepositories['example/demo'].allowedProjectOwnerIds = [] + f.rt.projectDelivery = createProjectDelivery(f.rt) + await assert.rejects(f.rt.projectEffects.perform({ kind: 'github.publish', key: 'repository-revoked-probe', projectId: 'p1', taskId: f.task.id, payload: { attemptId: attempt.id, candidateSha: attempt.candidateSha, initiatedBy: 'owner-1' } }), /not provisioned/) + assert.equal(attempt.evidence.tests.passed, true); assert.equal(f.published.length, 1) +}) diff --git a/patches/ds-bot/files/test/project-development-service.test.js b/patches/ds-bot/files/test/project-development-service.test.js index 284293084..77bc59470 100644 --- a/patches/ds-bot/files/test/project-development-service.test.js +++ b/patches/ds-bot/files/test/project-development-service.test.js @@ -306,3 +306,14 @@ test('attempt lists reference large frozen definitions instead of copying their assert.equal(result.attempts[0].roleSessions.developer.definitionRevision, 1) assert.equal(state.projectAttempts.a.roleSessions.developer.definition.instructions, text) }) + +test('GitHub connection operations use the v1 facade with current manager authority', async () => { + const { rt, service, actor } = fixture(), calls = [] + rt.projectGithubConnections = { endpoint: async (...args) => { rt.requireProjectAccess(args[2], args[1].projectId, args[0] === 'configure' ? 'configure' : 'read'); calls.push(args); return { connection: { configured: false } } } } + const envelope = { version: DEVELOPMENT_PROTOCOL, projectId: 'p', kind: 'command', name: 'github.connection.configure', requestId: 'select-one', payload: { connectionId: 'app', expectedRevision: 0 } } + await service.endpoint('project-development', envelope, actor) + assert.equal(calls[0][0], 'configure'); assert.equal(calls[0][2], actor) + await assert.rejects(service.endpoint('project-development', envelope, { memberId: 'bob' }), /denied/) + const result = await service.endpoint('project-development', { ...envelope, kind: 'query', name: 'github.connection' }, { memberId: 'bob' }) + assert.equal(result.result.connection.configured, false); assert.equal(calls.length, 2) +}) diff --git a/patches/ds-bot/files/test/project-github-app.test.js b/patches/ds-bot/files/test/project-github-app.test.js new file mode 100644 index 000000000..1b1d0425b --- /dev/null +++ b/patches/ds-bot/files/test/project-github-app.test.js @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { generateKeyPairSync, verify } from 'node:crypto' +import { createGithubAppProvider } from '../src/host/project-github-app.js' +const keys = () => generateKeyPairSync('rsa', { modulusLength: 2048 }) +function fixture() { + let time = 1800000000000, pair = keys(), mints = 0, suspended = false, permissions = { contents: 'write', pull_requests: 'write' }, installed = 12, tokenOverride = {} + const settings = { credential: 'github-app', appId: 7, installationId: 12, privateKeyFile: '/private/test.pem' } + const request = async (url, options) => { + const jwt = options.headers.authorization.slice(7), [header, payload, signature] = jwt.split('.') + assert.equal(JSON.parse(Buffer.from(header, 'base64url')).alg, 'RS256') + assert(verify('RSA-SHA256', Buffer.from(header + '.' + payload), pair.publicKey, Buffer.from(signature, 'base64url'))) + const claims = JSON.parse(Buffer.from(payload, 'base64url')) + assert.equal(claims.iss, '7'); assert.equal(claims.iat, Math.floor(time / 1000) - 60); assert(claims.exp <= time / 1000 + 600) + if (url.endsWith('/installation')) return Response.json({ id: installed, app_id: 7, app_slug: 'flybot-test', account: { login: 'example' }, suspended_at: suspended ? 'yesterday' : null, permissions }) + assert.equal(url, 'https://api.github.com/app/installations/12/access_tokens') + const body = JSON.parse(options.body); assert.deepEqual(body.repositories, ['demo']) + mints++ + return Response.json({ token: 'ghs_7_new.format.' + mints, expires_at: new Date(time + 3600000).toISOString(), permissions: body.permissions, repositories: [{ full_name: 'example/demo' }], ...tokenOverride }) + } + const makeProvider = () => createGithubAppProvider({ request, now: () => time, readKey: async () => pair.privateKey.export({ type: 'pkcs8', format: 'pem' }) }) + const provider = makeProvider() + return { settings, provider, restart: makeProvider, count: () => mints, token(value) { tokenOverride = value }, advance() { time += 3600000 }, rotate() { pair = keys() }, suspend() { suspended = true }, wrong() { installed = 99 }, reduce() { permissions = { contents: 'read' } } } +} +test('App JWT, scoped tokens, concurrent refresh, expiry, rotation and restart preserve logical identity', async () => { + const f = fixture(), options = { repository: 'example/demo', permissions: { contents: 'write', pull_requests: 'write' } } + const rows = await Promise.all(Array.from({ length: 8 }, () => f.provider.resolve(f.settings, options))) + assert.equal(f.count(), 1); assert.equal(rows[0].login, 'flybot-test[bot]'); assert.equal(rows[0].provider, 'github-app') + assert(rows.every(row => row.token === rows[0].token)); const binding = rows[0].credentialIdentity + f.advance(); assert.notEqual((await f.provider.resolve(f.settings, options)).token, rows[0].token); assert.equal(f.count(), 2) + f.rotate(); assert.equal((await f.provider.resolve(f.settings, options)).credentialIdentity, binding); assert.equal(f.count(), 3) + await assert.rejects(f.provider.resolve(f.settings, { ...options, repository: 'other/demo' }), /installation|scope/) + await f.restart().resolve(f.settings, options); assert.equal(f.count(), 4, 'restart remints instead of restoring tokens from state') + f.suspend(); await assert.rejects(f.provider.resolve(f.settings, options), /installation/); assert.equal(f.count(), 4) +}) +test('App installation and permissions fail closed even with a cached token', async () => { + for (const action of ['wrong', 'reduce']) { + const f = fixture(), options = { repository: 'example/demo', permissions: { contents: 'write' } } + await f.provider.resolve(f.settings, options); f[action]() + await assert.rejects(f.provider.resolve(f.settings, options), /installation|permission/); assert.equal(f.count(), 1) + } +}) + +test('App provider sanitizes key/network failures and refuses oversized, widened or stale tokens', async () => { + const f = fixture() + await assert.rejects(f.provider.resolve({ ...f.settings, appId: '7' }, { repository: 'example/demo' }), /scope/) + const provider = createGithubAppProvider({ readKey: async () => { throw Error('PRIVATE-KEY-SENTINEL') } }) + await assert.rejects(provider.resolve(f.settings, { repository: 'example/demo' }), error => !error.message.includes('SENTINEL') && /private key/.test(error.message)) +}) + +test('widened repository scope, missing permission, stale expiry and malformed tokens are rejected', async () => { + for (const bad of [{ repositories: [{ full_name: 'other/repo' }] }, { repositories: [{ full_name: 'example/demo' }, { full_name: 'other/repo' }] }, { permissions: {} }, { expires_at: '2000-01-01T00:00:00Z' }, { token: 'injected\r\nheader' }, { token: 'x'.repeat(8193) }]) { + const f = fixture(); f.token(bad) + await assert.rejects(f.provider.resolve(f.settings, { repository: 'example/demo' }), /scope|expiry|permission/) + } +}) diff --git a/patches/ds-bot/files/test/project-github-connections.test.js b/patches/ds-bot/files/test/project-github-connections.test.js new file mode 100644 index 000000000..4fffc1eed --- /dev/null +++ b/patches/ds-bot/files/test/project-github-connections.test.js @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createGithubConnections, projectGithubSettings } from '../src/host/project-github-connections.js' +function fixture() { + const project = { id: 'p', repository: 'example/demo' }, actor = { memberId: 'owner' } + const settings = { credential: 'github-app', appId: 7, installationId: 12, privateKeyFile: '/private/test.pem', projectIds: ['p'], repositories: ['example/demo'] } + let fail = false, calls = 0, during = () => {} + const rt = { state: {}, config: { projectGithubConnections: { app: settings }, projectRepositories: { 'example/demo': { github: { credential: 'gh-cli', configDir: '/owner' } } } }, requireProjectAccess(a, id, permission) { assert.equal(id, 'p'); if (permission === 'configure' && a.memberId !== 'owner') throw Error('Forbidden'); return project }, save: async () => { if (fail) throw Error('Disk failure') }, validateHumanActor: async () => true } + const service = createGithubConnections(rt, { identity: async () => { calls++; during(); return { provider: 'github-app', appId: 7, installationId: 12, login: 'test[bot]', token: 'PRIVATE-SENTINEL' } } }) + const configure = (body, a = actor) => service.endpoint('configure', { projectId: 'p', connectionId: 'app', expectedRevision: 0, requestId: 'select', ...body }, a) + return { rt, project, service, configure, settings, calls: () => calls, fail() { fail = true }, during(fn) { during = fn } } +} +test('human selection binds repository/install identity; disable and changed selection never fall back', async () => { + const f = fixture() + const result = await f.configure({}) + assert.equal(result.connection.provider, 'github-app'); assert.equal(result.connection.revision, 1) + assert.equal(projectGithubSettings(f.rt, f.project).installationId, 12) + f.settings.privateKeyFile = '/private/rotated.pem' + assert.equal(projectGithubSettings(f.rt, f.project).privateKeyFile, '/private/rotated.pem') + assert(!JSON.stringify(result).includes('/private/')); assert(!JSON.stringify(f.project).includes('SENTINEL')) + f.settings.installationId = 99 + assert.throws(() => projectGithubSettings(f.rt, f.project), /changed/) + await f.configure({ expectedRevision: 1, connectionId: null }) + assert.throws(() => projectGithubSettings(f.rt, f.project), /disabled/) + assert.equal((await f.service.endpoint('status', { projectId: 'p' }, { memberId: 'member' })).connection.configured, false) +}) +test('models, external full-watch agents, foreign projects/repositories and stale revisions cannot select credentials', async () => { + const f = fixture() + for (const extra of [{ agentId: 'a' }, { watchGrantId: 'w' }, { personalAgentCredentialId: 'c' }, { chiefJobId: 'j' }]) await assert.rejects(f.configure({}, { memberId: 'owner', ...extra }), /human/) + await assert.rejects(f.configure({}, { memberId: 'member' }), /Forbidden/) + await assert.rejects(f.configure({ repository: 'other/repo' }), /associated/) + f.settings.repositories = ['other/repo']; await assert.rejects(f.configure({}), /provisioned/) + assert.equal(f.calls(), 0) +}) +test('failed save and mid-verification change cannot commit selection', async () => { + const f = fixture(); f.fail(); await assert.rejects(f.configure({}), /Disk/) + assert.equal(f.project.githubConnections['example/demo'], undefined) + const g = fixture(); g.during(() => { g.settings.installationId = 99 }); await assert.rejects(g.configure({}), /changed/) + assert.equal(g.project.githubConnections, undefined) +}) + +test('real runtime v1 receipts persist human connection selection and survive restart without credentials', async t => { + const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import('node:fs') + const { tmpdir } = await import('node:os'), { join } = await import('node:path') + const { createRuntime } = await import('../index.js'), { fakeContext } = await import('./fake-ctx.js') + const home = mkdtempSync(join(tmpdir(), 'flybot-app-selection-')) + const config = { home, projectWorkbench: true, projectGithubConnections: { app: { credential: 'github-app', appId: 7, installationId: 12, privateKeyFile: '/private/not-read.pem', projectIds: ['p'], repositories: ['org/repo'] } } } + writeFileSync(join(home, 'state.json'), JSON.stringify({ version: 2, members: { 'owner-1': { id: 'owner-1' }, member: { id: 'member' } }, projects: { p: { id: 'p', ownerUserId: 'owner-1', repository: 'org/repo', status: 'active', roles: { maintainers: [], members: ['member'] } } }, bots: {}, rooms: {}, mainBotIds: [], tasks: {} })) + const contexts = []; t.after(async () => { for (const ctx of contexts) await ctx.dispose(); rmSync(home, { recursive: true, force: true }) }) + const ctx = fakeContext(); contexts.push(ctx); const rt = createRuntime(ctx, config); await rt.load() + let calls = 0 + rt.projectGithubConnections = createGithubConnections(rt, { identity: async () => { calls++; return { provider: 'github-app', appId: 7, installationId: 12, login: 'app[bot]', token: 'PRIVATE-INSTALLATION-SENTINEL' } } }) + const body = { version: 'project-development/v1', kind: 'command', projectId: 'p', name: 'github.connection.configure', requestId: 'select-one', payload: { connectionId: 'app', expectedRevision: 0 } } + const result = await rt.projectEndpoint('project-development', body, { memberId: 'owner-1' }) + assert.equal(result.result.connection.revision, 1) + assert.equal((await rt.projectEndpoint('project-development', body, { memberId: 'owner-1' })).replayed, true); assert.equal(calls, 1) + await assert.rejects(rt.projectEndpoint('project-development', { ...body, requestId: 'member-try' }, { memberId: 'member' }), /denied/) + const stored = readFileSync(join(home, 'state.json'), 'utf8'); assert(!stored.includes('SENTINEL')); assert(!stored.includes('not-read.pem')) + await ctx.dispose(); contexts.pop() + const next = fakeContext(); contexts.push(next); const restarted = createRuntime(next, config); await restarted.load() + assert.equal(projectGithubSettings(restarted, restarted.requireProjectAccess({ memberId: 'owner-1' }, 'p')).installationId, 12) + const status = await restarted.projectEndpoint('project-development', { version: 'project-development/v1', kind: 'query', projectId: 'p', name: 'github.connection', payload: {} }, { memberId: 'member' }) + assert.equal(status.result.connection.identity.installationId, 12) +}) diff --git a/patches/ds-bot/files/test/project-github-publish.test.js b/patches/ds-bot/files/test/project-github-publish.test.js index f937b96fe..ee9f80152 100644 --- a/patches/ds-bot/files/test/project-github-publish.test.js +++ b/patches/ds-bot/files/test/project-github-publish.test.js @@ -3,10 +3,11 @@ import { execFileSync } from 'node:child_process' import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { generateKeyPairSync } from 'node:crypto' import { test } from 'node:test' import { githubPublisher } from '../src/host/project-github.js' const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() -function fixture(t) { +function fixture(t, provider = 'gh-cli') { const path = mkdtempSync(join(tmpdir(), 'flybot-publish-')) git(path, 'init', '-b', 'main'); git(path, 'config', 'user.name', 'Fixture'); git(path, 'config', 'user.email', 'fixture@localhost') writeFileSync(join(path, 'code.txt'), 'first'); git(path, 'add', '.'); git(path, 'commit', '-m', 'Published candidate') @@ -19,24 +20,29 @@ function fixture(t) { let override = {}, postPushOverride = {}, beforePush = () => {}, upstream = previous, unbound = [], loseReply = false const request = async (url, options = {}) => { calls.push({ url, method: options.method ?? 'GET' }) + if (url.endsWith('/installation')) return Response.json({ id: 12, app_id: 7, app_slug: 'automation', account: { login: 'example' }, suspended_at: null, permissions: { contents: 'write', pull_requests: 'write' } }) + if (url.endsWith('/access_tokens')) return Response.json({ token: 'ghs_7_fixture.token', expires_at: new Date(Date.now() + 3600000).toISOString(), permissions: JSON.parse(options.body).permissions, repositories: [{ full_name: 'example/demo' }] }) if (url.endsWith('/user')) return Response.json({ login: 'automation' }) if (options.method === 'POST') { creates++ - const pr = { number: 99, state: 'open', head: { sha: candidate }, html_url: 'https://example.invalid/pr/99', body: JSON.parse(options.body).body } + const pr = { user: { login: 'automation[bot]' }, number: 99, state: 'open', head: { sha: candidate }, html_url: 'https://example.invalid/pr/99', body: JSON.parse(options.body).body } unbound = [pr] if (loseReply) { loseReply = false; throw Error('Lost response after PR creation') } return Response.json(pr) } if (url.includes('/git/ref/heads/')) return Response.json({ object: { type: 'commit', sha: upstream } }) if (url.includes('/pulls?')) return Response.json(unbound) - return Response.json({ number: 48, state: 'open', draft: true, base: { ref: 'main', sha: 'f'.repeat(40), repo: { full_name: 'example/demo' } }, head: { ref: 'existing', repo: { full_name: 'example/demo' }, sha: git(path, '--git-dir', remote, 'rev-parse', 'existing') }, html_url: 'https://example.invalid/pr/48', ...(pushes ? postPushOverride : override) }) + return Response.json({ user: { login: 'automation[bot]' }, number: 48, state: 'open', draft: true, base: { ref: 'main', sha: 'f'.repeat(40), repo: { full_name: 'example/demo' } }, head: { ref: 'existing', repo: { full_name: 'example/demo' }, sha: git(path, '--git-dir', remote, 'rev-parse', 'existing') }, html_url: 'https://example.invalid/pr/48', ...(pushes ? postPushOverride : override) }) } const run = async (command, args, options) => { if (command === 'gh') return { stdout: 'fixture-token' } if (args.includes('push')) { beforePush(); pushes++; const rewritten = args.map(arg => arg.startsWith('https://github.com/') ? remote : arg); return { stdout: git(options.cwd, ...rewritten) } } return { stdout: git(options.cwd, ...args) } } - const publisher = githubPublisher({ credential: 'gh-cli', configDir: '/fixture/explicit-gh', login: 'automation' }, { request, run }) + const keyFile = join(path, 'app.pem') + if (provider === 'github-app') writeFileSync(keyFile, generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 }) + const settings = provider === 'github-app' ? { credential: 'github-app', appId: 7, installationId: 12, privateKeyFile: keyFile } : { credential: 'gh-cli', configDir: '/fixture/explicit-gh', login: 'automation' } + const publisher = githubPublisher(settings, { request, run }) t.after(() => rmSync(path, { recursive: true, force: true })) return { publisher, attempt, previous, candidate, remote, path, calls, counters: () => ({ pushes, creates }), override(value) { override = value }, postPushOverride(value) { postPushOverride = value }, beforePush(value) { beforePush = value }, upstream(value) { upstream = value }, loseReply() { loseReply = true }, unbound(value) { unbound = value } } } @@ -92,7 +98,7 @@ test('first-PR response loss reuses its marker/branch receipt; closed or changed const f = fixture(t); delete f.attempt.publicationBinding f.attempt.publicationEffectId = 'effect-stable' f.loseReply() - await assert.rejects(f.publisher.publish(f.attempt, { repository: 'example/demo', defaultBranch: 'main' }), /Lost response/) + await assert.rejects(f.publisher.publish(f.attempt, { repository: 'example/demo', defaultBranch: 'main' }), /outcome is unavailable/) assert.equal(f.counters().creates, 1) const recovered = await f.publisher.publish(f.attempt, { repository: 'example/demo', defaultBranch: 'main' }) assert.equal(recovered.number, 99) @@ -151,3 +157,20 @@ test('same-PR continuation requires trusted upstream ancestry and rechecks targe await assert.rejects(f.publisher.publish(f.attempt, { repository: 'example/demo' }), /upstream changed during publication/) assert.deepEqual(f.counters(), { pushes: 1, creates: 0 }) }) + +for (const provider of ['gh-cli', 'github-app']) test(provider + ' shares push, existing PR, read and lost-create-response reconciliation', async t => { + const f = fixture(t, provider) + const pr = await f.publisher.publish(f.attempt, { repository: 'example/demo' }) + assert.equal(pr.identity.provider, provider); assert.equal(pr.sha, f.candidate) + assert.equal((await f.publisher.read(pr)).sha, f.candidate) + delete f.attempt.publicationBinding; f.loseReply() + await assert.rejects(f.publisher.publish(f.attempt, { repository: 'example/demo' }), /outcome is unavailable/) + assert.equal((await f.publisher.publish(f.attempt, { repository: 'example/demo' })).number, 99) + assert.equal(f.counters().creates, 1) +}) + +test('App publication rejects a different PR creator before any push', async t => { + const f = fixture(t, 'github-app'); f.override({ user: { login: 'another-app[bot]' } }) + await assert.rejects(f.publisher.publish(f.attempt, { repository: 'example/demo' }), /creator does not match/) + assert.deepEqual(f.counters(), { pushes: 0, creates: 0 }) +}) diff --git a/patches/ds-bot/files/test/project-github-read.test.js b/patches/ds-bot/files/test/project-github-read.test.js index 1f6d8b502..bf2f54d24 100644 --- a/patches/ds-bot/files/test/project-github-read.test.js +++ b/patches/ds-bot/files/test/project-github-read.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' import { test } from 'node:test' +import { appFixture } from './fixtures/github-app.mjs' import { createProjectGithubRead } from '../src/host/project-github-read.js' function setup() { const calls = [] @@ -103,3 +104,15 @@ test('a mutable Project cannot retarget its captured repository while credential await assert.rejects(read({projectId:'p'},{memberId:'owner'}),/repository changed/) assert.equal(calls,0) }) + +test('App project reads use minimal scoped installation tokens and never impersonate a personal user', async t => { + let calls = 0 + const f = appFixture(t, (_url, _options, permissions) => { calls++; assert.deepEqual(permissions, { issues: 'read' }); return Response.json([{ number: 1, title: 'App issue', state: 'open' }]) }) + const rt = { state: {}, config: { projectRepositories: { 'org/repo': { github: f.settings } }, personalGithub: { alice: { p: f.settings } } }, requireProjectAccess: () => ({ id: 'p', repository: 'org/repo' }) } + const read = createProjectGithubRead(rt, { request: f.request }) + const result = await read({ projectId: 'p', operation: 'issues' }, { memberId: 'alice' }) + assert.equal(result.executorLogin, 'contract-app[bot]'); assert.equal(result.identity.installationId, 12); assert.equal(calls, 1) + assert(!JSON.stringify(result).includes('ghs_')); assert(!JSON.stringify(result).includes('.pem')) + await assert.rejects(read({ projectId: 'p', scope: 'personal' }, { memberId: 'alice' }), /independent user/) + f.suspend(); await assert.rejects(read({ projectId: 'p' }, { memberId: 'alice' }), /installation/); assert.equal(calls, 1) +}) diff --git a/patches/ds-bot/files/test/project-release-github.test.js b/patches/ds-bot/files/test/project-release-github.test.js index af8b751ec..e28186033 100644 --- a/patches/ds-bot/files/test/project-release-github.test.js +++ b/patches/ds-bot/files/test/project-release-github.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' import { test } from 'node:test' +import { appFixture } from './fixtures/github-app.mjs' import { githubReleaseProvider } from '../src/host/project-release-github.js' const sha = 'a'.repeat(40), spec = { repository: 'org/repo', tag: 'v1', sha, name: 'One', notes: 'Public notes', marker: '' } const response = (value, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => value }) @@ -42,3 +43,20 @@ test('missing artifacts, rejected POSTs, lost responses and malformed published const draft = githubReleaseProvider({}, { identity, request: async url => url.includes('/releases/') ? response({ id: 2, tag_name: 'v1', draft: true }) : response({ object: { type: 'commit', sha } }) }) await assert.rejects(draft.inspect(spec), /not a verifiable published commit/) }) + +test('App Release publication and observation share exact SHA and typed external-identity proofs', async t => { + let release, creates = 0 + const f = appFixture(t, (url, options, permissions) => { + assert.deepEqual(permissions, { contents: options.method === 'POST' ? 'write' : 'read' }) + if (options.method === 'POST') { const body = JSON.parse(options.body); assert.equal(body.target_commitish, sha); creates++; release = { id: 8, tag_name: 'v1', draft: false, published_at: new Date().toISOString(), body: body.body }; return Response.json(release) } + if (url.includes('/releases/tags/')) return release ? Response.json(release) : Response.json({}, { status: 404 }) + return Response.json({ object: { type: 'commit', sha } }) + }) + const provider = githubReleaseProvider(f.settings, { request: f.request }) + assert.equal(await provider.inspect(spec), null) + await provider.publish(spec) + const proof = await provider.inspect(spec) + assert.equal(proof.sha, sha); assert.equal(proof.identity.provider, 'github-app'); assert.equal(proof.identity.installationId, 12); assert.equal(proof.executorLogin, 'contract-app[bot]'); assert.equal(creates, 1) + assert(!JSON.stringify(proof).includes('ghs_')); f.suspend() + await assert.rejects(provider.publish(spec), /installation/); assert.equal(creates, 1) +}) diff --git a/scripts/api-contracts.mjs b/scripts/api-contracts.mjs index 8cf455611..dce45160c 100644 --- a/scripts/api-contracts.mjs +++ b/scripts/api-contracts.mjs @@ -37,7 +37,7 @@ export const schemas = { Requirements: record({ revision: int(1), title: str(2000), criteria: str(2000), impact: str(2000), scope: str(2000), repository: nullable(repository) }, ['revision', 'title', 'criteria', 'impact']), TaskRecord: record({ id, projectId: nullable(id), title: str(), criteria: str(), requesterId: nullable(id), projectTaskKind: choice('code', 'decision', 'release'), status: str(), priority: str(), progress: int(0, 100), note: str(), deliveryAttemptId: id, projectTask: json, collaboration: json, result: json, createdAt: int(), updatedAt: int() }, ['id', 'title', 'status']), TaskView: record({ version: lit('project-development/v1'), projectId: id, taskId: id, title: str(1000), publication: lit('published'), phase: choice('published', 'accepted', 'cancelled', 'stopping', 'blocked', 'awaiting-owner', 'awaiting-chief', 'queued', 'starting', 'running', 'awaiting-input', 'awaiting-acceptance', 'failed', 'paused', 'publishing'), waitingFor: nullable(str()), reason: nullable(str()), botId: nullable(id), currentRole: nullable(role), generation: nullable(int()), attemptId: nullable(id), queueId: nullable(id), workerId: nullable(id), candidateSha: nullable(sha), nextActions: array(str()), evidence: json, contract: nullable(json), source: json, inputs: array(json), effects: array(ref('Effect')), execution: nullable(json), outcome: nullable(json) }, ['projectId', 'taskId', 'phase', 'nextActions']), - Attempt: record({ id, projectId: id, taskId: id, status: choice('preparing', 'running', 'waiting-acceptance', 'accepted', 'failed', 'interrupted', 'returned', 'cancelled'), protocol: str(), workflowVersion: str(), baseSha: sha, candidateSha: sha, requirement: json, workspaces: map(json), evidence: json, pr: json, release: json, roleSessions: map(json), createdAt: int(), updatedAt: int(), failure: json, sourceRefs: map(json), preview: bool }, ['id', 'projectId', 'taskId', 'status']), + Attempt: record({ id, projectId: id, taskId: id, status: choice('preparing', 'running', 'waiting-acceptance', 'accepted', 'failed', 'interrupted', 'returned', 'cancelled'), protocol: str(), workflowVersion: str(), baseSha: sha, candidateSha: sha, requirement: json, workspaces: map(json), evidence: json, pr: nullable(ref('GithubPublication')), release: json, roleSessions: map(json), createdAt: int(), updatedAt: int(), failure: json, sourceRefs: map(json), preview: bool }, ['id', 'projectId', 'taskId', 'status']), QueueEntry: record({ id, projectId: id, taskId: id, requestId, status: choice('waiting', 'starting', 'started', 'blocked', 'failed', 'cancelled'), memberId: id, reason: str(), initialTarget: json, repositoryBinding: json, attemptId: nullable(id) }, ['id', 'projectId', 'taskId', 'status']), Effect: record({ id, kind: str(), projectId: id, taskId: nullable(id), status: choice('pending', 'running', 'done', 'failed', 'cancelled'), failures: int(), dueAt: int(), error: nullable(str()), waitingFor: nullable(str()), receipt: json }, ['id', 'kind', 'status']), CommandReceipt: record({ id, name: str(), requestId, status: str(), result: json, resultRef: json, committed: bool, replayed: bool, error: json }, [],), @@ -51,8 +51,8 @@ export const schemas = { Handoff: record({ version: lit('project-handoff-contract/v1'), id, projectId: id, taskId: id, state: choice('pending', 'accepted', 'processed', 'revoked', 'invalidated', 'interrupted'), purpose: str(), source: json, target: json, initiative: json, acceptance: nullable(json), requirements: json, predecessorId: nullable(id), successorId: nullable(id), evidence: array(json), failure: nullable(json), events: array(json) }, ['id', 'projectId', 'taskId', 'state']), SourcePage: result({ text: str(), sha256: hash, offset: int(), nextOffset: nullable(int()), totalChars: int() }, ['text', 'sha256', 'offset', 'nextOffset']), LogPage: result({ text: nullable(str()), sha: nullable(sha), sha256: hash, offset: int(), nextOffset: nullable(int()), totalBytes: int(), bounded: bool, reason: str() }, ['text']), - ReleaseProof: record({ provider: lit('github'), repository, tag: str(), sha, releaseId: int(1), publishedAt: str(), url: str(), executorLogin: str(), marker: bool }, ['provider', 'repository', 'tag', 'sha', 'releaseId', 'publishedAt']), - Release: record({ id, status: choice('authorized', 'submitting', 'observed', 'awaiting-artifact', 'unknown', 'failed', 'revoked'), repository, tag: str(), sha, mode: choice('observe', 'publish'), authorizedBy: id, createdAt: int(), observedAt: nullable(int()), proof: nullable(ref('ReleaseProof')), effect: json, result: nullable(str()) }, ['id', 'status', 'repository', 'tag', 'sha', 'mode']), + ReleaseProof: record({ provider: lit('github'), repository, tag: str(), sha, releaseId: int(1), publishedAt: str(), url: str(), executorLogin: str(), identity: ref('GithubAutomationIdentity'), marker: bool }, ['provider', 'repository', 'tag', 'sha', 'releaseId', 'publishedAt']), + Release: record({ id, status: choice('authorized', 'submitting', 'observed', 'awaiting-artifact', 'unknown', 'failed', 'revoked'), repository, tag: str(), sha, mode: choice('observe', 'publish'), authorizedBy: id, authorizedAgentId: nullable(id), taskId: nullable(id), attemptId: nullable(id), createdAt: int(), observedAt: nullable(int()), proof: nullable(ref('ReleaseProof')), effect: json, result: nullable(str()) }, ['id', 'status', 'repository', 'tag', 'sha', 'mode']), ScheduleWhen: { oneOf: [obj({ kind: lit('every'), seconds: int(1) }, ['kind', 'seconds']), obj({ kind: lit('daily'), time: str(), timeZone: str() }, ['kind', 'time', 'timeZone']), obj({ kind: lit('weekly'), time: str(), timeZone: str(), weekdays: array(int(0, 6), 7) }, ['kind', 'time', 'timeZone', 'weekdays']), obj({ kind: lit('at'), at: str() }, ['kind', 'at']), obj({ kind: lit('after'), seconds: int(1) }, ['kind', 'seconds'])] }, Schedule: record({ id, projectId: id, target: json, enabled: bool, status: str(), config: json, revision: int(), scheduleId: id, error: str() }, ['id', 'projectId']), Event: record({ projectId: id, cursor: str(600), revision: int(), entityType: str(), entityId: id, type: str(), at: int(), data: json }, ['projectId', 'cursor']), @@ -84,6 +84,13 @@ schemas.AgentNotification = { oneOf: [obj({ version: lit('flybot-agent-notificat const inboxSource = 'core/src/host/project-agent-inbox.js', inboxAuth = { permission: 'own-consultation', source: inboxSource, preconditions: ['current-project-membership', 'own-active-agent-participation', 'current-principal-and-credential-generation'] } q('inbox.list', obj({ agentId: id, ...page }), result({ items: array(ref('InboxMessage')), next: nullable(id) }), inboxAuth) q('inbox.get', obj({ agentId: id, messageId: id, offset: int(), limit: int(1, 4000), expectedSha256: hash }, ['messageId']), result({ message: ref('InboxMessage'), text: str(4000), sha256: hash, offset: int(), totalChars: int(), nextOffset: nullable(int()) }), { ...inboxAuth, notes: 'UTF-16 source page of own consultation; no public SSE entry and no Task initiative. Use returned source hash on continuation.' }) +const githubIdentity = result({ provider: choice('gh-cli', 'github-app'), login: str(), appId: nullable(int(1)), installationId: nullable(int(1)), credentialIdentity: nullable(str(64)) }) +schemas.GithubAutomationIdentity = githubIdentity +schemas.GithubPublication = record({ number: int(1), url: str(), sha, repository, executorLogin: str(), credentialScope: lit('project-automation'), identity: ref('GithubAutomationIdentity'), initiatedBy: nullable(id), taskId: id, attemptId: nullable(id), candidateSha: sha, operation: lit('pull-request-publish'), executionRole: lit('project-delivery-host'), creatorLogin: nullable(str()) }, ['number', 'sha', 'repository']) +const githubStatus = record({ configured: bool, provider: nullable(choice('gh-cli', 'github-app')), login: nullable(str()), appId: nullable(int(1)), installationId: nullable(int(1)), verification: str(), recovery: str() }) +const githubConnection = record({ ...githubStatus.properties, repository, connectionId: nullable(id), revision: int(), verifiedAt: nullable(int()), identity: nullable(ref('GithubAutomationIdentity')), options: array(result({ id, provider: choice('gh-cli', 'github-app'), appId: nullable(int(1)), installationId: nullable(int(1)), login: nullable(str()) }), 100) }, ['repository', 'connectionId', 'revision', 'configured', 'provider', 'options']) +q('github.connection', obj({ repository }), result({ connection: githubConnection }), { source: 'core/src/host/project-github-connections.js', notes: 'Configuration metadata only; never returns private key path, JWT or installation token. No network probe.' }) +c('github.connection.configure', obj({ repository, connectionId: nullable(id), expectedRevision: int() }, ['connectionId', 'expectedRevision']), result({ connection: githubConnection }), { permission: 'human-manager', source: 'core/src/host/project-github-connections.js', preconditions: [...projectActive, 'signed-in-human-owner-or-maintainer', 'associated-repository', 'operator-provisioned-project-repository-connection', 'matching-selection-revision', 'current-verified-installation-or-gh-login'], effects: ['project-repository-credential-selection-committed', 'null-explicitly-disables-without-fallback'], notes: 'Selects an existing connection ID; never accepts keys, URLs or tokens. Bot/watch authority cannot select or replace credentials. May perform read-only authentication verification.' }) q('inbox.connection', obj({ agentId: id }), result({ connection: nullable(ref('InboxConnection')) }), inboxAuth) const inboxTarget = { agentId: id, messageId: id, expectedVersion: hash }, inboxLease = { ...inboxTarget, claimId: id, generation: int(1) } for (const name of ['inbox.claim', 'inbox.renew']) c(name, obj(name === 'inbox.claim' ? inboxTarget : inboxLease, name === 'inbox.claim' ? ['messageId'] : ['messageId', 'claimId', 'generation']), result({ message: ref('InboxMessage'), claim: ref('InboxClaim') }), { ...inboxAuth, preconditions: [...inboxAuth.preconditions, ...(name === 'inbox.claim' ? ['no-other-live-message-claim', 'expired-native-claim-requires-confirmed-quiescence'] : ['exact-live-claim-principal-generation-and-expiry'])], effects: ['durable-private-message-processing-lease-updated-no-task-initiative'], notes: 'Same application service for native and external personal agents. Lease duration is configured; message ownership is not a Task executor identity.' }) @@ -213,8 +220,8 @@ c('comment.publish', obj({ issueNumber: int(1), text: str(10000), scope: lit('pe n('task-update', obj({ id, title: str(300), criteria: str(2000), impact: str(2000), scope: str(2000), priority: taskFields.priority, progress: int(0, 100), note: str(), column: choice('not-started', 'in-progress', 'done') }, ['id']), ref('TaskRecord'), { permission: 'task-write', source: 'core/src/host/tasks.js', preconditions: [...projectActive, 'own-task-or-project-manager', 'project-requirements-unchanged-without-chief', 'column-transition-valid-for-task-contract'], effects: ['task-editable-metadata-updated'], idempotency: 'legacy-update-no-unified-command-receipt', notes: 'New Task requirements changes use v1 task.change.request. This native interface cannot impersonate Chief or close new Tasks.' }) n('task-cancel', obj({ id }, ['id']), ref('TaskRecord'), { permission: 'task-write', source: 'core/src/host/tasks.js', preconditions: [...projectActive, 'task-requester', 'legacy-task-or-chief-approved-lifecycle'], effects: ['legacy-task-cancelled-if-transition-allowed'], idempotency: 'legacy-transition', notes: 'Use v1 task.change.request action=cancel for new Chief-governed Tasks.' }) n('task-answer-input', obj({ id, questionId: id, answer: str(2000) }, ['id', 'questionId', 'answer']), ref('TaskRecord'), { permission: 'task-write', source: 'core/src/host/tasks.js', preconditions: [...projectActive, 'task-requester', 'exact-current-task-input-question', 'no-pending-approval'], effects: ['task-input-answer-recorded-and-working-resumed'], idempotency: 'exact-pending-question-once' }) -n('project-credential-status', obj(projectId, ['projectId']), result({ personal: result({ configured: bool, login: nullable(str()) }), automation: result({ configured: bool }) }), { source: 'core/src/host/project-credentials.js', notes: 'No token/password/config path is returned. Native FBA gate requires watch even though domain action is read.' }) -n('project-github-read', obj({ ...projectId, operation: choice('issues', 'pulls', 'pull', 'reviews', 'checks', 'thread-events'), number: int(1), scope: choice('personal', 'project'), threadKind: choice('issue', 'pull'), headSha: sha, pages: obj({ comments: int(1, 1000000), reviews: int(1, 1000000), inline: int(1, 1000000), checks: int(1, 1000000) }) }, ['projectId'], { allOf: [{ if: { properties: { operation: choice('pull', 'reviews', 'checks', 'thread-events') }, required: ['operation'] }, then: { required: ['number'] } }, { if: { properties: { operation: lit('thread-events') }, required: ['operation'] }, then: { required: ['threadKind'] } }] }), result({ repository, operation: str(), scope: choice('personal', 'project'), executorLogin: str(), at: int(), limit: lit(20), pagination: lit('bounded-first-page'), data: json }), { source: 'core/src/host/project-github-read.js', preconditions: ['current-project-membership', 'configured-explicit-scoped-github-credential', 'verified-selected-github-login'], notes: 'Only current default repository; bounded upstream GitHub projection. data varies by operation and is external untrusted JSON. Native FBA gate currently requires watch.' }) +n('project-credential-status', obj(projectId, ['projectId']), result({ personal: result({ configured: bool, login: nullable(str()) }), automation: githubStatus }), { source: 'core/src/host/project-credentials.js', notes: 'No token/password/config path is returned. Native FBA gate requires watch even though domain action is read.' }) +n('project-github-read', obj({ ...projectId, operation: choice('issues', 'pulls', 'pull', 'reviews', 'checks', 'thread-events'), number: int(1), scope: choice('personal', 'project'), threadKind: choice('issue', 'pull'), headSha: sha, pages: obj({ comments: int(1, 1000000), reviews: int(1, 1000000), inline: int(1, 1000000), checks: int(1, 1000000) }) }, ['projectId'], { allOf: [{ if: { properties: { operation: choice('pull', 'reviews', 'checks', 'thread-events') }, required: ['operation'] }, then: { required: ['number'] } }, { if: { properties: { operation: lit('thread-events') }, required: ['operation'] }, then: { required: ['threadKind'] } }] }), result({ repository, operation: str(), scope: choice('personal', 'project'), executorLogin: str(), identity: ref('GithubAutomationIdentity'), at: int(), limit: lit(20), pagination: lit('bounded-first-page'), data: json }), { source: 'core/src/host/project-github-read.js', preconditions: ['current-project-membership', 'configured-explicit-scoped-github-credential', 'verified-selected-github-login'], notes: 'Only current default repository; bounded upstream GitHub projection. data varies by operation and is external untrusted JSON. Native FBA gate currently requires watch.' }) export const personal = new Map(), workflows = new Map() const personalOp = (name, request, response, options = {}) => add(personal, name, obj({ operation: lit(name), ...request.properties }, ['operation', ...(request.required ?? [])], { ...request, properties: { operation: lit(name), ...request.properties }, required: ['operation', ...(request.required ?? [])] }), response, { ...human, source: 'core/src/host/channel.js', preconditions: ['human-session', 'owned-personal-bot-or-visible-original-group'], ...options }) diff --git a/scripts/openapi.mjs b/scripts/openapi.mjs index ebc3d5408..07ee783b4 100644 --- a/scripts/openapi.mjs +++ b/scripts/openapi.mjs @@ -54,6 +54,7 @@ const permissionRules = { accept: { humanRoles: ['owner', 'maintainer'], condition: 'legacy-only; new Task completion requires host-authenticated Chief' }, 'run-control': { humanRoles: ['owner', 'maintainer'], condition: 'current-manager-and-active-project' }, 'task-write': { humanRoles: ['owner', 'maintainer', 'member'], condition: 'current-manager-or-task-requester; current-Task-contract-still-applies' }, + 'human-manager': { humanRoles: ['owner', 'maintainer'], condition: 'current-signed-in-human-manager; no-agent-or-watch-credential' }, 'human-self': { humanRoles: ['owner', 'maintainer', 'member'], condition: 'human-only-and-own-personal-agent-or-session' }, 'human-owner': { humanRoles: ['owner'], condition: 'Owner-personally; primary-owner-required-by-legacy-lifecycle' }, 'human-conversation': { humanRoles: ['owner', 'maintainer', 'member'], condition: 'human-only; own-private-or-explicitly-approved-public-conversation' }, @@ -75,7 +76,7 @@ function access(entry, endpoint, kind, name) { let safe = false, watch = false if (endpoint === 'project-development') { safe = kind === 'query' || ['consultation.ask', 'consultation.reply', 'inbox.claim', 'inbox.renew', 'inbox.complete', 'inbox.fail'].includes(name) - watch = kind === 'query' || !['agent.mode', 'proposal.publish'].includes(name) + watch = kind === 'query' || !['agent.mode', 'proposal.publish', 'inbox.connection.configure', 'github.connection.configure'].includes(name) if (entry.permission === 'configure') safe = false // downstream gate, even for a query } else { safe = inv.fbaReads.includes(endpoint) || ['auth-whoami', 'auth-revoke', 'personal-agent-propose-task', 'project-close-request', 'project-consultation-ask', 'project-consultation-reply', 'project-chief-question', 'project-agents'].includes(endpoint)