Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
@@ -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=
1 change: 1 addition & 0 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
12 changes: 12 additions & 0 deletions apps/server/nitro.config.ts
Original file line numberDiff line numberDiff line change
@@ -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,
});
31 changes: 31 additions & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
@@ -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"
}
}
5 changes: 5 additions & 0 deletions apps/server/server/routes/health.get.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
import { defineHandler } from 'nitro';

import { health } from '../../src/router.js';

export default defineHandler(() => health());
5 changes: 5 additions & 0 deletions apps/server/server/routes/tasks/[id].get.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
import { defineHandler } from 'nitro';

import { taskResponse } from '../../../src/http.js';

export default defineHandler((event) => taskResponse(event.context.params?.id));
5 changes: 5 additions & 0 deletions apps/server/server/routes/tasks/[id]/evidence.get.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
import { defineHandler } from 'nitro';

import { evidenceResponse } from '../../../../src/http.js';

export default defineHandler((event) => evidenceResponse(event.context.params?.id));
5 changes: 5 additions & 0 deletions apps/server/server/routes/tasks/index.get.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
import { defineHandler } from 'nitro';

import { listTasks } from '../../../src/router.js';

export default defineHandler(() => listTasks());
8 changes: 8 additions & 0 deletions apps/server/server/routes/tasks/index.post.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +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, { checkoutRoot: checkoutRootFromEnvironment() }),
);
63 changes: 63 additions & 0 deletions apps/server/src/checkout.test.ts
Original file line numberDiff line numberDiff line change
@@ -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') });
});
});
64 changes: 64 additions & 0 deletions apps/server/src/checkout.ts
Original file line numberDiff line numberDiff line change
@@ -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<CheckoutResolution> {
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 };
}
101 changes: 101 additions & 0 deletions apps/server/src/http.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
import { mkdir, 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 root: string;
let checkout: string;

beforeEach(async () => {
tasks.clear();
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');
});

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 () => {
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: 'repo', feedback: 'x', mode: 'yolo' })),
{ checkoutRoot: root },
);
expect(statusOf(outcome)).toBe(400);
expect(tasks.size).toBe(0);
});

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: 'repo', feedback: 'x', mode: 'observe' })),
{ checkoutRoot: root },
);
expect(statusOf(outcome)).toBeUndefined();
expect(tasks.size).toBe(1);
});
});
61 changes: 61 additions & 0 deletions apps/server/src/http.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
import type { TaskResult } from '@agent-zero/shared';

import { resolveCheckout } from './checkout.js';
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' },
});
}

export interface CreateTaskOptions {
/** Supplied by the caller rather than read here, so the authorized root stays explicit. */
checkoutRoot: string | undefined;
}

/**
* Validate, authorize, and run one task from an inbound request body.
*
* 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<unknown> },
options: CreateTaskOptions,
): Promise<TaskResult | Response> {
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 },
);
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 });
}
Loading
Loading