From 6dd0a5714882bb81f9c368953ca35f6e3b813753 Mon Sep 17 00:00:00 2001 From: RedStar071 Date: Mon, 10 Aug 2026 08:10:11 +0000 Subject: [PATCH 1/2] feat(server): restore the Nitro control plane with registered routes Restores apps/server removed in #21 and fixes the unresolved review findings from #2 against it: file-based routing is now actually enabled (serverDir is opt-in in this Nitro release), so /health and the task API respond over HTTP, and the documented AGENT_ZERO_PORT is honored through a start wrapper that maps it onto NITRO_PORT before the listener boots. The build now runs tsdown alongside nitro build for the library surface and start wrapper. --- AGENTS.md | 1 + README.md | 1 + apps/server/nitro.config.ts | 12 + apps/server/package.json | 31 ++ apps/server/server/routes/health.get.ts | 5 + apps/server/server/routes/tasks/[id].get.ts | 5 + .../server/routes/tasks/[id]/evidence.get.ts | 5 + apps/server/server/routes/tasks/index.get.ts | 5 + apps/server/server/routes/tasks/index.post.ts | 5 + apps/server/src/http.test.ts | 77 +++++ apps/server/src/http.ts | 50 +++ apps/server/src/index.ts | 20 ++ apps/server/src/port.test.ts | 31 ++ apps/server/src/port.ts | 13 + apps/server/src/router.test.ts | 286 +++++++++++++++++ apps/server/src/router.ts | 188 +++++++++++ apps/server/src/start.ts | 7 + apps/server/tsconfig.json | 21 ++ apps/server/tsdown.config.ts | 7 + knip.jsonc | 4 + pnpm-lock.yaml | 298 ++++++++++++++++++ 21 files changed, 1072 insertions(+) create mode 100644 apps/server/nitro.config.ts create mode 100644 apps/server/package.json create mode 100644 apps/server/server/routes/health.get.ts create mode 100644 apps/server/server/routes/tasks/[id].get.ts create mode 100644 apps/server/server/routes/tasks/[id]/evidence.get.ts create mode 100644 apps/server/server/routes/tasks/index.get.ts create mode 100644 apps/server/server/routes/tasks/index.post.ts create mode 100644 apps/server/src/http.test.ts create mode 100644 apps/server/src/http.ts create mode 100644 apps/server/src/index.ts create mode 100644 apps/server/src/port.test.ts create mode 100644 apps/server/src/port.ts create mode 100644 apps/server/src/router.test.ts create mode 100644 apps/server/src/router.ts create mode 100644 apps/server/src/start.ts create mode 100644 apps/server/tsconfig.json create mode 100644 apps/server/tsdown.config.ts diff --git a/AGENTS.md b/AGENTS.md index a2d907e..d171e88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ These instructions apply to humans and coding agents working in this repository. - `packages/config`: configuration parsing and policy. - `packages/shared`: stable cross-package contracts. - `packages/cli`: argument parsing and terminal presentation. +- `apps/server`: Nitro control plane; HTTP transport and task composition. - `apps/dashboard`: frontend-only Nuxt operational dashboard. The runtime must remain independent from HTTP, GitHub, terminal UI, and specific model providers. Adapters depend on the runtime; the runtime must not depend on adapters. diff --git a/README.md b/README.md index a5e43f4..9f341ac 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Nuxt dashboard ─── frontend-only operational interface | [`packages/config`](./packages/config) | Configuration parsing and policy | | [`packages/shared`](./packages/shared) | Stable cross-package contracts | | [`packages/cli`](./packages/cli) | Argument parsing and terminal presentation | +| [`apps/server`](./apps/server) | Nitro control plane: HTTP task API and webhook composition | | [`apps/dashboard`](./apps/dashboard) | Frontend-only Nuxt operational dashboard | Adapters depend on the runtime; the runtime never depends on adapters. See [docs/architecture.md](./docs/architecture.md) for the full dependency rules. diff --git a/apps/server/nitro.config.ts b/apps/server/nitro.config.ts new file mode 100644 index 0000000..1b34f90 --- /dev/null +++ b/apps/server/nitro.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'nitro/config'; + +/** + * File-based routing is opt-in for this Nitro release (`serverDir` defaults to `false`), so the + * scan directory is enabled explicitly: every handler under `server/routes/` is a thin transport + * shell over the transport-independent task API in `src/router.ts`. Without this option the built + * server registers no routes at all. + */ +export default defineConfig({ + compatibilityDate: '2026-05-22', + serverDir: true, +}); diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..1bbb856 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,31 @@ +{ + "name": "@agent-zero/server", + "version": "0.3.0", + "type": "module", + "scripts": { + "build": "nitro build && tsdown", + "clean": "tsc -b --clean", + "dev": "nitro dev", + "lint": "oxlint --config ../../.oxlintrc.json --type-aware --type-check src server nitro.config.ts", + "start": "node dist/start.js", + "test": "vitest run src --passWithNoTests", + "typecheck": "nitro prepare && tsc --project tsconfig.json --pretty false --noEmit" + }, + "dependencies": { + "@agent-zero/agent": "workspace:*", + "@agent-zero/config": "workspace:*", + "@agent-zero/github": "workspace:*", + "@agent-zero/models": "workspace:*", + "@agent-zero/runner": "workspace:*", + "@agent-zero/shared": "workspace:*", + "nitro": "3.0.260522-beta", + "zod": "^4.4.3" + }, + "devDependencies": { + "oxlint": "^1.44.0", + "oxlint-tsgolint": "^7.0.2001", + "tsdown": "^0.22.14", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + } +} diff --git a/apps/server/server/routes/health.get.ts b/apps/server/server/routes/health.get.ts new file mode 100644 index 0000000..6e9e2b0 --- /dev/null +++ b/apps/server/server/routes/health.get.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro'; + +import { health } from '../../src/router.js'; + +export default defineHandler(() => health()); diff --git a/apps/server/server/routes/tasks/[id].get.ts b/apps/server/server/routes/tasks/[id].get.ts new file mode 100644 index 0000000..7f6121f --- /dev/null +++ b/apps/server/server/routes/tasks/[id].get.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro'; + +import { taskResponse } from '../../../src/http.js'; + +export default defineHandler((event) => taskResponse(event.context.params?.id)); diff --git a/apps/server/server/routes/tasks/[id]/evidence.get.ts b/apps/server/server/routes/tasks/[id]/evidence.get.ts new file mode 100644 index 0000000..a3318eb --- /dev/null +++ b/apps/server/server/routes/tasks/[id]/evidence.get.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro'; + +import { evidenceResponse } from '../../../../src/http.js'; + +export default defineHandler((event) => evidenceResponse(event.context.params?.id)); diff --git a/apps/server/server/routes/tasks/index.get.ts b/apps/server/server/routes/tasks/index.get.ts new file mode 100644 index 0000000..9b95c7f --- /dev/null +++ b/apps/server/server/routes/tasks/index.get.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro'; + +import { listTasks } from '../../../src/router.js'; + +export default defineHandler(() => listTasks()); diff --git a/apps/server/server/routes/tasks/index.post.ts b/apps/server/server/routes/tasks/index.post.ts new file mode 100644 index 0000000..76e400f --- /dev/null +++ b/apps/server/server/routes/tasks/index.post.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro'; + +import { createTaskResponse } from '../../../src/http.js'; + +export default defineHandler((event) => createTaskResponse(event.req)); diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts new file mode 100644 index 0000000..ddabbc5 --- /dev/null +++ b/apps/server/src/http.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { beforeEach, describe, expect, it } from 'vitest'; + +import { createTaskResponse, evidenceResponse, taskResponse } from './http.js'; +import { runTask, tasks } from './router.js'; + +let checkout: string; + +beforeEach(async () => { + tasks.clear(); + checkout = await mkdtemp(join(tmpdir(), 'agent-zero-server-http-')); + await writeFile(join(checkout, 'package.json'), JSON.stringify({ scripts: {} }), 'utf8'); +}); + +function postRequest(body: string): Request { + return new Request('http://localhost/tasks', { + method: 'POST', + body, + headers: { 'content-type': 'application/json' }, + }); +} + +/** The HTTP status of an explicit `Response`, or undefined for plain data payloads. */ +function statusOf(value: unknown): number | undefined { + return value instanceof Response ? value.status : undefined; +} + +describe('taskResponse', () => { + it('serves a stored task', async () => { + const result = await runTask({ repository: checkout, feedback: 'x', mode: 'observe' }); + expect(taskResponse(result.id)).toBe(result); + }); + + it('answers 404 for an unknown or missing id', () => { + expect(statusOf(taskResponse('az_missing'))).toBe(404); + expect(statusOf(taskResponse(undefined))).toBe(404); + }); +}); + +describe('evidenceResponse', () => { + it('serves the rendered evidence as markdown', async () => { + const result = await runTask({ repository: checkout, feedback: 'x', mode: 'observe' }); + const response = evidenceResponse(result.id); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/markdown'); + await expect(response.text()).resolves.toContain('## Agent Zero'); + }); + + it('answers 404 for an unknown task', () => { + expect(evidenceResponse('az_missing').status).toBe(404); + }); +}); + +describe('createTaskResponse', () => { + it('rejects a body that is not JSON', async () => { + expect(statusOf(await createTaskResponse(postRequest('not json')))).toBe(400); + }); + + it('rejects input the task schema refuses', async () => { + const outcome = await createTaskResponse( + postRequest(JSON.stringify({ repository: checkout, feedback: 'x', mode: 'yolo' })), + ); + expect(statusOf(outcome)).toBe(400); + expect(tasks.size).toBe(0); + }); + + it('runs a validated task and stores its evidence', async () => { + const outcome = await createTaskResponse( + postRequest(JSON.stringify({ repository: checkout, feedback: 'x', mode: 'observe' })), + ); + expect(statusOf(outcome)).toBeUndefined(); + expect(tasks.size).toBe(1); + }); +}); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts new file mode 100644 index 0000000..3ced3fc --- /dev/null +++ b/apps/server/src/http.ts @@ -0,0 +1,50 @@ +import type { TaskResult } from '@agent-zero/shared'; + +import { createTask, getTask, getTaskEvidence, taskInput } from './router.js'; + +/** + * Transport shaping for the Nitro route shells in `routes/`. + * + * Each function takes primitives and returns either plain data (serialized by Nitro) or a web + * `Response` carrying an explicit status, so the handlers stay one-line shells and the HTTP + * contract stays unit-testable without a listener. + */ + +export function taskResponse(id: string | undefined): TaskResult | Response { + const task = id ? getTask(id) : undefined; + if (!task) return Response.json({ error: `Unknown task: ${id ?? ''}` }, { status: 404 }); + return task; +} + +export function evidenceResponse(id: string | undefined): Response { + const markdown = id ? getTaskEvidence(id) : undefined; + if (markdown === undefined) + return Response.json({ error: `Unknown task: ${id ?? ''}` }, { status: 404 }); + return new Response(markdown, { + headers: { 'content-type': 'text/markdown; charset=utf-8' }, + }); +} + +/** + * Validate and run one task from an inbound request body. + * + * The body is validated with the transport-independent schema before anything executes, so the + * HTTP layer never chooses a mode or repository on its own. + */ +export async function createTaskResponse(request: { + json(): Promise; +}): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: 'Request body must be valid JSON' }, { status: 400 }); + } + const parsed = taskInput.safeParse(body); + if (!parsed.success) + return Response.json( + { error: 'Invalid task input', issues: parsed.error.issues }, + { status: 400 }, + ); + return await createTask(parsed.data); +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..52de21d --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,20 @@ +export { createTaskResponse, evidenceResponse, taskResponse } from './http.js'; +export { applyPortEnvironment } from './port.js'; +export { + createTask, + getTask, + getTaskEvidence, + githubTokenFromEnvironment, + health, + ingestWebhook, + listTasks, + publishEvidence, + runTask, + taskInput, + tasks, + type PublishOptions, + type StoredTask, + type WebhookOptions, + type WebhookOutcome, + type WebhookRequest, +} from './router.js'; diff --git a/apps/server/src/port.test.ts b/apps/server/src/port.test.ts new file mode 100644 index 0000000..05f5d9c --- /dev/null +++ b/apps/server/src/port.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { applyPortEnvironment } from './port.js'; + +describe('applyPortEnvironment', () => { + it('maps AGENT_ZERO_PORT onto NITRO_PORT before the server boots', () => { + const env: NodeJS.ProcessEnv = { AGENT_ZERO_PORT: '4040' }; + applyPortEnvironment(env); + expect(env.NITRO_PORT).toBe('4040'); + }); + + it('leaves the environment alone when AGENT_ZERO_PORT is absent or blank', () => { + const absent: NodeJS.ProcessEnv = {}; + applyPortEnvironment(absent); + expect(absent.NITRO_PORT).toBeUndefined(); + + const blank: NodeJS.ProcessEnv = { AGENT_ZERO_PORT: ' ' }; + applyPortEnvironment(blank); + expect(blank.NITRO_PORT).toBeUndefined(); + }); + + it('never overrides an explicitly configured Nitro port', () => { + const nitro: NodeJS.ProcessEnv = { AGENT_ZERO_PORT: '4040', NITRO_PORT: '5050' }; + applyPortEnvironment(nitro); + expect(nitro.NITRO_PORT).toBe('5050'); + + const generic: NodeJS.ProcessEnv = { AGENT_ZERO_PORT: '4040', PORT: '6060' }; + applyPortEnvironment(generic); + expect(generic.NITRO_PORT).toBeUndefined(); + }); +}); diff --git a/apps/server/src/port.ts b/apps/server/src/port.ts new file mode 100644 index 0000000..3a6e123 --- /dev/null +++ b/apps/server/src/port.ts @@ -0,0 +1,13 @@ +/** + * Map the documented `AGENT_ZERO_PORT` onto `NITRO_PORT`, the variable the built Nitro entry + * actually reads, so the configuration contract in `.env.example` controls the listener. + * + * Nitro's native variables win when both are set: an operator who configures `NITRO_PORT` or + * `PORT` explicitly is speaking Nitro's own contract, and this mapping must not override it. + */ +export function applyPortEnvironment(env: NodeJS.ProcessEnv): void { + const port = env.AGENT_ZERO_PORT?.trim(); + if (!port) return; + if (env.NITRO_PORT !== undefined || env.PORT !== undefined) return; + env.NITRO_PORT = port; +} diff --git a/apps/server/src/router.test.ts b/apps/server/src/router.test.ts new file mode 100644 index 0000000..9ed492f --- /dev/null +++ b/apps/server/src/router.test.ts @@ -0,0 +1,286 @@ +import { createHmac } from 'node:crypto'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + getTask, + getTaskEvidence, + health, + ingestWebhook, + listTasks, + publishEvidence, + runTask, + taskInput, + tasks, +} from './router.js'; + +const secret = 'webhook-secret-value'; +const MODE_ERROR = /mode/i; + +function sign(body: string): string { + return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`; +} + +function reviewPayload(overrides: Record = {}): string { + return JSON.stringify({ + action: 'submitted', + repository: { name: 'app', owner: { login: 'acme' } }, + pull_request: { + number: 7, + base: { sha: 'b'.repeat(40) }, + head: { sha: 'a'.repeat(40) }, + }, + review: { + id: 1, + body: 'load() can return null', + state: 'changes_requested', + user: { login: 'alice', type: 'User' }, + ...overrides, + }, + }); +} + +let checkout: string; + +beforeEach(async () => { + tasks.clear(); + checkout = await mkdtemp(join(tmpdir(), 'agent-zero-server-')); + await writeFile(join(checkout, 'package.json'), JSON.stringify({ scripts: {} }), 'utf8'); +}); + +describe('server task API', () => { + it('exposes health metadata for Nitro handlers', () => { + expect(health()).toMatchObject({ status: 'ok', service: 'agent-zero' }); + }); + + it('starts with an empty task collection', () => { + expect(listTasks()).toEqual({ tasks: [] }); + }); + + it('keeps task input validation independent from HTTP transport', () => { + expect( + taskInput.parse({ repository: '.', feedback: 'Check error handling', mode: 'observe' }), + ).toMatchObject({ repository: '.', mode: 'observe' }); + }); + + it('rejects an unknown mode at the transport edge', () => { + expect(() => taskInput.parse({ repository: '.', feedback: 'x', mode: 'yolo' })).toThrow( + MODE_ERROR, + ); + }); +}); + +describe('runTask', () => { + it('stores the result and its evidence together', async () => { + const result = await runTask({ + repository: checkout, + feedback: 'load() is wrong', + mode: 'observe', + }); + expect(getTask(result.id)).toBe(result); + expect(getTaskEvidence(result.id)).toContain('## Agent Zero'); + expect(listTasks().tasks).toHaveLength(1); + }); + + it('produces a read-only boundary for an observe run', async () => { + const result = await runTask({ + repository: checkout, + feedback: 'load() is wrong', + mode: 'observe', + }); + expect(result.runner.writable).toBe(false); + expect(result.changedFiles).toEqual([]); + }); + + it('keeps the boundary read-only in fix mode while policy disables autofix', async () => { + const result = await runTask({ + repository: checkout, + feedback: 'load() is wrong', + mode: 'fix', + }); + expect(result.runner.writable).toBe(false); + }); + + it('reports an unverified conclusion when no model is configured', async () => { + const result = await runTask({ + repository: checkout, + feedback: 'load() is wrong', + mode: 'observe', + }); + expect(result.verified).toBe(false); + expect(result.verdict).toBe('rejected'); + }); +}); + +describe('ingestWebhook', () => { + const options = () => ({ secret, checkoutPath: checkout }); + + it('rejects a forged signature before parsing the payload', async () => { + const body = reviewPayload(); + await expect( + ingestWebhook( + { event: 'pull_request_review', body, signature: 'sha256=deadbeef' }, + options(), + ), + ).resolves.toEqual({ status: 'rejected', reason: 'Invalid webhook signature' }); + expect(tasks.size).toBe(0); + }); + + it('rejects a body that is not JSON', async () => { + const body = 'not json'; + const outcome = await ingestWebhook( + { event: 'pull_request_review', body, signature: sign(body) }, + options(), + ); + expect(outcome).toEqual({ status: 'rejected', reason: 'Webhook body is not valid JSON' }); + }); + + it('ignores an event that carries no claim to validate', async () => { + const body = reviewPayload({ state: 'approved' }); + const outcome = await ingestWebhook( + { event: 'pull_request_review', body, signature: sign(body) }, + options(), + ); + expect(outcome.status).toBe('ignored'); + expect(tasks.size).toBe(0); + }); + + it('ignores its own account so a run cannot answer itself', async () => { + const body = reviewPayload({ user: { login: 'agent-zero[bot]' } }); + const outcome = await ingestWebhook( + { event: 'pull_request_review', body, signature: sign(body) }, + { ...options(), ignoreAuthors: ['agent-zero[bot]'] }, + ); + expect(outcome.status).toBe('ignored'); + }); + + it('runs an authenticated review in observe mode and never writes', async () => { + const body = reviewPayload(); + const outcome = await ingestWebhook( + { event: 'pull_request_review', body, signature: sign(body) }, + options(), + ); + expect(outcome.status).toBe('accepted'); + if (outcome.status !== 'accepted') return; + expect(outcome.pullRequest).toEqual({ + owner: 'acme', + repo: 'app', + number: 7, + baseSha: 'b'.repeat(40), + headSha: 'a'.repeat(40), + }); + expect(outcome.result.runner.writable).toBe(false); + expect(outcome.result.changedFiles).toEqual([]); + expect(outcome.result.summary).toContain('github:acme/app#7'); + }); + + it('ignores proactive pull-request events until repository policy enables them', async () => { + const body = JSON.stringify({ + action: 'synchronize', + repository: { name: 'app', owner: { login: 'acme' } }, + pull_request: { + number: 7, + base: { sha: 'b'.repeat(40) }, + head: { sha: 'a'.repeat(40) }, + }, + }); + await expect( + ingestWebhook({ event: 'pull_request', body, signature: sign(body) }, options()), + ).resolves.toEqual({ + status: 'ignored', + reason: 'Proactive review is disabled by repository policy', + }); + }); + + it('runs an enabled proactive pull-request review in repository mode', async () => { + await writeFile( + join(checkout, '.agent-zero.yml'), + 'version: 1\nproactive:\n enabled: true\nmode: observe\n', + 'utf8', + ); + const body = JSON.stringify({ + action: 'opened', + repository: { name: 'app', owner: { login: 'acme' } }, + pull_request: { + number: 7, + base: { sha: 'b'.repeat(40) }, + head: { sha: 'a'.repeat(40) }, + }, + }); + const outcome = await ingestWebhook( + { event: 'pull_request', body, signature: sign(body) }, + options(), + ); + expect(outcome.status).toBe('accepted'); + if (outcome.status !== 'accepted') return; + expect(outcome.result.runner.writable).toBe(false); + expect(getTaskEvidence(outcome.result.id)).toContain('proactive finding'); + }); +}); + +type FetchArguments = Parameters; + +function readBody(body: NonNullable['body']): Record { + if (typeof body !== 'string') return {}; + const parsed: unknown = JSON.parse(body); + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? { ...parsed } + : {}; +} + +/** Records what would have been sent to GitHub, so no test needs the network. */ +function recordingFetch(): { + fetch: typeof globalThis.fetch; + bodies: Record[]; +} { + const bodies: Record[] = []; + const fetch: typeof globalThis.fetch = async (_url, init) => { + bodies.push(readBody(init?.body)); + return new Response(JSON.stringify({ id: 99 }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }); + }; + return { fetch, bodies }; +} + +describe('publishEvidence', () => { + const target = { + owner: 'acme', + repo: 'app', + number: 7, + baseSha: 'b'.repeat(40), + headSha: 'a'.repeat(40), + }; + + it('skips publishing rather than faking a check without a token', async () => { + const result = await runTask({ repository: checkout, feedback: 'x', mode: 'observe' }); + const { fetch, bodies } = recordingFetch(); + await expect(publishEvidence(target, result.id, { token: undefined, fetch })).resolves.toEqual({ + published: false, + reason: 'GITHUB_TOKEN is not configured', + }); + expect(bodies).toEqual([]); + }); + + it('reports an unknown task instead of publishing an empty report', async () => { + const { fetch, bodies } = recordingFetch(); + await expect( + publishEvidence(target, 'az_missing', { token: 'ghs_token_value', fetch }), + ).resolves.toMatchObject({ published: false }); + expect(bodies).toEqual([]); + }); + + it('publishes the stored evidence without claiming an unverified run passed', async () => { + const result = await runTask({ repository: checkout, feedback: 'x', mode: 'observe' }); + const { fetch, bodies } = recordingFetch(); + await expect( + publishEvidence(target, result.id, { token: 'ghs_token_value', fetch }), + ).resolves.toEqual({ published: true }); + expect(bodies[0]).toMatchObject({ head_sha: target.headSha, status: 'completed' }); + expect(bodies[0]?.conclusion).not.toBe('success'); + }); +}); diff --git a/apps/server/src/router.ts b/apps/server/src/router.ts new file mode 100644 index 0000000..6e9d24f --- /dev/null +++ b/apps/server/src/router.ts @@ -0,0 +1,188 @@ +import { AgentZero } from '@agent-zero/agent'; +import { loadConfig, mayModifyRepository } from '@agent-zero/config'; +import { + GitHubChecks, + parseReviewEvent, + reviewInputFromEvent, + verifyWebhook, +} from '@agent-zero/github'; +import { modelFromEnvironment } from '@agent-zero/models'; +import { createRunner, runnerOptionsFromPolicy } from '@agent-zero/runner'; +import { + evidenceFromResult, + renderEvidenceMarkdown, + type EvidenceBundle, + type PullRequestRef, + type ReviewInput, + type TaskResult, +} from '@agent-zero/shared'; +import { z } from 'zod'; + +/** A finished run together with the evidence bundle derived from it. */ +export interface StoredTask { + result: TaskResult; + evidence: EvidenceBundle; +} + +export const tasks = new Map(); + +export const taskInput = z + .object({ + repository: z.string().min(1), + feedback: z.string().min(1).optional(), + trigger: z.enum(['feedback', 'proactive']).default('feedback'), + mode: z.enum(['observe', 'suggest', 'fix', 'autonomous']), + source: z.string().optional(), + files: z.array(z.string()).optional(), + }) + .superRefine((input, context) => { + if (input.trigger !== 'proactive' && input.feedback === undefined) + context.addIssue({ code: 'custom', path: ['feedback'], message: 'Feedback is required' }); + }); + +export function health() { + return { status: 'ok' as const, service: 'agent-zero', version: '0.3.0' }; +} + +export function listTasks() { + return { tasks: Array.from(tasks.values(), (task) => task.result) }; +} + +export function getTask(id: string): TaskResult | undefined { + return tasks.get(id)?.result; +} + +/** The rendered evidence report for a finished run. */ +export function getTaskEvidence(id: string): string | undefined { + const task = tasks.get(id); + return task ? renderEvidenceMarkdown(task.evidence) : undefined; +} + +export async function createTask(input: z.infer): Promise { + return runTask({ + repository: input.repository, + mode: input.mode, + trigger: input.trigger, + ...(input.feedback ? { feedback: input.feedback } : {}), + ...(input.source ? { source: input.source } : {}), + ...(input.files ? { files: input.files } : {}), + }); +} + +/** + * Run one unit of work and store its evidence. + * + * This is the composition root: it resolves policy, builds an execution boundary that is read-only + * unless the mode and the repository both authorize writing, and never lets the transport layer + * choose those things for itself. + */ +export async function runTask(input: ReviewInput): Promise { + const config = await loadConfig(input.repository); + const agent = new AgentZero({ + model: modelFromEnvironment(config.model), + runner: createRunner( + input.repository, + runnerOptionsFromPolicy(config, mayModifyRepository(config, input.mode)), + ), + config, + }); + const result = await agent.run(input); + tasks.set(result.id, { + result, + evidence: evidenceFromResult(result, input), + }); + return result; +} + +export interface WebhookRequest { + event: string; + body: string; + signature: string | undefined; +} + +export interface WebhookOptions { + secret: string; + /** Where the pull request is already checked out. */ + checkoutPath: string; + /** Logins to ignore, so the agent never reacts to its own comments. */ + ignoreAuthors?: readonly string[]; +} + +export type WebhookOutcome = + | { status: 'rejected'; reason: string } + | { status: 'ignored'; reason: string } + | { status: 'accepted'; result: TaskResult; pullRequest: PullRequestRef }; + +/** + * Handle an inbound GitHub webhook. + * + * The signature is verified before the payload is parsed. Feedback-triggered runs remain + * read-only; proactive pull-request runs use repository mode only after proactive review is + * explicitly enabled in that checkout. Writes still require the independent autofix policy gate. + */ +export async function ingestWebhook( + request: WebhookRequest, + options: WebhookOptions, +): Promise { + if (!verifyWebhook(request.body, request.signature, options.secret)) + return { status: 'rejected', reason: 'Invalid webhook signature' }; + + let payload: unknown; + try { + payload = JSON.parse(request.body); + } catch { + return { status: 'rejected', reason: 'Webhook body is not valid JSON' }; + } + + const event = parseReviewEvent( + request.event, + payload, + options.ignoreAuthors ? { ignoreAuthors: options.ignoreAuthors } : {}, + ); + if (!event) return { status: 'ignored', reason: 'No actionable review feedback in this event' }; + + let mode: ReviewInput['mode'] = 'observe'; + if (event.trigger === 'proactive') { + const config = await loadConfig(options.checkoutPath); + if (!config.proactive.enabled) + return { status: 'ignored', reason: 'Proactive review is disabled by repository policy' }; + mode = config.mode; + } + + const result = await runTask( + reviewInputFromEvent(event, { checkoutPath: options.checkoutPath, mode }), + ); + return { status: 'accepted', result, pullRequest: event.pullRequest }; +} + +export interface PublishOptions { + /** Supplied by the caller rather than read here, so no code path reaches GitHub implicitly. */ + token: string | undefined; + fetch?: typeof globalThis.fetch; +} + +/** The credential a deployment configures for check reporting. */ +export function githubTokenFromEnvironment(): string | undefined { + return process.env.GITHUB_TOKEN; +} + +/** + * Publish a finished run to GitHub Checks. + * + * Reporting is skipped rather than faked when no token is configured, so a missing credential never + * turns into a green check. + */ +export async function publishEvidence( + target: PullRequestRef, + taskIdentifier: string, + options: PublishOptions, +): Promise<{ published: boolean; reason?: string }> { + if (!options.token) return { published: false, reason: 'GITHUB_TOKEN is not configured' }; + const task = tasks.get(taskIdentifier); + if (!task) return { published: false, reason: `Unknown task: ${taskIdentifier}` }; + await new GitHubChecks({ + token: options.token, + ...(options.fetch ? { fetch: options.fetch } : {}), + }).publish(target, task.evidence); + return { published: true }; +} diff --git a/apps/server/src/start.ts b/apps/server/src/start.ts new file mode 100644 index 0000000..537449f --- /dev/null +++ b/apps/server/src/start.ts @@ -0,0 +1,7 @@ +import { applyPortEnvironment } from './port.js'; + +applyPortEnvironment(process.env); + +// The listener is created by the Nitro output, which `nitro build` emits next to this bundle. +// The port mapping above must run in the same process before that entry reads its environment. +await import(new URL('../.output/server/index.mjs', import.meta.url).href); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..d250605 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + // An application project: nothing consumes declarations, and declaration + // emit cannot name the h3 handler types that Nitro route files infer. + "composite": false, + "declaration": false, + "declarationMap": false + }, + "include": ["src/**/*.ts", "server/**/*.ts", "nitro.config.ts"], + "references": [ + { "path": "../../packages/agent" }, + { "path": "../../packages/config" }, + { "path": "../../packages/github" }, + { "path": "../../packages/models" }, + { "path": "../../packages/runner" }, + { "path": "../../packages/shared" } + ] +} diff --git a/apps/server/tsdown.config.ts b/apps/server/tsdown.config.ts new file mode 100644 index 0000000..26a2216 --- /dev/null +++ b/apps/server/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineAppConfig } from '../../scripts/tsdown.config.ts'; + +// `nitro build` emits the HTTP listener into `.output/`; tsdown builds the library surface and +// the start wrapper that maps `AGENT_ZERO_PORT` before that listener boots. +export default defineAppConfig({ + entry: ['src/index.ts', 'src/start.ts'], +}); diff --git a/knip.jsonc b/knip.jsonc index 67fcc53..1e886c4 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -10,6 +10,10 @@ "nano-staged", ], "workspaces": { + "apps/server": { + // Nitro discovers route handlers by convention rather than by import. + "entry": ["server/routes/**/*.ts"], + }, "apps/dashboard": { // Nuxt 4 keeps application code in app/, which knip's Nuxt plugin does // not scan, and Nuxt auto-imports components, composables, and utils. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58f23d5..0a158a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,76 @@ importers: specifier: ^3.1.2 version: 3.3.9(typescript@6.0.3-bridge.12.tsgo.7.0.2) + apps/server: + dependencies: + '@agent-zero/agent': + specifier: workspace:* + version: 0.3.0 + '@agent-zero/config': + specifier: workspace:* + version: 0.3.0 + '@agent-zero/github': + specifier: workspace:* + version: 0.3.0 + '@agent-zero/models': + specifier: workspace:* + version: 0.3.0 + '@agent-zero/runner': + specifier: workspace:* + version: 0.3.0 + '@agent-zero/shared': + specifier: workspace:* + version: 0.3.0 + '@arethetypeswrong/core': + specifier: ^0.18.1 + version: 0.18.5 + '@types/node': + specifier: ^18.0.0 || ^20.0.0 || >=22.0.0 + version: 24.13.3 + dotenv: + specifier: '*' + version: 17.4.2 + giget: + specifier: '*' + version: 3.3.1 + jiti: + specifier: ^2.6.1 + version: 2.7.0 + nitro: + specifier: 3.0.260522-beta + version: 3.0.260522-beta(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(rollup@4.62.4)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)) + publint: + specifier: ^0.3.8 + version: 0.3.23 + rollup: + specifier: ^4.60.3 + version: 4.62.4 + tsx: + specifier: '*' + version: 4.23.11 + vite: + specifier: ^7 || ^8 + version: 8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + oxlint: + specifier: ^1.44.0 + version: 1.77.0(oxlint-tsgolint@7.0.2001) + oxlint-tsgolint: + specifier: ^7.0.2001 + version: 7.0.2001 + tsdown: + specifier: ^0.22.14 + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(tsx@4.23.11) + typescript: + specifier: ^5.9.2 + version: typescript-native-bridge@6.0.3-bridge.12.tsgo.7.0.2 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@24.13.3) + packages/agent: dependencies: '@agent-zero/config': @@ -4049,6 +4119,24 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + env-runner@0.1.16: + resolution: {integrity: sha512-2LRJM4P2KLX6J83QZZrMqvgCDt/D5ea7wPcI3yYiy5cG/9rX5QwdwZFx0D7ktWnjdRyZxYjttGGorb5nFqb1CA==} + hasBin: true + peerDependencies: + '@netlify/runtime': ^4.1.23 + '@vercel/queue': '>=0.2.0' + miniflare: ^4.20260515.0 + wrangler: ^4.0.0 + peerDependenciesMeta: + '@netlify/runtime': + optional: true + '@vercel/queue': + optional: true + miniflare: + optional: true + wrangler: + optional: true + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -4389,6 +4477,16 @@ packages: h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + h3@2.0.1-rc.26: + resolution: {integrity: sha512-GDxlvDsKxgjRvG5UBRJYyGJTMWLV30CJ4cV+e7QCTgftDHihrvio1fVPbNembhEr6J4WNm8IWy3fookgyTweLw==} + engines: {node: '>=20.11.1'} + hasBin: true + peerDependencies: + crossws: ^0.4.9 + peerDependenciesMeta: + crossws: + optional: true + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -4979,6 +5077,40 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + nf3@0.3.23: + resolution: {integrity: sha512-RWVLAWozmVD3AaDmaU3qMGB3v+yNlH5d9qqStI4e/WLlNQVnJ4YErGDbYCIrGFyrHdbF6I6Baf0Ae6c7tFYmSg==} + + nitro@3.0.260522-beta: + resolution: {integrity: sha512-L/z2eOWgkiQHc65kv+SEMgau505afSRF7NJlbooaaZEZscFrNSD7rXZzeVubQlgIzPbhOG8o73bk9soIiGTHRA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@vercel/queue': ^0.2.0 + dotenv: '*' + giget: '*' + jiti: ^2.6.1 + rollup: ^4.60.3 + vite: ^7 || ^8 + xml2js: ^0.6.2 + zephyr-agent: ^0.2.0 + peerDependenciesMeta: + '@vercel/queue': + optional: true + dotenv: + optional: true + giget: + optional: true + jiti: + optional: true + rollup: + optional: true + vite: + optional: true + xml2js: + optional: true + zephyr-agent: + optional: true + nitropack@2.13.4: resolution: {integrity: sha512-tX7bT6zxNeMwkc6hxHiZeUoTOjVrcjoh1Z3cmxOlodIqjl4HISgqfGOmkWSayky3Nv9Z5+KQH52F8nmXJY5AAA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5099,6 +5231,9 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + ocache@0.1.5: + resolution: {integrity: sha512-kNNnkkVQup/QDvmTz8Q84wc2ntiyoVHDxa6eHWKt5qdGAmFRBIxy83rxgCYEjW0x06UJ9E3P6VgM2yY4rOBH4w==} + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -5937,6 +6072,11 @@ packages: engines: {node: '>=20.16.0'} hasBin: true + srvx@0.12.5: + resolution: {integrity: sha512-IuvtDNQg5EIwv3c6dleyau7u8hCyGQ7D6+V/QM799Aud07z0wCUcurKLTRfyG33C8oUY+UWcVBFkfHMcbtmRLA==} + engines: {node: '>=20.16.0'} + hasBin: true + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -6507,6 +6647,80 @@ packages: uploadthing: optional: true + unstorage@2.0.0-alpha.7: + resolution: {integrity: sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog==} + peerDependencies: + '@azure/app-configuration': ^1.11.0 + '@azure/cosmos': ^4.9.1 + '@azure/data-tables': ^13.3.2 + '@azure/identity': ^4.13.0 + '@azure/keyvault-secrets': ^4.10.0 + '@azure/storage-blob': ^12.31.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.13.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.36.2 + '@vercel/blob': '>=0.27.3' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1.0.1 + aws4fetch: ^1.0.20 + chokidar: ^4 || ^5 + db0: '>=0.3.4' + idb-keyval: ^6.2.2 + ioredis: ^5.9.3 + lru-cache: ^11.2.6 + mongodb: ^6 || ^7 + ofetch: '*' + uploadthing: ^7.7.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + chokidar: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + lru-cache: + optional: true + mongodb: + optional: true + ofetch: + optional: true + uploadthing: + optional: true + untun@0.2.2: resolution: {integrity: sha512-+NnOJcSiEtYsVgJmXUzQbJeRAFXJC4yPJYuh6kF9B0Rm6zunXcs/3GZOTllyocSbUDIxD6Bj7e/4ATw7sph1Sw==} hasBin: true @@ -10277,6 +10491,10 @@ snapshots: optionalDependencies: srvx: 0.11.22 + crossws@0.4.10(srvx@0.12.5): + optionalDependencies: + srvx: 0.12.5 + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -10471,6 +10689,13 @@ snapshots: env-paths@2.2.1: {} + env-runner@0.1.16: + dependencies: + crossws: 0.4.10(srvx@0.11.22) + exsolve: 1.1.1 + httpxy: 0.5.5 + srvx: 0.11.22 + environment@1.1.0: {} error-ex@1.3.4: @@ -10832,6 +11057,12 @@ snapshots: ufo: 1.6.4 uncrypto: 0.1.3 + h3@2.0.1-rc.26(crossws@0.4.10(srvx@0.11.22)): + dependencies: + crossws: 0.4.10(srvx@0.12.5) + rou3: 0.9.1 + srvx: 0.12.5 + has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -11378,6 +11609,63 @@ snapshots: neo-async@2.6.2: {} + nf3@0.3.23: {} + + nitro@3.0.260522-beta(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(rollup@4.62.4)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11)): + dependencies: + consola: 3.4.2 + crossws: 0.4.10(srvx@0.11.22) + db0: 0.3.4 + dotenv: 17.4.2 + env-runner: 0.1.16 + giget: 3.3.1 + h3: 2.0.1-rc.26(crossws@0.4.10(srvx@0.11.22)) + hookable: 6.1.1 + jiti: 2.7.0 + nf3: 0.3.23 + ocache: 0.1.5 + ofetch: 2.0.0-alpha.3 + ohash: 2.0.11 + rolldown: 1.2.3 + rollup: 4.62.4 + srvx: 0.11.22 + unenv: 2.0.0-rc.24 + unstorage: 2.0.0-alpha.7(db0@0.3.4) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@netlify/runtime' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vercel/queue' + - aws4fetch + - better-sqlite3 + - chokidar + - drizzle-orm + - idb-keyval + - ioredis + - lru-cache + - miniflare + - mongodb + - mysql2 + - ofetch + - sqlite3 + - uploadthing + - wrangler + nitropack@2.13.4: dependencies: '@cloudflare/kv-asset-handler': 0.4.2 @@ -11697,6 +11985,10 @@ snapshots: obug@2.1.4: {} + ocache@0.1.5: + dependencies: + ohash: 2.0.11 + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -12697,6 +12989,8 @@ snapshots: srvx@0.11.22: {} + srvx@0.12.5: {} + stackback@0.0.2: {} standard-as-callback@2.1.0: {} @@ -13177,6 +13471,10 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.4 + unstorage@2.0.0-alpha.7(db0@0.3.4): + dependencies: + db0: 0.3.4 + untun@0.2.2: {} untyped@2.0.0: From a16364e2979959c01dfb6519e0c9138716313a7a Mon Sep 17 00:00:00 2001 From: RedStar071 Date: Mon, 10 Aug 2026 08:22:37 +0000 Subject: [PATCH 2/2] fix(server): authorize task checkouts against a managed root POST /tasks is unauthenticated transport, so the requested repository is untrusted input. The route no longer accepts a raw filesystem path: the body carries an identifier that must canonically resolve (realpath, so symlinks cannot escape) to a directory strictly inside the operator configured AGENT_ZERO_CHECKOUT_ROOT. Without a configured root the route fails closed with 403 and runs nothing. Co-authored-by: Codesmith --- .env.example | 1 + apps/server/server/routes/tasks/index.post.ts | 5 +- apps/server/src/checkout.test.ts | 63 ++++++++++++++++++ apps/server/src/checkout.ts | 64 +++++++++++++++++++ apps/server/src/http.test.ts | 36 +++++++++-- apps/server/src/http.ts | 25 ++++++-- apps/server/src/index.ts | 12 +++- 7 files changed, 191 insertions(+), 15 deletions(-) create mode 100644 apps/server/src/checkout.test.ts create mode 100644 apps/server/src/checkout.ts diff --git a/.env.example b/.env.example index 3ae050b..3e99b3e 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ OPENAI_API_KEY= AGENT_ZERO_MODEL=gpt-5 AGENT_ZERO_PORT=4040 +AGENT_ZERO_CHECKOUT_ROOT= GITHUB_TOKEN= GITHUB_WEBHOOK_SECRET= diff --git a/apps/server/server/routes/tasks/index.post.ts b/apps/server/server/routes/tasks/index.post.ts index 76e400f..1cf39b1 100644 --- a/apps/server/server/routes/tasks/index.post.ts +++ b/apps/server/server/routes/tasks/index.post.ts @@ -1,5 +1,8 @@ import { defineHandler } from 'nitro'; +import { checkoutRootFromEnvironment } from '../../../src/checkout.js'; import { createTaskResponse } from '../../../src/http.js'; -export default defineHandler((event) => createTaskResponse(event.req)); +export default defineHandler((event) => + createTaskResponse(event.req, { checkoutRoot: checkoutRootFromEnvironment() }), +); diff --git a/apps/server/src/checkout.test.ts b/apps/server/src/checkout.test.ts new file mode 100644 index 0000000..7daa84a --- /dev/null +++ b/apps/server/src/checkout.test.ts @@ -0,0 +1,63 @@ +import { mkdir, mkdtemp, realpath, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { beforeEach, describe, expect, it } from 'vitest'; + +import { resolveCheckout } from './checkout.js'; + +let root: string; + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'agent-zero-server-checkout-'))); + await mkdir(join(root, 'repo')); +}); + +describe('resolveCheckout', () => { + it('fails closed when no checkout root is configured', async () => { + const outcome = await resolveCheckout('repo', undefined); + expect(outcome.authorized).toBe(false); + }); + + it('resolves an identifier to its canonical checkout inside the root', async () => { + const outcome = await resolveCheckout('repo', root); + expect(outcome).toEqual({ authorized: true, path: join(root, 'repo') }); + }); + + it('rejects absolute paths', async () => { + const outcome = await resolveCheckout(join(root, 'repo'), root); + expect(outcome.authorized).toBe(false); + }); + + it('rejects traversal outside the root', async () => { + const outcome = await resolveCheckout(join('..', 'escape'), root); + expect(outcome.authorized).toBe(false); + }); + + it('rejects the root itself', async () => { + const outcome = await resolveCheckout('.', root); + expect(outcome.authorized).toBe(false); + }); + + it('rejects an identifier that does not exist under the root', async () => { + const outcome = await resolveCheckout('missing', root); + expect(outcome.authorized).toBe(false); + }); + + it('rejects a symlink that escapes the root', async () => { + await symlink(tmpdir(), join(root, 'sneaky')); + const outcome = await resolveCheckout('sneaky', root); + expect(outcome.authorized).toBe(false); + }); + + it('fails closed when the configured root does not exist', async () => { + const outcome = await resolveCheckout('repo', join(root, 'missing-root')); + expect(outcome.authorized).toBe(false); + }); + + it('resolves nested identifiers that stay inside the root', async () => { + await mkdir(join(root, 'repo', 'nested')); + const outcome = await resolveCheckout(join('repo', 'nested'), root); + expect(outcome).toEqual({ authorized: true, path: join(root, 'repo', 'nested') }); + }); +}); diff --git a/apps/server/src/checkout.ts b/apps/server/src/checkout.ts new file mode 100644 index 0000000..37f4d49 --- /dev/null +++ b/apps/server/src/checkout.ts @@ -0,0 +1,64 @@ +import { realpath } from 'node:fs/promises'; +import { isAbsolute, relative, resolve } from 'node:path'; + +/** + * Authorization of inbound task checkouts. + * + * `POST /tasks` is reachable by anything that can reach the listener, so the requested repository + * is untrusted input. The route never accepts a raw filesystem path: it accepts an identifier that + * must resolve, after canonicalization, to a directory strictly inside the managed checkout root + * the operator configured. Without a configured root the route fails closed and runs nothing. + */ + +/** The managed checkout root a deployment authorizes for inbound task requests. */ +export function checkoutRootFromEnvironment(): string | undefined { + const root = process.env.AGENT_ZERO_CHECKOUT_ROOT?.trim(); + return root ? root : undefined; +} + +export type CheckoutResolution = + | { authorized: true; path: string } + | { authorized: false; reason: string }; + +/** + * Resolve an inbound repository identifier against the managed checkout root. + * + * Both the root and the candidate are canonicalized with `realpath`, so symlinks cannot smuggle a + * checkout out of the root, and containment is checked on the canonical paths. The root itself is + * not a checkout and is rejected. + */ +export async function resolveCheckout( + repository: string, + root: string | undefined, +): Promise { + if (!root) + return { + authorized: false, + reason: 'Task execution is disabled: AGENT_ZERO_CHECKOUT_ROOT is not configured', + }; + if (isAbsolute(repository)) + return { + authorized: false, + reason: 'Repository must be an identifier relative to the managed checkout root', + }; + + let canonicalRoot: string; + try { + canonicalRoot = await realpath(resolve(root)); + } catch { + return { authorized: false, reason: 'The managed checkout root does not exist' }; + } + + let canonical: string; + try { + canonical = await realpath(resolve(canonicalRoot, repository)); + } catch { + return { authorized: false, reason: `Unknown repository: ${repository}` }; + } + + const contained = relative(canonicalRoot, canonical); + if (contained === '' || contained.startsWith('..') || isAbsolute(contained)) + return { authorized: false, reason: 'Repository is outside the managed checkout root' }; + + return { authorized: true, path: canonical }; +} diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index ddabbc5..7a5d9d9 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -7,11 +7,14 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createTaskResponse, evidenceResponse, taskResponse } from './http.js'; import { runTask, tasks } from './router.js'; +let root: string; let checkout: string; beforeEach(async () => { tasks.clear(); - checkout = await mkdtemp(join(tmpdir(), 'agent-zero-server-http-')); + root = await mkdtemp(join(tmpdir(), 'agent-zero-server-http-')); + checkout = join(root, 'repo'); + await mkdir(checkout); await writeFile(join(checkout, 'package.json'), JSON.stringify({ scripts: {} }), 'utf8'); }); @@ -56,20 +59,41 @@ describe('evidenceResponse', () => { describe('createTaskResponse', () => { it('rejects a body that is not JSON', async () => { - expect(statusOf(await createTaskResponse(postRequest('not json')))).toBe(400); + const outcome = await createTaskResponse(postRequest('not json'), { checkoutRoot: root }); + expect(statusOf(outcome)).toBe(400); }); it('rejects input the task schema refuses', async () => { const outcome = await createTaskResponse( - postRequest(JSON.stringify({ repository: checkout, feedback: 'x', mode: 'yolo' })), + postRequest(JSON.stringify({ repository: 'repo', feedback: 'x', mode: 'yolo' })), + { checkoutRoot: root }, ); expect(statusOf(outcome)).toBe(400); expect(tasks.size).toBe(0); }); - it('runs a validated task and stores its evidence', async () => { + it('fails closed when no checkout root is configured', async () => { + const outcome = await createTaskResponse( + postRequest(JSON.stringify({ repository: 'repo', feedback: 'x', mode: 'observe' })), + { checkoutRoot: undefined }, + ); + expect(statusOf(outcome)).toBe(403); + expect(tasks.size).toBe(0); + }); + + it('refuses a repository outside the managed checkout root', async () => { + const outcome = await createTaskResponse( + postRequest(JSON.stringify({ repository: '../escape', feedback: 'x', mode: 'observe' })), + { checkoutRoot: root }, + ); + expect(statusOf(outcome)).toBe(403); + expect(tasks.size).toBe(0); + }); + + it('runs a validated task against an authorized checkout and stores its evidence', async () => { const outcome = await createTaskResponse( - postRequest(JSON.stringify({ repository: checkout, feedback: 'x', mode: 'observe' })), + postRequest(JSON.stringify({ repository: 'repo', feedback: 'x', mode: 'observe' })), + { checkoutRoot: root }, ); expect(statusOf(outcome)).toBeUndefined(); expect(tasks.size).toBe(1); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 3ced3fc..d647dff 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -1,5 +1,6 @@ import type { TaskResult } from '@agent-zero/shared'; +import { resolveCheckout } from './checkout.js'; import { createTask, getTask, getTaskEvidence, taskInput } from './router.js'; /** @@ -25,15 +26,23 @@ export function evidenceResponse(id: string | undefined): Response { }); } +export interface CreateTaskOptions { + /** Supplied by the caller rather than read here, so the authorized root stays explicit. */ + checkoutRoot: string | undefined; +} + /** - * Validate and run one task from an inbound request body. + * Validate, authorize, and run one task from an inbound request body. * - * The body is validated with the transport-independent schema before anything executes, so the - * HTTP layer never chooses a mode or repository on its own. + * The body is validated with the transport-independent schema before anything executes, and the + * requested repository is an identifier that must resolve to a canonical directory inside the + * managed checkout root. Without a configured root the route fails closed, so the HTTP layer never + * chooses a mode or a checkout on its own. */ -export async function createTaskResponse(request: { - json(): Promise; -}): Promise { +export async function createTaskResponse( + request: { json(): Promise }, + options: CreateTaskOptions, +): Promise { let body: unknown; try { body = await request.json(); @@ -46,5 +55,7 @@ export async function createTaskResponse(request: { { error: 'Invalid task input', issues: parsed.error.issues }, { status: 400 }, ); - return await createTask(parsed.data); + const checkout = await resolveCheckout(parsed.data.repository, options.checkoutRoot); + if (!checkout.authorized) return Response.json({ error: checkout.reason }, { status: 403 }); + return await createTask({ ...parsed.data, repository: checkout.path }); } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 52de21d..c41348a 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,4 +1,14 @@ -export { createTaskResponse, evidenceResponse, taskResponse } from './http.js'; +export { + checkoutRootFromEnvironment, + resolveCheckout, + type CheckoutResolution, +} from './checkout.js'; +export { + createTaskResponse, + evidenceResponse, + taskResponse, + type CreateTaskOptions, +} from './http.js'; export { applyPortEnvironment } from './port.js'; export { createTask,