API - CODEX AGENTS: product tool boundary
This commit is contained in:
parent
14c5f490b1
commit
9f402074f2
24
README.md
24
README.md
|
|
@ -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.
|
- Internal REST endpoints for agent profile, grant, and token lifecycle.
|
||||||
- Opaque agent tokens are generated once and stored only as SHA-256 hashes.
|
- 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.
|
- 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.
|
- MCP and Tasker write execution are documented but not implemented yet.
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
@ -43,6 +44,7 @@ Useful checks:
|
||||||
```bash
|
```bash
|
||||||
npm run check
|
npm run check
|
||||||
npm run build
|
npm run build
|
||||||
|
npm run smoke:gateway
|
||||||
curl http://127.0.0.1:4100/readyz
|
curl http://127.0.0.1:4100/readyz
|
||||||
curl http://127.0.0.1:4100/api/v1/meta/capabilities
|
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.
|
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`
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ Exit criteria:
|
||||||
|
|
||||||
## Phase 1. Agent Gateway skeleton
|
## 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:
|
Create standalone service with:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"check": "tsc --noEmit -p tsconfig.json",
|
"check": "tsc --noEmit -p tsconfig.json",
|
||||||
"migrate": "tsx src/scripts/migrate.ts",
|
"migrate": "tsx src/scripts/migrate.ts",
|
||||||
"migrate:dist": "node dist/scripts/migrate.js",
|
"migrate:dist": "node dist/scripts/migrate.js",
|
||||||
|
"smoke:gateway": "tsx src/scripts/smoke-gateway.ts",
|
||||||
"start": "node dist/server.js"
|
"start": "node dist/server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
46
src/app.ts
46
src/app.ts
|
|
@ -6,11 +6,18 @@ import { createPool, DatabaseNotConfiguredError } from "./db/pool.js";
|
||||||
import { AgentsRepository } from "./repositories/agents.js";
|
import { AgentsRepository } from "./repositories/agents.js";
|
||||||
import { registerAgentRoutes } from "./routes/agents.js";
|
import { registerAgentRoutes } from "./routes/agents.js";
|
||||||
import { registerHealthRoutes } from "./routes/health.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 { UnauthorizedError } from "./security/bearer.js";
|
||||||
|
import { TaskerAdapterError, TaskerAdapterNotConfiguredError, TaskerAdapterUnavailableError, TaskerClient } from "./tasker/client.js";
|
||||||
|
|
||||||
export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||||
const pool = createPool(config);
|
const pool = createPool(config);
|
||||||
const agentsRepository = pool ? new AgentsRepository(pool) : null;
|
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({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
level: config.LOG_LEVEL,
|
level: config.LOG_LEVEL,
|
||||||
|
|
@ -49,6 +56,44 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||||
return;
|
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);
|
app.log.error(error);
|
||||||
void reply.status(500).send({
|
void reply.status(500).send({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|
@ -59,6 +104,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||||
|
|
||||||
await registerHealthRoutes(app, config, pool);
|
await registerHealthRoutes(app, config, pool);
|
||||||
await registerAgentRoutes(app, { agentsRepository });
|
await registerAgentRoutes(app, { agentsRepository });
|
||||||
|
await registerToolRoutes(app, { agentsRepository, taskerClient });
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue