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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/orpc-server
1 change: 1 addition & 0 deletions .skills/agent-zero-architecture/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ Keep dependency direction explicit while changing the monorepo.
- `runner`: command execution and checkout mutation boundary, plus the policy-to-boundary factory.
- `agent`: orchestration, the lifecycle machine, and the validation policy.
- `cli`: argument parsing and terminal presentation.
- `apps/server`: oRPC transport, task persistence, scheduling, and the composition root that constructs a runner. See the `orpc-server` skill.
- `apps/dashboard`: frontend-only Nuxt operational dashboard with no runtime-package dependencies.

## Workflow
Expand Down
32 changes: 32 additions & 0 deletions .skills/orpc-server/SKILL.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
name: orpc-server
description: Use when changing apps/server procedures, oRPC contracts, handlers, middleware, transport setup, or typed clients.
---

# oRPC server

`apps/server` is a transport adapter and composition root built with oRPC.

## Rules

- Keep procedure contracts and router composition in `apps/server/src/rpc.ts`, and the runtime
operations they delegate to in `apps/server/src/router.ts`.
- Keep Node HTTP startup in `apps/server/src/index.ts` and request routing in
`apps/server/src/http.ts`.
- Infer client types from the router; do not duplicate request or response interfaces.
- Validate inputs at the procedure boundary and return stable domain-shaped results.
- Procedures call the agent runtime through typed APIs. They do not execute shell commands or
mutate checkouts directly.
- Persist through the `KeyValueStorage` contract so Redis, KV, and Nitro drivers stay
interchangeable; never store review input or checkout paths.
- Keep transport-specific headers, status mapping, and request objects out of runtime packages.
- Do not introduce Hono, Nitro, or a second HTTP framework.

## Workflow

1. Read the router, its tests, and the runtime method being exposed.
2. Define or adjust the oRPC procedure contract.
3. Keep the handler thin: validate, authorize, delegate, translate.
4. Add router tests with `createRouterClient`, without opening a real network port.
5. Update the README client example when the public router shape changes.
6. Run `aube run test --filter @agent-zero/server`, typecheck, and build.
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`: oRPC control-plane transport and composition root.
- `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
36 changes: 36 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,7 @@ GitHub adapter / CLI
Runner boundary ─── repository commands and file operations

