From 2ea3b065579944e25bdec17506c8f6733a62c7fd Mon Sep 17 00:00:00 2001 From: Samuel Date: Tue, 28 Jul 2026 09:27:51 +0200 Subject: [PATCH 1/3] Implement cockpit domain and governance foundations --- docs/cockpit.md | 113 ++++++++++++++++++++ lib/__tests__/cockpit-domain.test.mjs | 142 ++++++++++++++++++++++++++ lib/cockpit-actions.mjs | 88 ++++++++++++++++ lib/cockpit-auth.mjs | 112 ++++++++++++++++++++ lib/cockpit-domain.mjs | 138 +++++++++++++++++++++++++ lib/cockpit-markdown.mjs | 65 ++++++++++++ 6 files changed, 658 insertions(+) create mode 100644 docs/cockpit.md create mode 100644 lib/__tests__/cockpit-domain.test.mjs create mode 100644 lib/cockpit-actions.mjs create mode 100644 lib/cockpit-auth.mjs create mode 100644 lib/cockpit-domain.mjs create mode 100644 lib/cockpit-markdown.mjs diff --git a/docs/cockpit.md b/docs/cockpit.md new file mode 100644 index 0000000..aed4800 --- /dev/null +++ b/docs/cockpit.md @@ -0,0 +1,113 @@ +# AgentFlow Cockpit + +AgentFlow Cockpit is the planned optional UI for goal-oriented SDLC delivery. It presents GitHub issues, epics, comments, PRs, role-pass evidence, validation, and follow-ups as AgentFlow workflow state instead of a generic issue list. + +Cockpit is optional. The CLI/GitHub workflow remains authoritative and fully usable without Cockpit. + +## Product principles + +- GitHub is durable truth: issues, comments, PR bodies, commits, closure metadata. +- Local or runner telemetry is optional and non-authoritative. +- Single-agent, multi-role execution remains default. +- Helpers are advisory unless role attribution proves a valid multi-agent handoff. +- High-assurance work still requires human security/acceptance review. +- Follow-up issues are first-class; hidden TODOs are not. +- No raw model transcripts, prompts, tool inputs, secrets, or hidden runner state in the UI. + +## MVP authentication + +Remote-capable Cockpit uses GitHub OAuth as the MVP authentication option. + +Required controls: + +- GitHub OAuth login. +- Allowlisted users, orgs, or teams. +- Repository permission checks for each managed repo. +- Secure sessions and logout. +- CSRF and origin checks for write-capable actions. +- Audit log for every guarded action. +- Remote mode defaults read-only until write actions are explicitly enabled. + +## Project linking + +For MVP, a Cockpit project is a registered GitHub repository, for example `smota/agentflow-sdlc`. + +Cockpit reads: + +- `AGENTS.md` +- `agent-workflow.config.json` +- issue bodies and labels +- issue comments and markers +- PR bodies and checks +- commits and closure metadata + +Cockpit does not require a mounted repo checkout in remote mode. Future Docker deployment should use repository allowlists and GitHub API first; local runner telemetry can be added later as sanitized optional events. + +## Opinionated views + +### Goal Board + +Shows goals grouped by workflow state: Intake, Scoped, Planned, Implementation, Validation, Review, PR Ready, Done, and Blocked. + +### Epic View + +Rolls up child issues, dependencies, acceptance matrix, risk, active workstreams, PRs, follow-ups, and decisions. + +### Issue SDLC View + +Shows phases 0-8: + +1. Product manager / JTBD +2. Analyst +3. Architect +4. Developer planning +5. Developer +6. Tester +7. Review +8. Tech writer +9. PR readiness + +Each phase shows role-pass completeness, evidence, validation, handover, and next role contract. + +### Evidence Health + +Reports governance completeness, not model confidence: + +- Required policy read state. +- Acceptance criteria. +- Role-pass fields. +- Validation status. +- Review independence. +- Human gate requirements. +- PR manifest readiness. +- Follow-up disposition. + +### Comments lanes + +Comments are grouped by workflow purpose using markers/templates: + +- Workflow Status +- Handover +- Decisions +- Clarifications +- Review Findings +- Validation +- Follow-ups + +## Guarded actions + +Write-capable actions are structured and auditable: + +- add clarification +- request checkpoint +- request human review +- draft follow-up issue +- update issue section +- post handover +- start next phase + +Forbidden actions include remote gate bypass, marking validation/review passed, weakening acceptance criteria silently, deleting evidence, and merge-by-chat. + +## Future Docker deployment + +Docker is a second-pass packaging option, not an MVP dependency. Current implementation should stay Docker-ready by using environment configuration, stateless server boundaries, GitHub API as primary data source, `/healthz`, and no mandatory local repo mount. diff --git a/lib/__tests__/cockpit-domain.test.mjs b/lib/__tests__/cockpit-domain.test.mjs new file mode 100644 index 0000000..0cfc8b5 --- /dev/null +++ b/lib/__tests__/cockpit-domain.test.mjs @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { + classifyIssue, + createCockpitProject, + derivePhaseState, + missingRolePassFieldsFor, +} from '../cockpit-domain.mjs' +import { + classifyCommentLane, + extractAgentFlowIssueSections, + groupCommentsByLane, +} from '../cockpit-markdown.mjs' +import { authorizeCockpitUser, createAuthPolicy, validateAllowedOrigin } from '../cockpit-auth.mjs' +import { evaluateGuardedAction, parseCockpitIntent } from '../cockpit-actions.mjs' + +describe('cockpit domain', () => { + it('creates github-backed project without local checkout requirement', () => { + expect( + createCockpitProject({ repo: 'https://github.com/smota/agentflow-sdlc.git' }), + ).toMatchObject({ + id: 'smota/agentflow-sdlc', + durableTruth: 'github', + localTelemetry: 'optional', + }) + }) + + it('classifies AgentFlow-managed epic issues', () => { + expect( + classifyIssue({ + labels: ['epic', 'drafted-by:pi'], + body: '## Feature Tracking\n- [ ] #120', + }), + ).toMatchObject({ isEpic: true, isAgentFlowManaged: true }) + }) + + it('derives next safe action from evidence and review state', () => { + expect(derivePhaseState({ rolePasses: [{ phase: '5', role: 'tester' }] }).nextSafeAction).toBe( + 'complete-role-pass-evidence', + ) + expect( + derivePhaseState({ + rolePasses: [completeRolePass()], + validations: [{ status: 'failed' }], + }).nextSafeAction, + ).toBe('return-to-developer') + expect( + derivePhaseState({ + rolePasses: [completeRolePass()], + validations: [{ status: 'passed' }], + reviewFindings: [{ severity: 'blocker' }], + }).nextSafeAction, + ).toBe('resolve-review-findings') + }) + + it('checks required role-pass fields', () => { + expect(missingRolePassFieldsFor(completeRolePass())).toEqual([]) + }) +}) + +describe('cockpit markdown mapping', () => { + it('extracts canonical issue sections', () => { + const sections = extractAgentFlowIssueSections( + '## Requirements\n- one\n\n## Test plan\n- run tests', + ) + expect(sections.Requirements).toContain('- one') + expect(sections['Test plan']).toContain('run tests') + }) + + it('groups workflow comments into AgentFlow lanes', () => { + const grouped = groupCommentsByLane([ + { body: '\nnext role' }, + { body: '\npassed' }, + ]) + expect(grouped.Handover).toHaveLength(1) + expect(grouped.Validation).toHaveLength(1) + expect(classifyCommentLane({ body: 'Decision: keep GitHub truth' })).toBe('Decisions') + }) +}) + +describe('cockpit auth and guarded actions', () => { + it('enforces GitHub allowlist and repo permission', () => { + const policy = createAuthPolicy({ + allowedUsers: ['samue'], + repositories: ['smota/agentflow-sdlc'], + }) + expect( + authorizeCockpitUser({ + user: { login: 'samue' }, + repo: 'smota/agentflow-sdlc', + repoPermission: 'read', + policy, + }).ok, + ).toBe(true) + expect( + authorizeCockpitUser({ + user: { login: 'other' }, + repo: 'smota/agentflow-sdlc', + repoPermission: 'read', + policy, + }).reasons, + ).toContain('user-not-allowed') + }) + + it('validates allowed origins', () => { + expect( + validateAllowedOrigin({ + origin: 'https://cockpit.example.com', + publicUrl: 'https://cockpit.example.com/', + }), + ).toBe(true) + expect( + validateAllowedOrigin({ + origin: 'https://evil.example.com', + publicUrl: 'https://cockpit.example.com', + }), + ).toBe(false) + }) + + it('blocks remote gate bypass and requires confirmations', () => { + expect( + evaluateGuardedAction({ action: 'merge-pr', userRole: 'admin', confirmation: true }).reasons, + ).toContain('forbidden-by-policy') + expect( + evaluateGuardedAction({ action: 'post-handover', userRole: 'operator' }).reasons, + ).toContain('confirmation-required') + expect(parseCockpitIntent({ type: 'draft-follow-up', issue: 123 })).toMatchObject({ + ok: true, + intent: { requiresDurableEvidence: true }, + }) + }) +}) + +function completeRolePass() { + return { + phase: '5', + role: 'tester', + read: ['AGENTS.md'], + decisions: ['run validation'], + uncertainties: ['none'], + nextRoleContract: 'review findings', + } +} diff --git a/lib/cockpit-actions.mjs b/lib/cockpit-actions.mjs new file mode 100644 index 0000000..0ae2843 --- /dev/null +++ b/lib/cockpit-actions.mjs @@ -0,0 +1,88 @@ +export const COCKPIT_INTENTS = [ + 'ask-status', + 'add-clarification', + 'request-checkpoint', + 'request-human-review', + 'draft-follow-up', + 'update-issue-section', + 'post-handover', + 'start-next-phase', +] + +export const FORBIDDEN_REMOTE_ACTIONS = [ + 'mark-validation-passed', + 'mark-review-passed', + 'bypass-human-gate', + 'merge-pr', + 'weaken-acceptance-criteria', + 'delete-evidence', +] + +export function parseCockpitIntent(input = {}) { + const type = input.type || input.intent + if (!COCKPIT_INTENTS.includes(type)) { + return { ok: false, errors: [`unknown-intent:${type || 'missing'}`] } + } + return { + ok: true, + intent: { + type, + repo: input.repo, + issue: input.issue, + body: input.body || '', + evidenceRefs: input.evidenceRefs || [], + requiresDurableEvidence: MATERIAL_INTENTS.has(type), + }, + } +} + +export function evaluateGuardedAction({ + action, + userRole = 'viewer', + highAssurance = false, + confirmation = false, +} = {}) { + const reasons = [] + if (FORBIDDEN_REMOTE_ACTIONS.includes(action)) reasons.push('forbidden-by-policy') + if (WRITE_ACTIONS.has(action) && !['operator', 'maintainer', 'admin'].includes(userRole)) + reasons.push('role-cannot-write') + if (ADMIN_ACTIONS.has(action) && !['maintainer', 'admin'].includes(userRole)) + reasons.push('role-cannot-admin') + if (highAssurance && HIGH_ASSURANCE_BLOCKED_ACTIONS.has(action)) + reasons.push('high-assurance-human-gate-required') + if ((WRITE_ACTIONS.has(action) || ADMIN_ACTIONS.has(action)) && !confirmation) + reasons.push('confirmation-required') + return { ok: reasons.length === 0, reasons } +} + +export function createActionPreview({ action, target, summary, durableEffect } = {}) { + return { + action, + target, + summary, + durableEffect, + requiresConfirmation: WRITE_ACTIONS.has(action) || ADMIN_ACTIONS.has(action), + } +} + +const MATERIAL_INTENTS = new Set([ + 'add-clarification', + 'request-checkpoint', + 'request-human-review', + 'draft-follow-up', + 'update-issue-section', + 'post-handover', + 'start-next-phase', +]) + +const WRITE_ACTIONS = new Set([ + 'add-clarification', + 'request-human-review', + 'draft-follow-up', + 'update-issue-section', + 'post-handover', +]) + +const ADMIN_ACTIONS = new Set(['start-next-phase']) + +const HIGH_ASSURANCE_BLOCKED_ACTIONS = new Set(['start-next-phase']) diff --git a/lib/cockpit-auth.mjs b/lib/cockpit-auth.mjs new file mode 100644 index 0000000..bb35336 --- /dev/null +++ b/lib/cockpit-auth.mjs @@ -0,0 +1,112 @@ +import { normalizeRepoId } from './cockpit-domain.mjs' + +const PERMISSION_RANK = { none: 0, read: 1, triage: 2, write: 3, maintain: 4, admin: 5 } + +export function parseAllowlist(value = '') { + return String(value) + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) +} + +export function createAuthPolicy({ + allowedUsers = [], + allowedOrgs = [], + allowedTeams = [], + repositories = [], +} = {}) { + return { + allowedUsers: allowedUsers.map((user) => user.toLowerCase()), + allowedOrgs: allowedOrgs.map((org) => org.toLowerCase()), + allowedTeams: allowedTeams.map((team) => team.toLowerCase()), + repositories: repositories.map(normalizeRepoId), + } +} + +export function authorizeCockpitUser({ + user = {}, + repo, + repoPermission = 'none', + policy = {}, + requiredPermission = 'read', +} = {}) { + const login = user.login?.toLowerCase() + const repoId = normalizeRepoId(repo) + const reasons = [] + + if (!login) reasons.push('missing-github-user') + if (policy.repositories?.length && !policy.repositories.includes(repoId)) + reasons.push('repo-not-registered') + + const userAllowed = !policy.allowedUsers?.length || policy.allowedUsers.includes(login) + const orgAllowed = + !policy.allowedOrgs?.length || + (user.orgs || []) + .map((org) => org.toLowerCase()) + .some((org) => policy.allowedOrgs.includes(org)) + const teamAllowed = + !policy.allowedTeams?.length || + (user.teams || []) + .map((team) => team.toLowerCase()) + .some((team) => policy.allowedTeams.includes(team)) + + if (!userAllowed) reasons.push('user-not-allowed') + if (!orgAllowed) reasons.push('org-not-allowed') + if (!teamAllowed) reasons.push('team-not-allowed') + + if (PERMISSION_RANK[repoPermission] < PERMISSION_RANK[requiredPermission]) { + reasons.push('insufficient-repo-permission') + } + + return { + ok: reasons.length === 0, + reasons, + actor: login, + repo: repoId, + requiredPermission, + repoPermission, + } +} + +export function secureCookieOptions({ + https = true, + sameSite = 'lax', + maxAgeSeconds = 8 * 60 * 60, +} = {}) { + return { + httpOnly: true, + secure: Boolean(https), + sameSite, + path: '/', + maxAge: maxAgeSeconds, + } +} + +export function validateAllowedOrigin({ origin, publicUrl, extraOrigins = [] } = {}) { + if (!origin) return false + const allowed = new Set( + [publicUrl, ...extraOrigins].filter(Boolean).map((value) => String(value).replace(/\/$/, '')), + ) + return allowed.has(String(origin).replace(/\/$/, '')) +} + +export function createAuditEvent({ + actor, + action, + target, + allowed, + reason = [], + resultUrl, + previewSummary, +} = {}) { + return { + type: 'cockpit.audit', + actor, + action, + target, + allowed: Boolean(allowed), + reason, + resultUrl, + previewSummary, + } +} diff --git a/lib/cockpit-domain.mjs b/lib/cockpit-domain.mjs new file mode 100644 index 0000000..a9e1782 --- /dev/null +++ b/lib/cockpit-domain.mjs @@ -0,0 +1,138 @@ +export const COCKPIT_PHASES = [ + { index: 0, slug: 'product-manager-jtbd', label: 'Product manager / JTBD' }, + { index: 1, slug: 'analyst', label: 'Analyst' }, + { index: 2, slug: 'architect', label: 'Architect' }, + { index: 3, slug: 'developer-planning', label: 'Developer planning' }, + { index: 4, slug: 'developer', label: 'Developer' }, + { index: 5, slug: 'tester', label: 'Tester' }, + { index: 6, slug: 'review', label: 'Review' }, + { index: 7, slug: 'tech-writer', label: 'Tech writer' }, + { index: 8, slug: 'pr-readiness', label: 'PR readiness' }, +] + +export const COCKPIT_COMMENT_MARKERS = { + workflowStatus: 'agentflow:workflow-status', + handover: 'agent-handover', + rolePass: 'agentflow:role-pass', + humanReviewRequest: 'agentflow:human-review-request', + validationSummary: 'agentflow:validation-summary', + followUpProposal: 'agentflow:follow-up-proposal', +} + +export const COCKPIT_COMMENT_LANES = [ + 'Workflow Status', + 'Handover', + 'Decisions', + 'Clarifications', + 'Review Findings', + 'Validation', + 'Follow-ups', +] + +export function normalizeRepoId(repo) { + if (typeof repo !== 'string') return '' + return repo + .trim() + .replace(/^https:\/\/github\.com\//, '') + .replace(/\.git$/, '') +} + +export function createCockpitProject({ + repo, + defaultBranch = 'development', + authPolicy = {}, +} = {}) { + const id = normalizeRepoId(repo) + if (!/^[^/\s]+\/[^/\s]+$/.test(id)) { + throw new Error('Cockpit project repo must be owner/name') + } + return { + id, + githubRepo: id, + defaultBranch, + configSource: 'agent-workflow.config.json', + durableTruth: 'github', + localTelemetry: 'optional', + authPolicy, + } +} + +export function classifyIssue({ labels = [], body = '', comments = [] } = {}) { + const labelNames = labels + .map((label) => (typeof label === 'string' ? label : label.name)) + .filter(Boolean) + const markerText = [body, ...comments.map((comment) => comment.body || '')].join('\n') + return { + isEpic: labelNames.includes('epic') || /## Feature Tracking/i.test(body), + isAgentFlowManaged: + labelNames.some((name) => name.startsWith('drafted-by:') || name === 'agentflow:managed') || + markerText.includes(COCKPIT_COMMENT_MARKERS.workflowStatus) || + markerText.includes(COCKPIT_COMMENT_MARKERS.handover), + labels: labelNames, + } +} + +export function derivePhaseState({ + issue = {}, + rolePasses = [], + validations = [], + reviewFindings = [], +} = {}) { + const phaseLabel = (issue.labels || []) + .map((label) => (typeof label === 'string' ? label : label.name)) + .find((name) => name?.startsWith('phase:')) + const currentPhase = phaseLabel + ? phaseLabel.slice('phase:'.length) + : inferCurrentPhase(rolePasses) + const missingRolePassFields = rolePasses.flatMap((pass) => missingRolePassFieldsFor(pass)) + const failingValidations = validations.filter((validation) => validation.status === 'failed') + const blockingReviewFindings = reviewFindings.filter( + (finding) => finding.severity === 'blocker' || finding.status === 'blocking', + ) + return { + currentPhase, + rolePassComplete: missingRolePassFields.length === 0, + missingRolePassFields, + validationStatus: failingValidations.length + ? 'failed' + : validations.length + ? 'passed' + : 'missing', + reviewStatus: blockingReviewFindings.length + ? 'blocked' + : reviewFindings.length + ? 'findings' + : 'unknown', + nextSafeAction: deriveNextSafeAction({ + currentPhase, + missingRolePassFields, + failingValidations, + blockingReviewFindings, + }), + } +} + +function inferCurrentPhase(rolePasses) { + const last = rolePasses.at(-1) + return last?.phaseSlug || last?.role || 'intake' +} + +export function missingRolePassFieldsFor(pass = {}) { + return ['phase', 'role', 'read', 'decisions', 'uncertainties', 'nextRoleContract'].filter( + (field) => + pass[field] == null || + pass[field] === '' || + (Array.isArray(pass[field]) && pass[field].length === 0), + ) +} + +function deriveNextSafeAction({ + missingRolePassFields, + failingValidations, + blockingReviewFindings, +}) { + if (missingRolePassFields.length) return 'complete-role-pass-evidence' + if (failingValidations.length) return 'return-to-developer' + if (blockingReviewFindings.length) return 'resolve-review-findings' + return 'advance-next-phase' +} diff --git a/lib/cockpit-markdown.mjs b/lib/cockpit-markdown.mjs new file mode 100644 index 0000000..4e18b1d --- /dev/null +++ b/lib/cockpit-markdown.mjs @@ -0,0 +1,65 @@ +import { COCKPIT_COMMENT_MARKERS } from './cockpit-domain.mjs' +import { extractSection } from './markdown-sections.mjs' + +export function extractAgentFlowIssueSections(body = '') { + const headings = [ + 'Background & Problem Statement', + 'Proposed Solution', + 'Requirements', + 'Technical Design', + 'Acceptance criteria', + 'Open questions', + 'Feature Tracking', + 'Test plan', + 'Workflow classification', + ] + return Object.fromEntries(headings.map((heading) => [heading, safeGetSection(body, heading)])) +} + +export function extractCommentMarkers(body = '') { + const markerRegex = //g + return [...body.matchAll(markerRegex)].map((match) => match[1].trim()) +} + +export function classifyCommentLane(comment = {}) { + const body = comment.body || '' + const markers = extractCommentMarkers(body) + if (markers.includes(COCKPIT_COMMENT_MARKERS.workflowStatus)) return 'Workflow Status' + if (markers.includes(COCKPIT_COMMENT_MARKERS.handover)) return 'Handover' + if (markers.includes(COCKPIT_COMMENT_MARKERS.humanReviewRequest)) return 'Review Findings' + if (markers.includes(COCKPIT_COMMENT_MARKERS.validationSummary)) return 'Validation' + if (markers.includes(COCKPIT_COMMENT_MARKERS.followUpProposal)) return 'Follow-ups' + if (/clarification|question answered/i.test(body)) return 'Clarifications' + if (/decision|adr|decided/i.test(body)) return 'Decisions' + return 'Workflow Status' +} + +export function groupCommentsByLane(comments = []) { + return comments.reduce((lanes, comment) => { + const lane = classifyCommentLane(comment) + lanes[lane] ??= [] + lanes[lane].push(comment) + return lanes + }, {}) +} + +export function extractIssueRelationships({ body = '', comments = [], pullRequests = [] } = {}) { + const text = [body, ...comments.map((comment) => comment.body || '')].join('\n') + const issueRefs = [...new Set([...text.matchAll(/#(\d+)/g)].map((match) => Number(match[1])))] + const followUps = [...text.matchAll(/follow-up[^#]*(#\d+)/gi)].map((match) => + Number(match[1].slice(1)), + ) + return { + issueRefs, + followUps: [...new Set(followUps)], + pullRequests: pullRequests.map((pr) => ({ number: pr.number, url: pr.url, state: pr.state })), + } +} + +function safeGetSection(body, heading) { + try { + return extractSection(body, heading) || '' + } catch { + return '' + } +} From 4f55f67a961ecced89b5b185421a1c3f3a851c7a Mon Sep 17 00:00:00 2001 From: Samuel Date: Tue, 28 Jul 2026 09:45:56 +0200 Subject: [PATCH 2/3] Add cockpit read-only server and goal views --- docs/cockpit.md | 32 ++- lib/__tests__/cockpit-config-github.test.mjs | 50 +++++ lib/__tests__/cockpit-read-model.test.mjs | 55 ++++++ lib/cockpit-config.mjs | 62 ++++++ lib/cockpit-github.mjs | 70 +++++++ lib/cockpit-read-model.mjs | 146 ++++++++++++++ lib/cockpit-ui.mjs | 68 +++++++ package.json | 1 + scripts/cockpit-server.mjs | 197 +++++++++++++++++++ 9 files changed, 680 insertions(+), 1 deletion(-) create mode 100644 lib/__tests__/cockpit-config-github.test.mjs create mode 100644 lib/__tests__/cockpit-read-model.test.mjs create mode 100644 lib/cockpit-config.mjs create mode 100644 lib/cockpit-github.mjs create mode 100644 lib/cockpit-read-model.mjs create mode 100644 lib/cockpit-ui.mjs create mode 100644 scripts/cockpit-server.mjs diff --git a/docs/cockpit.md b/docs/cockpit.md index aed4800..b5ed41c 100644 --- a/docs/cockpit.md +++ b/docs/cockpit.md @@ -108,6 +108,36 @@ Write-capable actions are structured and auditable: Forbidden actions include remote gate bypass, marking validation/review passed, weakening acceptance criteria silently, deleting evidence, and merge-by-chat. +## Running the MVP server + +Cockpit currently ships as a minimal Node service: + +```bash +AGENTFLOW_REPOSITORIES=smota/agentflow-sdlc \ +GITHUB_TOKEN=ghp_readonly_or_fine_grained_token \ +pnpm cockpit +``` + +Remote mode requires GitHub OAuth configuration: + +```bash +COCKPIT_REMOTE=true \ +COCKPIT_PUBLIC_URL=https://cockpit.example.com \ +COCKPIT_SESSION_SECRET=32-plus-character-secret \ +GITHUB_CLIENT_ID=... \ +GITHUB_CLIENT_SECRET=... \ +GITHUB_ALLOWED_USERS=samue \ +AGENTFLOW_REPOSITORIES=smota/agentflow-sdlc \ +pnpm cockpit +``` + +Available surfaces: + +- `/healthz` — health check. +- `/` — Goal Board for registered repository. +- `/issues/` — issue SDLC view. +- `/login`, `/oauth/callback`, `/logout` — GitHub OAuth flow for remote mode. + ## Future Docker deployment -Docker is a second-pass packaging option, not an MVP dependency. Current implementation should stay Docker-ready by using environment configuration, stateless server boundaries, GitHub API as primary data source, `/healthz`, and no mandatory local repo mount. +Docker is a second-pass packaging option, not an MVP dependency. Current implementation stays Docker-ready by using environment configuration, stateless server boundaries, GitHub API as primary data source, `/healthz`, and no mandatory local repo mount. diff --git a/lib/__tests__/cockpit-config-github.test.mjs b/lib/__tests__/cockpit-config-github.test.mjs new file mode 100644 index 0000000..c5f9237 --- /dev/null +++ b/lib/__tests__/cockpit-config-github.test.mjs @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { loadCockpitConfig, validateCockpitConfig } from '../cockpit-config.mjs' +import { createGitHubClient, loadRepositoryPermission } from '../cockpit-github.mjs' + +describe('cockpit config', () => { + it('requires OAuth/session config for remote mode', () => { + const config = loadCockpitConfig({ + COCKPIT_REMOTE: 'true', + AGENTFLOW_REPOSITORIES: 'smota/agentflow-sdlc', + }) + expect(validateCockpitConfig(config).errors).toEqual( + expect.arrayContaining([ + 'GITHUB_CLIENT_ID required for remote mode', + 'GITHUB_CLIENT_SECRET required for remote mode', + ]), + ) + }) + + it('accepts local read mode with registered repositories', () => { + const result = validateCockpitConfig( + loadCockpitConfig({ AGENTFLOW_REPOSITORIES: 'smota/agentflow-sdlc' }), + ) + expect(result.ok).toBe(true) + }) +}) + +describe('cockpit github client', () => { + it('sends bearer token and parses repo permission', async () => { + const calls = [] + const client = createGitHubClient({ + token: 'test-token', + fetchImpl: async (url, options) => { + calls.push({ url, options }) + return response({ permissions: { pull: true, push: false } }) + }, + }) + await expect(loadRepositoryPermission({ client, repo: 'smota/agentflow-sdlc' })).resolves.toBe( + 'read', + ) + expect(calls[0].options.headers.Authorization).toBe('Bearer test-token') + }) +}) + +function response(data, ok = true, status = 200) { + return { + ok, + status, + text: async () => JSON.stringify(data), + } +} diff --git a/lib/__tests__/cockpit-read-model.test.mjs b/lib/__tests__/cockpit-read-model.test.mjs new file mode 100644 index 0000000..37cda45 --- /dev/null +++ b/lib/__tests__/cockpit-read-model.test.mjs @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { buildCockpitIssueView, buildEpicRollup, buildGoalBoard } from '../cockpit-read-model.mjs' + +describe('cockpit read model', () => { + it('builds opinionated issue SDLC view from GitHub issue shape', () => { + const view = buildCockpitIssueView({ + issue: managedIssue({ number: 122, title: 'Read-only MVP' }), + comments: [{ body: '\nNext: tester' }], + }) + expect(view.number).toBe(122) + expect(view.classification.isAgentFlowManaged).toBe(true) + expect(view.commentLanes.Handover).toHaveLength(1) + expect(view.evidenceHealth.status).toBe('complete') + }) + + it('builds goal board columns with blocked evidence surfaced', () => { + const board = buildGoalBoard({ + issues: [ + managedIssue({ number: 1, title: 'Good' }), + { number: 2, title: 'Missing', labels: [], body: '' }, + ], + }) + expect(board.Scoped).toHaveLength(1) + expect(board.Blocked).toHaveLength(1) + }) + + it('rolls up epic child evidence', () => { + const rollup = buildEpicRollup({ + epic: managedIssue({ + number: 119, + title: 'Cockpit', + labels: [{ name: 'epic' }, { name: 'drafted-by:pi' }], + }), + childIssues: [ + managedIssue({ number: 120, title: 'Domain' }), + { number: 121, title: 'Auth', labels: [], body: '' }, + ], + }) + expect(rollup.totals.children).toBe(2) + expect(rollup.totals.blocked).toBe(1) + expect(rollup.totals.evidenceComplete).toBe(1) + }) +}) + +function managedIssue({ number, title, labels = [{ name: 'drafted-by:pi' }], body } = {}) { + return { + number, + title, + state: 'open', + labels, + body: + body ?? + '## Acceptance criteria\n- [ ] done\n\n## Test plan\n- pnpm test\n\n', + } +} diff --git a/lib/cockpit-config.mjs b/lib/cockpit-config.mjs new file mode 100644 index 0000000..2d76645 --- /dev/null +++ b/lib/cockpit-config.mjs @@ -0,0 +1,62 @@ +import { readFileSync } from 'node:fs' +import { createAuthPolicy, parseAllowlist } from './cockpit-auth.mjs' +import { normalizeRepoId } from './cockpit-domain.mjs' + +export function loadCockpitConfig(env = process.env) { + const repositories = parseAllowlist(env.AGENTFLOW_REPOSITORIES || env.COCKPIT_REPOSITORIES).map( + normalizeRepoId, + ) + const publicUrl = env.COCKPIT_PUBLIC_URL || `http://127.0.0.1:${env.PORT || 3975}` + return { + enabled: env.COCKPIT_ENABLED !== 'false', + remote: env.COCKPIT_REMOTE === 'true', + publicUrl, + port: Number(env.PORT || env.COCKPIT_PORT || 3975), + github: { + clientId: env.GITHUB_CLIENT_ID || '', + clientSecret: env.GITHUB_CLIENT_SECRET || '', + token: env.GITHUB_TOKEN || env.GH_TOKEN || '', + }, + authPolicy: createAuthPolicy({ + allowedUsers: parseAllowlist(env.GITHUB_ALLOWED_USERS), + allowedOrgs: parseAllowlist(env.GITHUB_ALLOWED_ORGS), + allowedTeams: parseAllowlist(env.GITHUB_ALLOWED_TEAMS), + repositories, + }), + repositories, + sessionSecret: env.COCKPIT_SESSION_SECRET || '', + writeActions: env.COCKPIT_WRITE_ACTIONS === 'true', + chat: env.COCKPIT_CHAT === 'true', + dataDir: env.COCKPIT_DATA_DIR || '.agent-runs/cockpit', + } +} + +export function validateCockpitConfig(config) { + const errors = [] + const warnings = [] + if (!config.repositories.length) errors.push('AGENTFLOW_REPOSITORIES required') + if (config.remote) { + if (!config.github.clientId) errors.push('GITHUB_CLIENT_ID required for remote mode') + if (!config.github.clientSecret) errors.push('GITHUB_CLIENT_SECRET required for remote mode') + if (!config.sessionSecret || config.sessionSecret.length < 32) + errors.push('COCKPIT_SESSION_SECRET must be at least 32 chars for remote mode') + if (!config.publicUrl.startsWith('https://')) + warnings.push('COCKPIT_PUBLIC_URL should be https in remote mode') + } + if ( + !config.authPolicy.allowedUsers.length && + !config.authPolicy.allowedOrgs.length && + !config.authPolicy.allowedTeams.length + ) { + warnings.push('No GitHub allowlist configured; repo permissions still apply') + } + return { ok: errors.length === 0, errors, warnings } +} + +export function loadJsonFile(path, fallback = null) { + try { + return JSON.parse(readFileSync(path, 'utf8')) + } catch { + return fallback + } +} diff --git a/lib/cockpit-github.mjs b/lib/cockpit-github.mjs new file mode 100644 index 0000000..2a9b3ac --- /dev/null +++ b/lib/cockpit-github.mjs @@ -0,0 +1,70 @@ +export class GitHubApiError extends Error { + constructor(message, { status, path } = {}) { + super(message) + this.name = 'GitHubApiError' + this.status = status + this.path = path + } +} + +export function createGitHubClient({ + token, + fetchImpl = globalThis.fetch, + apiBaseUrl = 'https://api.github.com', +} = {}) { + if (!fetchImpl) throw new Error('fetch implementation required') + async function request(path, { method = 'GET', body, headers = {} } = {}) { + const response = await fetchImpl(`${apiBaseUrl}${path}`, { + method, + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers, + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) + const text = await response.text() + const data = text ? JSON.parse(text) : null + if (!response.ok) { + throw new GitHubApiError(data?.message || `GitHub API request failed: ${response.status}`, { + status: response.status, + path, + }) + } + return data + } + + return { + request, + currentUser: () => request('/user'), + orgs: () => request('/user/orgs'), + repo: (repo) => request(`/repos/${repo}`), + issues: (repo, params = {}) => request(`/repos/${repo}/issues${query(params)}`), + issue: (repo, number) => request(`/repos/${repo}/issues/${number}`), + issueComments: (repo, number) => + request(`/repos/${repo}/issues/${number}/comments?per_page=100`), + pullRequests: (repo, params = {}) => request(`/repos/${repo}/pulls${query(params)}`), + createIssueComment: (repo, number, body) => + request(`/repos/${repo}/issues/${number}/comments`, { method: 'POST', body: { body } }), + } +} + +export async function loadRepositoryPermission({ client, repo }) { + const data = await client.repo(repo) + const permissions = data.permissions || {} + if (permissions.admin) return 'admin' + if (permissions.maintain) return 'maintain' + if (permissions.push) return 'write' + if (permissions.triage) return 'triage' + if (permissions.pull) return 'read' + return 'none' +} + +function query(params = {}) { + const search = new URLSearchParams( + Object.entries(params).filter(([, value]) => value !== undefined && value !== null), + ) + const value = search.toString() + return value ? `?${value}` : '' +} diff --git a/lib/cockpit-read-model.mjs b/lib/cockpit-read-model.mjs new file mode 100644 index 0000000..cc06c01 --- /dev/null +++ b/lib/cockpit-read-model.mjs @@ -0,0 +1,146 @@ +import { COCKPIT_PHASES, classifyIssue, derivePhaseState } from './cockpit-domain.mjs' +import { extractIssueRelationships, groupCommentsByLane } from './cockpit-markdown.mjs' + +export function buildCockpitIssueView({ + issue, + comments = [], + pullRequests = [], + rolePasses = [], + validations = [], + reviewFindings = [], +} = {}) { + const classification = classifyIssue({ + labels: issue?.labels || [], + body: issue?.body || '', + comments, + }) + const phaseState = derivePhaseState({ issue, rolePasses, validations, reviewFindings }) + return { + number: issue.number, + title: issue.title, + url: issue.html_url || issue.url, + state: issue.state, + classification, + phaseState, + phases: COCKPIT_PHASES.map((phase) => ({ + ...phase, + status: + phase.slug === phaseState.currentPhase + ? 'active' + : hasRolePass(rolePasses, phase) + ? 'complete' + : 'unknown', + })), + evidenceHealth: buildEvidenceHealth({ + issue, + rolePasses, + validations, + reviewFindings, + classification, + }), + commentLanes: groupCommentsByLane(comments), + relationships: extractIssueRelationships({ body: issue.body || '', comments, pullRequests }), + nextSafeAction: phaseState.nextSafeAction, + } +} + +export function buildGoalBoard({ issues = [] } = {}) { + const columns = Object.fromEntries(GOAL_COLUMNS.map((column) => [column, []])) + for (const issue of issues) { + const view = buildCockpitIssueView({ issue }) + columns[classifyGoalColumn(view)].push({ + number: issue.number, + title: issue.title, + url: issue.html_url || issue.url, + evidenceHealth: view.evidenceHealth.status, + nextSafeAction: view.nextSafeAction, + isEpic: view.classification.isEpic, + }) + } + return columns +} + +export function buildEpicRollup({ epic, childIssues = [] } = {}) { + const childViews = childIssues.map((issue) => buildCockpitIssueView({ issue })) + return { + epic: buildCockpitIssueView({ issue: epic }), + children: childViews, + totals: { + children: childViews.length, + blocked: childViews.filter( + (view) => + view.nextSafeAction !== 'advance-next-phase' || view.evidenceHealth.status !== 'complete', + ).length, + evidenceComplete: childViews.filter((view) => view.evidenceHealth.status === 'complete') + .length, + }, + } +} + +export function buildEvidenceHealth({ + issue = {}, + rolePasses = [], + validations = [], + reviewFindings = [], + classification, +} = {}) { + const checks = [ + { + id: 'agentflow-managed', + ok: Boolean(classification?.isAgentFlowManaged), + label: 'AgentFlow-managed issue signal', + }, + { + id: 'acceptance', + ok: /## Acceptance criteria/i.test(issue.body || ''), + label: 'Acceptance criteria section', + }, + { id: 'test-plan', ok: /## Test plan/i.test(issue.body || ''), label: 'Test plan section' }, + { + id: 'role-pass', + ok: rolePasses.length === 0 || derivePhaseState({ issue, rolePasses }).rolePassComplete, + label: 'Role-pass evidence complete', + }, + { + id: 'validation', + ok: validations.length === 0 || validations.every((item) => item.status === 'passed'), + label: 'Validation not failing', + }, + { + id: 'review', + ok: !reviewFindings.some( + (finding) => finding.severity === 'blocker' || finding.status === 'blocking', + ), + label: 'No blocking review findings', + }, + ] + const missing = checks.filter((check) => !check.ok) + return { status: missing.length ? 'incomplete' : 'complete', checks, missing } +} + +function hasRolePass(rolePasses, phase) { + return rolePasses.some( + (pass) => pass.phaseSlug === phase.slug || Number(pass.phase) === phase.index, + ) +} + +function classifyGoalColumn(view) { + if (view.evidenceHealth.status !== 'complete') return 'Blocked' + if (view.nextSafeAction === 'resolve-review-findings') return 'Review' + if (view.nextSafeAction === 'return-to-developer') return 'Validation' + if (view.nextSafeAction === 'complete-role-pass-evidence') return 'Blocked' + if (view.phaseState.currentPhase === 'pr-readiness') return 'PR Ready' + return 'Scoped' +} + +export const GOAL_COLUMNS = [ + 'Intake', + 'Scoped', + 'Planned', + 'Implementation', + 'Validation', + 'Review', + 'PR Ready', + 'Done', + 'Blocked', +] diff --git a/lib/cockpit-ui.mjs b/lib/cockpit-ui.mjs new file mode 100644 index 0000000..95d8635 --- /dev/null +++ b/lib/cockpit-ui.mjs @@ -0,0 +1,68 @@ +export function renderCockpitPage({ title = 'AgentFlow Cockpit', body = '', user, repo } = {}) { + return ` + + + + + ${escapeHtml(title)} + + + +
AgentFlow Cockpit${repo ? ` ${escapeHtml(repo)}` : ''}${user ? ` ${escapeHtml(user)}` : ''}
+
${body}
+ +` +} + +export function renderGoalBoard(board = {}) { + return `

Goal Board

${Object.entries(board) + .map( + ([column, cards]) => + `

${escapeHtml(column)} (${cards.length})

${cards + .map( + (card) => + `

#${card.number} ${escapeHtml(card.title)}
${escapeHtml(card.evidenceHealth)} ${escapeHtml(card.nextSafeAction)}

`, + ) + .join('')}
`, + ) + .join('')}
` +} + +export function renderIssueView(view) { + return `

#${view.number} ${escapeHtml(view.title)}

${escapeHtml(view.phaseState.currentPhase)} ${escapeHtml(view.nextSafeAction)}

+

SDLC Timeline

${view.phases + .map( + (phase) => + `
${phase.index}. ${escapeHtml(phase.label)}
${escapeHtml(phase.status)}
`, + ) + .join('')}
+

Evidence Health

${renderEvidenceHealth(view.evidenceHealth)}
+

Comment Lanes

${Object.entries(view.commentLanes) + .map(([lane, comments]) => `

${escapeHtml(lane)} (${comments.length})

`) + .join('')}
` +} + +export function renderEvidenceHealth(health) { + return `

${escapeHtml(health.status)}

    ${health.checks + .map((check) => `
  • ${check.ok ? '✓' : '✗'} ${escapeHtml(check.label)}
  • `) + .join('')}
