API - CODEX AGENTS: product tool boundary

This commit is contained in:
DCCONSTRUCTIONS 2026-05-14 19:36:39 +03:00
parent 14c5f490b1
commit 9f402074f2
9 changed files with 723 additions and 1 deletions

View File

@ -26,6 +26,7 @@ All writes go through NODE.DC Agent Gateway, are scoped by agent grants, and are
- Internal REST endpoints for agent profile, grant, and token lifecycle.
- Opaque agent tokens are generated once and stored only as SHA-256 hashes.
- Authenticated agent-session endpoint returns effective grants/scopes for future MCP calls.
- Product tool endpoints validate agent token, scopes, and project grants before calling Tasker internal adapter.
- MCP and Tasker write execution are documented but not implemented yet.
## Local development
@ -43,6 +44,7 @@ Useful checks:
```bash
npm run check
npm run build
npm run smoke:gateway
curl http://127.0.0.1:4100/readyz
curl http://127.0.0.1:4100/api/v1/meta/capabilities
```
@ -67,3 +69,25 @@ curl http://127.0.0.1:4100/api/v1/agent-session \
```
Do not expose these lifecycle endpoints publicly before the Launcher/internal auth layer is added.
## Local testing strategy
No fake Tasker storage is embedded into Agent Gateway.
Local verification is split into product layers:
1. `npm run smoke:gateway` verifies real Agent Gateway persistence, bearer token auth, scope checks, grant checks, and the boundary before Tasker calls.
2. Full localhost e2e starts after Tasker implements `/api/internal/nodedc/agent/...` adapter. Then the same Gateway tool endpoints call the real local Tasker runtime.
3. External-machine testing uses the same token and endpoint shape against staging HTTPS; no extra protocol or fake environment should be introduced.
Current Tasker internal adapter contract expected by Gateway:
- `POST /api/internal/nodedc/agent/projects/resolve`
- `GET /api/internal/nodedc/agent/projects/:projectId/context`
- `GET /api/internal/nodedc/agent/issues?project_id=...`
- `POST /api/internal/nodedc/agent/issues`
- `PATCH /api/internal/nodedc/agent/issues/:issueId`
- `POST /api/internal/nodedc/agent/issues/:issueId/move`
- `POST /api/internal/nodedc/agent/issues/:issueId/comments`
- `PUT /api/internal/nodedc/agent/issues/:issueId/labels`
- `PUT /api/internal/nodedc/agent/issues/:issueId/assignees`

View File

@ -23,7 +23,7 @@ Exit criteria:
## Phase 1. Agent Gateway skeleton
Status: in progress. Initial service, migrations, persistence endpoints, token hashing, bearer-token session auth, local Postgres compose, and smoke checks are implemented.
Status: in progress. Initial service, migrations, persistence endpoints, token hashing, bearer-token session auth, product tool endpoints, local Postgres compose, and Gateway smoke checks are implemented.
Create standalone service with:

View File

@ -12,6 +12,7 @@
"check": "tsc --noEmit -p tsconfig.json",
"migrate": "tsx src/scripts/migrate.ts",
"migrate:dist": "node dist/scripts/migrate.js",
"smoke:gateway": "tsx src/scripts/smoke-gateway.ts",
"start": "node dist/server.js"
},
"dependencies": {

View File

@ -6,11 +6,18 @@ import { createPool, DatabaseNotConfiguredError } from "./db/pool.js";
import { AgentsRepository } from "./repositories/agents.js";
import { registerAgentRoutes } from "./routes/agents.js";
import { registerHealthRoutes } from "./routes/health.js";
import { registerToolRoutes } from "./routes/tools.js";
import { ForbiddenError } from "./security/authorization.js";
import { UnauthorizedError } from "./security/bearer.js";
import { TaskerAdapterError, TaskerAdapterNotConfiguredError, TaskerAdapterUnavailableError, TaskerClient } from "./tasker/client.js";
export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
const pool = createPool(config);
const agentsRepository = pool ? new AgentsRepository(pool) : null;
const taskerClient = new TaskerClient({
baseUrl: config.NODEDC_TASKER_INTERNAL_URL,
internalAccessToken: config.NODEDC_INTERNAL_ACCESS_TOKEN,
});
const app = Fastify({
logger: {
level: config.LOG_LEVEL,
@ -49,6 +56,44 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
return;
}
if (error instanceof ForbiddenError) {
void reply.status(403).send({
ok: false,
error: "forbidden",
message: error.message,
});
return;
}
if (error instanceof TaskerAdapterNotConfiguredError) {
void reply.status(503).send({
ok: false,
error: "tasker_adapter_not_configured",
message: error.message,
});
return;
}
if (error instanceof TaskerAdapterError) {
void reply.status(error.statusCode).send({
ok: false,
error: "tasker_adapter_error",
message: error.message,
tasker_status: error.statusCode,
tasker_payload: error.payload,
});
return;
}
if (error instanceof TaskerAdapterUnavailableError) {
void reply.status(502).send({
ok: false,
error: "tasker_adapter_unavailable",
message: error.message,
});
return;
}
app.log.error(error);
void reply.status(500).send({
ok: false,
@ -59,6 +104,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
await registerHealthRoutes(app, config, pool);
await registerAgentRoutes(app, { agentsRepository });
await registerToolRoutes(app, { agentsRepository, taskerClient });
return app;
}

24
src/routes/session.ts Normal file
View File

@ -0,0 +1,24 @@
import type { FastifyRequest } from "fastify";
import { DatabaseNotConfiguredError } from "../db/pool.js";
import type { AgentsRepository, AgentSessionRecord } from "../repositories/agents.js";
import { parseBearerToken, UnauthorizedError } from "../security/bearer.js";
export type SessionRouteDeps = {
agentsRepository: AgentsRepository | null;
};
export async function authenticateAgent(request: FastifyRequest, deps: SessionRouteDeps): Promise<AgentSessionRecord> {
if (!deps.agentsRepository) {
throw new DatabaseNotConfiguredError();
}
const token = parseBearerToken(request.headers.authorization);
const session = await deps.agentsRepository.findActiveSessionByToken(token);
if (!session) {
throw new UnauthorizedError("Agent token is inactive, expired, or revoked.");
}
return session;
}

200
src/routes/tools.ts Normal file
View File

@ -0,0 +1,200 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { AgentScope } from "../domain/scopes.js";
import { structuredBlocksSchema } from "../domain/structured-blocks.js";
import type { AgentsRepository, AgentSessionRecord } from "../repositories/agents.js";
import { ForbiddenError, requireProjectGrant, requireScope } from "../security/authorization.js";
import type { TaskerClient } from "../tasker/client.js";
import { authenticateAgent } from "./session.js";
type ToolRouteDeps = {
agentsRepository: AgentsRepository | null;
taskerClient: TaskerClient;
};
const projectParamsSchema = z.object({
projectId: z.string().min(1),
});
const issueParamsSchema = z.object({
issueId: z.string().min(1),
});
const listIssuesQuerySchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).optional(),
query: z.string().min(1).optional(),
});
const projectContextQuerySchema = z.object({
workspace_slug: z.string().min(1).optional(),
});
const prioritySchema = z.enum(["none", "low", "medium", "high", "urgent"]);
const createIssueBodySchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
title: z.string().min(1).max(500),
description: z.string().max(20000).optional(),
priority: prioritySchema.optional(),
structured_blocks: structuredBlocksSchema.optional(),
});
const updateIssueBodySchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
title: z.string().min(1).max(500).optional(),
description: z.string().max(20000).optional(),
priority: prioritySchema.optional(),
structured_blocks: structuredBlocksSchema.optional(),
});
const moveIssueBodySchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
state_id: z.string().min(1),
});
const commentBodySchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
body: z.string().min(1).max(20000),
});
const labelsBodySchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
label_ids: z.array(z.string().min(1)).default([]),
});
const assigneesBodySchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
member_ids: z.array(z.string().min(1)).default([]),
});
export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDeps): Promise<void> {
app.get("/api/v1/tools/projects", async (request) => {
const session = await authenticateAgent(request, deps);
requireScope(session, "project:read");
return deps.taskerClient.listGrantedProjects(session);
});
app.get("/api/v1/tools/projects/:projectId/context", async (request) => {
const session = await authenticateAgent(request, deps);
const { projectId } = projectParamsSchema.parse(request.params);
const query = projectContextQuerySchema.parse(request.query);
requireScope(session, "project:read");
requireProjectGrant(session, { projectId, workspaceSlug: query.workspace_slug });
return deps.taskerClient.getProjectContext(session, projectId, query.workspace_slug);
});
app.get("/api/v1/tools/issues", async (request) => {
const session = await authenticateAgent(request, deps);
const query = listIssuesQuerySchema.parse(request.query);
requireScope(session, "issue:read");
requireProjectGrant(session, { projectId: query.project_id, workspaceSlug: query.workspace_slug });
return deps.taskerClient.listIssues(session, query.project_id, query.workspace_slug, query.query);
});
app.post("/api/v1/tools/issues", async (request) => {
const session = await authenticateAgent(request, deps);
const body = createIssueBodySchema.parse(request.body);
requireToolAccess(session, "issue:create", body.project_id, body.workspace_slug);
return deps.taskerClient.createIssue(session, {
project_id: body.project_id,
workspace_slug: body.workspace_slug,
title: body.title,
description: body.description,
priority: body.priority,
structured_blocks: body.structured_blocks,
});
});
app.patch("/api/v1/tools/issues/:issueId", async (request) => {
const session = await authenticateAgent(request, deps);
const { issueId } = issueParamsSchema.parse(request.params);
const body = updateIssueBodySchema.parse(request.body);
requireToolAccess(session, "issue:update", body.project_id, body.workspace_slug);
if (body.structured_blocks) {
requireScope(session, "issue:structured_blocks:write");
}
return deps.taskerClient.updateIssue(session, issueId, {
project_id: body.project_id,
workspace_slug: body.workspace_slug,
title: body.title,
description: body.description,
priority: body.priority,
structured_blocks: body.structured_blocks,
});
});
app.post("/api/v1/tools/issues/:issueId/move", async (request) => {
const session = await authenticateAgent(request, deps);
const { issueId } = issueParamsSchema.parse(request.params);
const body = moveIssueBodySchema.parse(request.body);
requireToolAccess(session, "issue:move", body.project_id, body.workspace_slug);
return deps.taskerClient.moveIssue(session, issueId, {
project_id: body.project_id,
workspace_slug: body.workspace_slug,
state_id: body.state_id,
});
});
app.post("/api/v1/tools/issues/:issueId/comments", async (request) => {
const session = await authenticateAgent(request, deps);
const { issueId } = issueParamsSchema.parse(request.params);
const body = commentBodySchema.parse(request.body);
requireToolAccess(session, "issue:comment", body.project_id, body.workspace_slug);
return deps.taskerClient.appendComment(session, issueId, {
project_id: body.project_id,
workspace_slug: body.workspace_slug,
body: body.body,
});
});
app.put("/api/v1/tools/issues/:issueId/labels", async (request) => {
const session = await authenticateAgent(request, deps);
const { issueId } = issueParamsSchema.parse(request.params);
const body = labelsBodySchema.parse(request.body);
requireToolAccess(session, "issue:label", body.project_id, body.workspace_slug);
return deps.taskerClient.setLabels(session, issueId, {
project_id: body.project_id,
workspace_slug: body.workspace_slug,
label_ids: body.label_ids,
});
});
app.put("/api/v1/tools/issues/:issueId/assignees", async (request) => {
const session = await authenticateAgent(request, deps);
const { issueId } = issueParamsSchema.parse(request.params);
const body = assigneesBodySchema.parse(request.body);
requireToolAccess(session, "issue:assign", body.project_id, body.workspace_slug);
return deps.taskerClient.assignIssue(session, issueId, {
project_id: body.project_id,
workspace_slug: body.workspace_slug,
member_ids: body.member_ids,
});
});
}
function requireToolAccess(session: AgentSessionRecord, scope: AgentScope, projectId: string, workspaceSlug?: string | null): void {
requireScope(session, scope);
const grant = requireProjectGrant(session, { projectId, workspaceSlug });
if (!grant.scopes.includes(scope)) {
throw new ForbiddenError(`Grant for project does not include required scope: ${scope}.`);
}
}

View File

@ -0,0 +1,139 @@
import { Pool } from "pg";
import { buildApp } from "../app.js";
import { loadConfig } from "../config.js";
import { runMigrations } from "../db/migrations.js";
const envWithoutTaskerToken = { ...process.env };
delete envWithoutTaskerToken.NODEDC_INTERNAL_ACCESS_TOKEN;
envWithoutTaskerToken.LOG_LEVEL = "silent";
const config = loadConfig(envWithoutTaskerToken);
if (!config.DATABASE_URL) {
throw new Error("DATABASE_URL is required for gateway smoke test.");
}
const migrationPool = new Pool({ connectionString: config.DATABASE_URL });
await runMigrations(migrationPool);
await migrationPool.end();
const app = await buildApp(config);
try {
const suffix = Date.now().toString(36);
const projectId = `contract-project-${suffix}`;
const workspaceSlug = `contract-workspace-${suffix}`;
const createAgentResponse = await app.inject({
method: "POST",
url: "/api/v1/agents",
payload: {
owner_user_id: `smoke-owner-${suffix}`,
owner_email: `smoke-${suffix}@example.test`,
display_name: `Smoke Codex ${suffix}`,
},
});
assertStatus(createAgentResponse.statusCode, 201, createAgentResponse.body);
const createAgentPayload = JSON.parse(createAgentResponse.body);
const agentId = createAgentPayload.agent.id as string;
const readGrantResponse = await app.inject({
method: "POST",
url: `/api/v1/agents/${agentId}/grants`,
payload: {
workspace_slug: workspaceSlug,
project_id: projectId,
scopes: ["workspace:read", "project:read", "issue:read"],
mode: "voluntary",
created_by_user_id: "smoke-admin",
},
});
assertStatus(readGrantResponse.statusCode, 201, readGrantResponse.body);
const createTokenResponse = await app.inject({
method: "POST",
url: `/api/v1/agents/${agentId}/tokens`,
payload: {
name: "Gateway smoke token",
},
});
assertStatus(createTokenResponse.statusCode, 201, createTokenResponse.body);
const createTokenPayload = JSON.parse(createTokenResponse.body);
const token = createTokenPayload.token as string;
const sessionResponse = await app.inject({
method: "GET",
url: "/api/v1/agent-session",
headers: {
Authorization: `Bearer ${token}`,
},
});
assertStatus(sessionResponse.statusCode, 200, sessionResponse.body);
const deniedCreateResponse = await app.inject({
method: "POST",
url: "/api/v1/tools/issues",
headers: {
Authorization: `Bearer ${token}`,
},
payload: {
project_id: projectId,
workspace_slug: workspaceSlug,
title: "Should be denied before Tasker call",
},
});
assertStatus(deniedCreateResponse.statusCode, 403, deniedCreateResponse.body);
const writeGrantResponse = await app.inject({
method: "POST",
url: `/api/v1/agents/${agentId}/grants`,
payload: {
workspace_slug: workspaceSlug,
project_id: projectId,
scopes: ["workspace:read", "project:read", "issue:read", "issue:create"],
mode: "voluntary",
created_by_user_id: "smoke-admin",
},
});
assertStatus(writeGrantResponse.statusCode, 201, writeGrantResponse.body);
const allowedButNoAdapterResponse = await app.inject({
method: "POST",
url: "/api/v1/tools/issues",
headers: {
Authorization: `Bearer ${token}`,
},
payload: {
project_id: projectId,
workspace_slug: workspaceSlug,
title: "Allowed by Gateway, waiting for Tasker adapter",
},
});
assertStatus(allowedButNoAdapterResponse.statusCode, 503, allowedButNoAdapterResponse.body);
console.log(
JSON.stringify(
{
ok: true,
agent_id: agentId,
token_prefix: token.split("_")[0],
checks: {
session_auth: "passed",
denied_without_scope: "passed",
allowed_request_reaches_tasker_boundary: "passed",
},
},
null,
2
)
);
} finally {
await app.close();
}
function assertStatus(actual: number, expected: number, body: string): void {
if (actual !== expected) {
throw new Error(`Expected HTTP ${expected}, received HTTP ${actual}: ${body}`);
}
}

View File

@ -0,0 +1,39 @@
import type { AgentScope } from "../domain/scopes.js";
import type { AgentGrantRecord, AgentSessionRecord } from "../repositories/agents.js";
export class ForbiddenError extends Error {
constructor(message = "Agent is not allowed to perform this operation.") {
super(message);
this.name = "ForbiddenError";
}
}
export function requireScope(session: AgentSessionRecord, scope: AgentScope): void {
const hasScope = session.grants.some((grant) => grant.scopes.includes(scope));
if (!hasScope) {
throw new ForbiddenError(`Missing required scope: ${scope}.`);
}
}
export function requireProjectGrant(
session: AgentSessionRecord,
input: {
projectId: string;
workspaceSlug?: string | null;
}
): AgentGrantRecord {
const grant = session.grants.find((candidate) => {
if (candidate.projectId === input.projectId) {
return true;
}
return Boolean(input.workspaceSlug && candidate.projectId === null && candidate.workspaceSlug === input.workspaceSlug);
});
if (!grant) {
throw new ForbiddenError("Agent is not granted to this project.");
}
return grant;
}

249
src/tasker/client.ts Normal file
View File

@ -0,0 +1,249 @@
import type { AgentSessionRecord } from "../repositories/agents.js";
export class TaskerAdapterNotConfiguredError extends Error {
constructor() {
super("NODEDC_INTERNAL_ACCESS_TOKEN is required for Tasker adapter calls.");
this.name = "TaskerAdapterNotConfiguredError";
}
}
export class TaskerAdapterError extends Error {
constructor(
message: string,
readonly statusCode: number,
readonly payload: unknown
) {
super(message);
this.name = "TaskerAdapterError";
}
}
export class TaskerAdapterUnavailableError extends Error {
constructor(readonly causeError: unknown) {
super("Tasker internal adapter is unavailable.");
this.name = "TaskerAdapterUnavailableError";
}
}
export type TaskerClientConfig = {
baseUrl: string;
internalAccessToken?: string;
};
export type TaskerAgentContext = {
agentId: string;
ownerUserId: string;
tokenId: string;
};
export type GrantedProjectInput = {
workspace_slug: string;
project_id: string | null;
mode: string;
scopes: string[];
};
export type ListGrantedProjectsInput = {
grants: GrantedProjectInput[];
};
export type CreateIssueInput = {
project_id: string;
workspace_slug?: string | null;
title: string;
description?: string;
priority?: "none" | "low" | "medium" | "high" | "urgent";
structured_blocks?: unknown[];
};
export type UpdateIssueInput = {
project_id: string;
workspace_slug?: string | null;
title?: string;
description?: string;
priority?: "none" | "low" | "medium" | "high" | "urgent";
structured_blocks?: unknown[];
};
export type MoveIssueInput = {
project_id: string;
workspace_slug?: string | null;
state_id: string;
};
export type CommentInput = {
project_id: string;
workspace_slug?: string | null;
body: string;
};
export type SetLabelsInput = {
project_id: string;
workspace_slug?: string | null;
label_ids: string[];
};
export type AssignIssueInput = {
project_id: string;
workspace_slug?: string | null;
member_ids: string[];
};
export class TaskerClient {
constructor(private readonly config: TaskerClientConfig) {}
async listGrantedProjects(session: AgentSessionRecord): Promise<unknown> {
return this.request("/api/internal/nodedc/agent/projects/resolve", {
method: "POST",
session,
body: {
grants: session.grants.map((grant) => ({
workspace_slug: grant.workspaceSlug,
project_id: grant.projectId,
mode: grant.mode,
scopes: grant.scopes,
})),
} satisfies ListGrantedProjectsInput,
});
}
async getProjectContext(session: AgentSessionRecord, projectId: string, workspaceSlug?: string | null): Promise<unknown> {
const searchParams = new URLSearchParams();
if (workspaceSlug) {
searchParams.set("workspace_slug", workspaceSlug);
}
return this.request(`/api/internal/nodedc/agent/projects/${encodeURIComponent(projectId)}/context?${searchParams.toString()}`, {
method: "GET",
session,
});
}
async listIssues(session: AgentSessionRecord, projectId: string, workspaceSlug?: string | null, query?: string): Promise<unknown> {
const searchParams = new URLSearchParams({ project_id: projectId });
if (workspaceSlug) {
searchParams.set("workspace_slug", workspaceSlug);
}
if (query) {
searchParams.set("query", query);
}
return this.request(`/api/internal/nodedc/agent/issues?${searchParams.toString()}`, {
method: "GET",
session,
});
}
async createIssue(session: AgentSessionRecord, input: CreateIssueInput): Promise<unknown> {
return this.request("/api/internal/nodedc/agent/issues", {
method: "POST",
session,
body: input,
});
}
async updateIssue(session: AgentSessionRecord, issueId: string, input: UpdateIssueInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}`, {
method: "PATCH",
session,
body: input,
});
}
async moveIssue(session: AgentSessionRecord, issueId: string, input: MoveIssueInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/move`, {
method: "POST",
session,
body: input,
});
}
async appendComment(session: AgentSessionRecord, issueId: string, input: CommentInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/comments`, {
method: "POST",
session,
body: input,
});
}
async setLabels(session: AgentSessionRecord, issueId: string, input: SetLabelsInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/labels`, {
method: "PUT",
session,
body: input,
});
}
async assignIssue(session: AgentSessionRecord, issueId: string, input: AssignIssueInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/assignees`, {
method: "PUT",
session,
body: input,
});
}
private async request(
path: string,
input: {
method: "GET" | "POST" | "PATCH" | "PUT";
session: AgentSessionRecord;
body?: unknown;
}
): Promise<unknown> {
if (!this.config.internalAccessToken) {
throw new TaskerAdapterNotConfiguredError();
}
const response = await this.fetchTasker(path, input);
const payload = await readResponsePayload(response);
if (!response.ok) {
throw new TaskerAdapterError("Tasker internal adapter request failed.", response.status, payload);
}
return payload;
}
private async fetchTasker(
path: string,
input: {
method: "GET" | "POST" | "PATCH" | "PUT";
session: AgentSessionRecord;
body?: unknown;
}
): Promise<Response> {
try {
return await fetch(new URL(path, this.config.baseUrl), {
method: input.method,
headers: {
Authorization: `Bearer ${this.config.internalAccessToken}`,
"Content-Type": "application/json",
"X-NODEDC-Agent-Id": input.session.agent.id,
"X-NODEDC-Agent-Owner-User-Id": input.session.agent.ownerUserId,
"X-NODEDC-Agent-Token-Id": input.session.token.id,
},
body: input.body === undefined ? undefined : JSON.stringify(input.body),
});
} catch (error) {
throw new TaskerAdapterUnavailableError(error);
}
}
}
async function readResponsePayload(response: Response): Promise<unknown> {
const text = await response.text();
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch {
return text;
}
}