oRPC control plane ─── typed task API, persistence, and scheduling
Nuxt dashboard ─── frontend-only operational interface
```

Expand All@@ -56,6 +57,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) | oRPC control-plane transport and composition root |
| [`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 DownExpand Up@@ -96,6 +98,40 @@ The CLI parses arguments with [`@bomb.sh/args`](https://github.com/bomb-sh/args)

---

## Control plane

`aube --filter @agent-zero/server run dev` starts the control plane on `http://localhost:3001` (override with `PORT`; 3000 belongs to the dashboard). It is the only adapter that composes a runner for hosted work, and it exposes exactly two surfaces:

| Surface | Purpose |
| -------------------- | ------------------------------------------------------------------------- |
| `/rpc/**` | Typed oRPC router: `health`, `tasks.list/get/create`, `approvals.decide` |
| `GET /api/dashboard` | One aggregate view: task history plus queue, approval, and usage counters |

Reads are open for the dashboard; mutations (`tasks.create`, `approvals.decide`) fail closed. `AGENT_ZERO_CONTROL_PLANE_TOKENS` holds comma-separated `name:token` bearer credentials, and `AGENT_ZERO_CONTROL_PLANE_REPOSITORIES` allow-lists the repository paths `tasks.create` may target; without them every mutation is rejected. `AGENT_ZERO_CONTROL_PLANE_MODES` holds comma-separated `name:mode|mode` grants for the execution modes each principal may request; without a grant a principal may only request the non-writable `observe` and `suggest` modes, so `fix` and `autonomous` require an explicit operator grant. The approval actor is the authenticated principal's name, never a wire-supplied value.

Clients infer their types from the router rather than redeclaring request and response shapes:

```ts
import { createORPCClient } from '@orpc/client';
import { RPCLink } from '@orpc/client/fetch';
import type { RouterClient } from '@orpc/server';
import type { RpcRouter } from '@agent-zero/server';

const client: RouterClient<RpcRouter> = createORPCClient(
new RPCLink({
url: 'http://localhost:3001/rpc',
headers: { authorization: `Bearer ${process.env.CONTROL_PLANE_TOKEN}` },
}),
);

const { tasks } = await client.tasks.list();
await client.approvals.decide({ taskId: tasks[0]!.id, decision: 'approved' });
```

Task history persists through a `KeyValueStorage` contract — a filesystem store by default, with Redis, KV, or Nitro storage dropping in unchanged. Records are redacted before they are written and never contain review input or checkout paths. `TaskScheduler` bounds work globally and per repository, so a burst queues instead of fanning out unbounded runs.

---

## Dashboard

`aube run dev` starts the frontend-only Nuxt dashboard on `http://localhost:3000`. It is an operational interface shell: it does not expose API or RPC routes, persist task data, import runtime packages, or execute repository work.
Expand Down
32 changes: 32 additions & 0 deletions apps/server/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
{
"name": "@agent-zero/server",
"version": "0.3.0",
"type": "module",
"scripts": {
"build": "tsdown",
"clean": "tsc -b --clean",
"dev": "node --import tsx src/index.ts",
"lint": "oxlint --config ../../.oxlintrc.json --type-aware --type-check src shared",
"start": "node dist/index.js",
"test": "vitest run src",
"typecheck": "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:*",
"@orpc/server": "2.0.0-beta.26",
"zod": "^4.1.5"
},
"devDependencies": {
"oxlint": "^1.44.0",
"oxlint-tsgolint": "^7.0.2001",
"tsdown": "^0.22.14",
"tsx": "^4.20.5",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
}
31 changes: 31 additions & 0 deletions apps/server/shared/dashboard.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
import type { TaskEvent, TaskResult } from '@agent-zero/shared';

export type ControlPlaneTaskStatus = 'queued' | 'running' | 'completed' | 'needs-human' | 'failed';
export type ApprovalDecision = 'approved' | 'rejected';

export interface TaskApproval {
decision: ApprovalDecision;
actor: string;
comment: string | null;
decidedAt: string;
}

export interface DashboardTask {
id: string;
repository: string;
status: ControlPlaneTaskStatus;
createdAt: string;
updatedAt: string;
events: TaskEvent[];
result?: TaskResult;
approval?: TaskApproval;
}

export interface DashboardOverview {
tasks: DashboardTask[];
active: number;
queued: number;
awaitingApproval: number;
totalTokens: number;
costUsd: number;
}
125 changes: 125 additions & 0 deletions apps/server/src/auth.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';

import {
accessFromEnvironment,
authenticate,
mayTargetRepository,
type ControlPlaneAccess,
} from './auth.js';

const TOKEN_FORMAT_ERROR = /name:token/;
const MODE_FORMAT_ERROR = /name:mode\|mode/;
const UNKNOWN_MODE_ERROR = /unknown mode/;
const UNKNOWN_PRINCIPAL_ERROR = /unknown principal/;

function access(overrides: Partial<ControlPlaneAccess> = {}): ControlPlaneAccess {
return {
principals: new Map([
['token-value', { name: 'release-manager', modes: ['observe', 'suggest'] as const }],
]),
repositories: ['/srv/checkout'],
...overrides,
};
}

describe('accessFromEnvironment', () => {
it('fails closed when no tokens are configured', () => {
expect(accessFromEnvironment(undefined, '/srv/checkout')).toBeUndefined();
expect(accessFromEnvironment('', '/srv/checkout')).toBeUndefined();
expect(accessFromEnvironment(' , ', '/srv/checkout')).toBeUndefined();
});

it('parses name:token pairs and the repository allow-list', () => {
const parsed = accessFromEnvironment('release-manager:tok1, ci:tok2', '/srv/app, ./checkout');
expect(parsed?.principals.get('tok1')?.name).toBe('release-manager');
expect(parsed?.principals.get('tok2')?.name).toBe('ci');
expect(parsed?.repositories).toEqual(['/srv/app', './checkout']);
});

it('keeps tokens containing separators intact after the first colon', () => {
const parsed = accessFromEnvironment('ops:v1:secret');
expect(parsed?.principals.get('v1:secret')?.name).toBe('ops');
});

it('refuses malformed entries rather than silently dropping them', () => {
expect(() => accessFromEnvironment('missing-separator')).toThrow(TOKEN_FORMAT_ERROR);
expect(() => accessFromEnvironment(':token-only')).toThrow(TOKEN_FORMAT_ERROR);
expect(() => accessFromEnvironment('name-only:')).toThrow(TOKEN_FORMAT_ERROR);
});

it('defaults to an empty repository allow-list', () => {
expect(accessFromEnvironment('ops:tok', undefined)?.repositories).toEqual([]);
});

it('grants only the non-writable modes without an explicit mode entry', () => {
const parsed = accessFromEnvironment('ops:tok', undefined, undefined);
expect(parsed?.principals.get('tok')?.modes).toEqual(['observe', 'suggest']);
});

it('parses per-principal mode grants', () => {
const parsed = accessFromEnvironment(
'release-manager:tok1, ci:tok2',
undefined,
'release-manager:observe|fix|autonomous',
);
expect(parsed?.principals.get('tok1')?.modes).toEqual(['observe', 'fix', 'autonomous']);
expect(parsed?.principals.get('tok2')?.modes).toEqual(['observe', 'suggest']);
});

it('refuses unknown modes rather than silently granting or dropping them', () => {
expect(() => accessFromEnvironment('ops:tok', undefined, 'ops:yolo')).toThrow(
UNKNOWN_MODE_ERROR,
);
});

it('refuses mode grants for principals that hold no token', () => {
expect(() => accessFromEnvironment('ops:tok', undefined, 'ghost:fix')).toThrow(
UNKNOWN_PRINCIPAL_ERROR,
);
});

it('refuses malformed mode entries', () => {
expect(() => accessFromEnvironment('ops:tok', undefined, 'ops')).toThrow(MODE_FORMAT_ERROR);
expect(() => accessFromEnvironment('ops:tok', undefined, 'ops:')).toThrow(MODE_FORMAT_ERROR);
expect(() => accessFromEnvironment('ops:tok', undefined, ':fix')).toThrow(MODE_FORMAT_ERROR);
expect(() => accessFromEnvironment('ops:tok', undefined, 'ops:|')).toThrow(MODE_FORMAT_ERROR);
});
});

describe('authenticate', () => {
it('resolves the principal for a valid bearer token', () => {
expect(authenticate('Bearer token-value', access())).toEqual({
name: 'release-manager',
modes: ['observe', 'suggest'],
});
});

it('rejects missing, malformed, and unknown credentials', () => {
expect(authenticate(undefined, access())).toBeUndefined();
expect(authenticate('token-value', access())).toBeUndefined();
expect(authenticate('Basic token-value', access())).toBeUndefined();
expect(authenticate('Bearer wrong-token', access())).toBeUndefined();
expect(authenticate('Bearer token-valu', access())).toBeUndefined();
});

it('fails closed when no access policy is configured', () => {
expect(authenticate('Bearer token-value', undefined)).toBeUndefined();
});
});

describe('mayTargetRepository', () => {
it('authorizes only allow-listed repository paths', () => {
expect(mayTargetRepository('/srv/checkout', access())).toBe(true);
expect(mayTargetRepository('/srv/other', access())).toBe(false);
});

it('compares resolved paths so traversal cannot dodge the allow-list', () => {
expect(mayTargetRepository('/srv/checkout/../checkout', access())).toBe(true);
expect(mayTargetRepository('/srv/checkout/../other', access())).toBe(false);
});

it('fails closed without a policy or with an empty allow-list', () => {
expect(mayTargetRepository('/srv/checkout', undefined)).toBe(false);
expect(mayTargetRepository('/srv/checkout', access({ repositories: [] }))).toBe(false);
});
});
Loading
Loading