` +} + +function escapeHtml(value = '') { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') +} diff --git a/package.json b/package.json index 11333c7..4685f95 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test:hooks": "node scripts/verify-hooks.mjs", "test:workflow": "node scripts/verify-agent-workflow.mjs", "validate:extensions": "node scripts/validate-extension-packs.mjs --allow-empty", + "cockpit": "node scripts/cockpit-server.mjs", "format": "prettier --write .", "format:check": "prettier --check ." }, diff --git a/scripts/cockpit-server.mjs b/scripts/cockpit-server.mjs new file mode 100644 index 0000000..aea512f --- /dev/null +++ b/scripts/cockpit-server.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node +import { createServer } from 'node:http' +import { randomBytes } from 'node:crypto' +import { appendFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { authorizeCockpitUser, createAuditEvent } from '../lib/cockpit-auth.mjs' +import { loadCockpitConfig, validateCockpitConfig } from '../lib/cockpit-config.mjs' +import { createGitHubClient, loadRepositoryPermission } from '../lib/cockpit-github.mjs' +import { buildCockpitIssueView, buildGoalBoard } from '../lib/cockpit-read-model.mjs' +import { renderCockpitPage, renderGoalBoard, renderIssueView } from '../lib/cockpit-ui.mjs' + +const config = loadCockpitConfig() +const validation = validateCockpitConfig(config) +if (!validation.ok) { + console.error( + `Cockpit config invalid:\n${validation.errors.map((error) => `- ${error}`).join('\n')}`, + ) + process.exit(2) +} +for (const warning of validation.warnings) console.warn(`Cockpit warning: ${warning}`) +mkdirSync(config.dataDir, { recursive: true }) + +const sessions = new Map() +const github = createGitHubClient({ token: config.github.token }) + +createServer(async (req, res) => { + try { + const url = new URL(req.url, config.publicUrl) + if (url.pathname === '/healthz') return json(res, { ok: true }) + if (url.pathname === '/login') return login(req, res) + if (url.pathname === '/oauth/callback') return oauthCallback(url, res) + if (url.pathname === '/logout') return logout(req, res) + + const session = sessions.get(readCookie(req, 'cockpit_session')) + if (config.remote && !session) return redirect(res, '/login') + + const repo = url.searchParams.get('repo') || config.repositories[0] + const authz = await authorizeRequest({ session, repo }) + if (!authz.ok) + return html( + res, + renderCockpitPage({ + title: 'Denied', + body: `

Denied

${authz.reasons.join(', ')}

`, + }), + 403, + ) + + if (url.pathname === '/') return home(res, repo, session) + const issueMatch = url.pathname.match(/^\/issues\/(\d+)$/) + if (issueMatch) return issuePage(res, repo, Number(issueMatch[1]), session) + return html( + res, + renderCockpitPage({ body: '

Not found

' }), + 404, + ) + } catch (error) { + console.error(error) + return html( + res, + renderCockpitPage({ + body: `

Error

${error.message}

`, + }), + 500, + ) + } +}).listen(config.port, '127.0.0.1', () => { + console.log(`AgentFlow Cockpit listening on http://127.0.0.1:${config.port}`) +}) + +async function home(res, repo, session) { + const issues = await github.issues(repo, { state: 'open', per_page: 50 }) + const board = buildGoalBoard({ issues: issues.filter((issue) => !issue.pull_request) }) + return html( + res, + renderCockpitPage({ + repo, + user: session?.user?.login || 'token', + body: renderGoalBoard(board), + }), + ) +} + +async function issuePage(res, repo, number, session) { + const [issue, comments] = await Promise.all([ + github.issue(repo, number), + github.issueComments(repo, number), + ]) + const view = buildCockpitIssueView({ issue, comments }) + return html( + res, + renderCockpitPage({ repo, user: session?.user?.login || 'token', body: renderIssueView(view) }), + ) +} + +async function authorizeRequest({ session, repo }) { + if (!config.remote && config.github.token) return { ok: true, reasons: [] } + const repoPermission = await loadRepositoryPermission({ client: github, repo }) + return authorizeCockpitUser({ + user: session?.user, + repo, + repoPermission, + policy: config.authPolicy, + requiredPermission: 'read', + }) +} + +function login(_req, res) { + if (!config.github.clientId) + return html( + res, + renderCockpitPage({ + body: '

GitHub OAuth not configured

', + }), + 500, + ) + const state = randomBytes(16).toString('hex') + sessions.set(state, { oauthState: true }) + const redirectUri = `${config.publicUrl.replace(/\/$/, '')}/oauth/callback` + const target = new URL('https://github.com/login/oauth/authorize') + target.searchParams.set('client_id', config.github.clientId) + target.searchParams.set('redirect_uri', redirectUri) + target.searchParams.set('scope', 'read:user read:org repo') + target.searchParams.set('state', state) + return redirect(res, target.toString()) +} + +async function oauthCallback(url, res) { + const state = url.searchParams.get('state') + const code = url.searchParams.get('code') + if (!state || !code || !sessions.get(state)?.oauthState) + return html( + res, + renderCockpitPage({ body: '

Invalid OAuth state

' }), + 400, + ) + sessions.delete(state) + const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: config.github.clientId, + client_secret: config.github.clientSecret, + code, + }), + }) + const tokenData = await tokenResponse.json() + if (!tokenData.access_token) + return html( + res, + renderCockpitPage({ + body: '

OAuth token exchange failed

', + }), + 401, + ) + const client = createGitHubClient({ token: tokenData.access_token }) + const [user, orgs] = await Promise.all([client.currentUser(), client.orgs()]) + const id = randomBytes(24).toString('hex') + sessions.set(id, { + user: { login: user.login, orgs: orgs.map((org) => org.login) }, + token: tokenData.access_token, + }) + res.setHeader('Set-Cookie', `cockpit_session=${id}; HttpOnly; SameSite=Lax; Path=/`) + audit(createAuditEvent({ actor: user.login, action: 'login', allowed: true })) + return redirect(res, '/') +} + +function logout(req, res) { + sessions.delete(readCookie(req, 'cockpit_session')) + res.setHeader('Set-Cookie', 'cockpit_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0') + return redirect(res, '/login') +} + +function audit(event) { + appendFileSync(join(config.dataDir, 'audit.jsonl'), `${JSON.stringify(event)}\n`) +} + +function readCookie(req, name) { + return Object.fromEntries( + (req.headers.cookie || '').split(';').map((part) => part.trim().split('=')), + )[name] +} + +function html(res, body, status = 200) { + res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(body) +} + +function json(res, body, status = 200) { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify(body)) +} + +function redirect(res, location) { + res.writeHead(302, { Location: location }) + res.end() +} From d27dcb51c455612648cb3f1430314e6d2d9c043a Mon Sep 17 00:00:00 2001 From: Samuel Date: Tue, 28 Jul 2026 09:48:15 +0200 Subject: [PATCH 3/3] Add cockpit authenticated server and guarded actions --- docs/cockpit.md | 1 + lib/__tests__/cockpit-domain.test.mjs | 9 +++- lib/cockpit-actions.mjs | 11 +++++ lib/cockpit-github.mjs | 1 + scripts/cockpit-server.mjs | 66 +++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 1 deletion(-) diff --git a/docs/cockpit.md b/docs/cockpit.md index b5ed41c..21ba60f 100644 --- a/docs/cockpit.md +++ b/docs/cockpit.md @@ -137,6 +137,7 @@ Available surfaces: - `/` — Goal Board for registered repository. - `/issues/` — issue SDLC view. - `/login`, `/oauth/callback`, `/logout` — GitHub OAuth flow for remote mode. +- `POST /actions` — guarded write gateway when `COCKPIT_WRITE_ACTIONS=true` and request includes `X-Cockpit-Confirm: true`. ## Future Docker deployment diff --git a/lib/__tests__/cockpit-domain.test.mjs b/lib/__tests__/cockpit-domain.test.mjs index 0cfc8b5..98fc65e 100644 --- a/lib/__tests__/cockpit-domain.test.mjs +++ b/lib/__tests__/cockpit-domain.test.mjs @@ -11,7 +11,11 @@ import { groupCommentsByLane, } from '../cockpit-markdown.mjs' import { authorizeCockpitUser, createAuthPolicy, validateAllowedOrigin } from '../cockpit-auth.mjs' -import { evaluateGuardedAction, parseCockpitIntent } from '../cockpit-actions.mjs' +import { + evaluateGuardedAction, + formatDurableActionBody, + parseCockpitIntent, +} from '../cockpit-actions.mjs' describe('cockpit domain', () => { it('creates github-backed project without local checkout requirement', () => { @@ -127,6 +131,9 @@ describe('cockpit auth and guarded actions', () => { ok: true, intent: { requiresDurableEvidence: true }, }) + expect(formatDurableActionBody({ type: 'post-handover', body: 'Next: review' })).toContain( + '', + ) }) }) diff --git a/lib/cockpit-actions.mjs b/lib/cockpit-actions.mjs index 0ae2843..aaea1b0 100644 --- a/lib/cockpit-actions.mjs +++ b/lib/cockpit-actions.mjs @@ -65,6 +65,17 @@ export function createActionPreview({ action, target, summary, durableEffect } = } } +export function formatDurableActionBody(intent) { + const body = intent.body || '' + if (intent.type === 'post-handover') return `\n${body}` + if (intent.type === 'request-human-review') + return `\n${body}` + if (intent.type === 'draft-follow-up') return `\n${body}` + if (intent.type === 'add-clarification') return `Clarification:\n\n${body}` + if (intent.type === 'request-checkpoint') return `Checkpoint requested:\n\n${body}` + return body +} + const MATERIAL_INTENTS = new Set([ 'add-clarification', 'request-checkpoint', diff --git a/lib/cockpit-github.mjs b/lib/cockpit-github.mjs index 2a9b3ac..609ac5c 100644 --- a/lib/cockpit-github.mjs +++ b/lib/cockpit-github.mjs @@ -47,6 +47,7 @@ export function createGitHubClient({ pullRequests: (repo, params = {}) => request(`/repos/${repo}/pulls${query(params)}`), createIssueComment: (repo, number, body) => request(`/repos/${repo}/issues/${number}/comments`, { method: 'POST', body: { body } }), + createIssue: (repo, body) => request(`/repos/${repo}/issues`, { method: 'POST', body }), } } diff --git a/scripts/cockpit-server.mjs b/scripts/cockpit-server.mjs index aea512f..4e6f7f9 100644 --- a/scripts/cockpit-server.mjs +++ b/scripts/cockpit-server.mjs @@ -3,6 +3,11 @@ import { createServer } from 'node:http' import { randomBytes } from 'node:crypto' import { appendFileSync, mkdirSync } from 'node:fs' import { join } from 'node:path' +import { + formatDurableActionBody, + parseCockpitIntent, + evaluateGuardedAction, +} from '../lib/cockpit-actions.mjs' import { authorizeCockpitUser, createAuditEvent } from '../lib/cockpit-auth.mjs' import { loadCockpitConfig, validateCockpitConfig } from '../lib/cockpit-config.mjs' import { createGitHubClient, loadRepositoryPermission } from '../lib/cockpit-github.mjs' @@ -46,6 +51,8 @@ createServer(async (req, res) => { 403, ) + if (url.pathname === '/actions' && req.method === 'POST') + return actionEndpoint(req, res, repo, session) if (url.pathname === '/') return home(res, repo, session) const issueMatch = url.pathname.match(/^\/issues\/(\d+)$/) if (issueMatch) return issuePage(res, repo, Number(issueMatch[1]), session) @@ -93,6 +100,59 @@ async function issuePage(res, repo, number, session) { ) } +async function actionEndpoint(req, res, repo, session) { + if (!config.writeActions) return json(res, { ok: false, errors: ['write-actions-disabled'] }, 403) + const payload = JSON.parse(await readBody(req)) + const parsed = parseCockpitIntent({ ...payload, repo }) + if (!parsed.ok) return json(res, parsed, 400) + const intent = parsed.intent + const guard = evaluateGuardedAction({ + action: intent.type, + userRole: session?.role || 'operator', + highAssurance: Boolean(payload.highAssurance), + confirmation: req.headers['x-cockpit-confirm'] === 'true', + }) + if (!guard.ok) { + audit( + createAuditEvent({ + actor: session?.user?.login || 'token', + action: intent.type, + target: `${repo}#${intent.issue}`, + allowed: false, + reason: guard.reasons, + }), + ) + return json(res, { ok: false, errors: guard.reasons }, 403) + } + + const writeClient = session?.token ? createGitHubClient({ token: session.token }) : github + let result + if (intent.type === 'draft-follow-up') { + result = await writeClient.createIssue(repo, { + title: payload.title || `Follow-up from #${intent.issue}`, + body: formatDurableActionBody(intent), + labels: ['drafted-by:pi'], + }) + } else { + result = await writeClient.createIssueComment( + repo, + intent.issue, + formatDurableActionBody(intent), + ) + } + audit( + createAuditEvent({ + actor: session?.user?.login || 'token', + action: intent.type, + target: `${repo}#${intent.issue}`, + allowed: true, + resultUrl: result.html_url, + previewSummary: intent.body.slice(0, 120), + }), + ) + return json(res, { ok: true, url: result.html_url }) +} + async function authorizeRequest({ session, repo }) { if (!config.remote && config.github.token) return { ok: true, reasons: [] } const repoPermission = await loadRepositoryPermission({ client: github, repo }) @@ -175,6 +235,12 @@ function audit(event) { appendFileSync(join(config.dataDir, 'audit.jsonl'), `${JSON.stringify(event)}\n`) } +async function readBody(req) { + const chunks = [] + for await (const chunk of req) chunks.push(chunk) + return Buffer.concat(chunks).toString('utf8') || '{}' +} + function readCookie(req, name) { return Object.fromEntries( (req.headers.cookie || '').split(';').map((part) => part.trim().split('=')),