SEC - CODEX AGENTS: idempotent audited tool writes

This commit is contained in:
DCCONSTRUCTIONS 2026-05-14 20:12:29 +03:00
parent c9519b52d2
commit 2c1e83dd37
14 changed files with 503 additions and 75 deletions

View File

@ -28,6 +28,8 @@ All writes go through NODE.DC Agent Gateway, are scoped by agent grants, and are
- 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 JSON-RPC endpoint `/mcp` exposes the same tool runtime as REST product endpoints.
- Write tools require idempotency keys and replay successful duplicate requests without creating duplicate Tasker writes.
- Agent Gateway writes audit events for executed, replayed, and failed write-tool calls.
- Tool execution calls the real Tasker internal adapter; no fake Tasker storage exists in Gateway.
- Local real e2e smoke verifies Gateway -> MCP -> Tasker runtime writes.
@ -90,10 +92,10 @@ No fake Tasker storage is embedded into Agent Gateway.
Local verification is split into product layers:
1. `npm run smoke:mcp` verifies MCP initialize, tool listing, bearer token auth, scope checks, grant checks, and the Tasker boundary.
2. `npm run smoke:gateway` verifies the REST compatibility boundary over the same tool execution path.
3. `npm run smoke:e2e` verifies REST tool endpoints against the real local Tasker runtime.
4. `npm run smoke:mcp:e2e` verifies MCP tool calls against the real local Tasker runtime.
1. `npm run smoke:mcp` verifies MCP initialize, tool listing, bearer token auth, scope checks, grant checks, idempotency requirement, and the Tasker boundary.
2. `npm run smoke:gateway` verifies the REST compatibility boundary and idempotency requirement over the same tool execution path.
3. `npm run smoke:e2e` verifies REST tool endpoints and idempotent replay against the real local Tasker runtime.
4. `npm run smoke:mcp:e2e` verifies MCP tool calls and idempotent replay against the real local Tasker runtime.
5. External-machine testing uses the same token and endpoint shape against staging HTTPS; no extra protocol or fake environment should be introduced.
Example real localhost MCP e2e:

View File

@ -111,7 +111,7 @@ Acceptance:
- local Codex can move card state;
- local Codex cannot delete/archive.
Status: initial product slice implemented. `/mcp` supports JSON-RPC `initialize`, `ping`, `tools/list`, and `tools/call`. REST product endpoints and MCP tools share the same runtime, scope checks, grant checks, and Tasker adapter calls. `npm run smoke:mcp:e2e` verifies real local Tasker writes.
Status: initial product slice implemented. `/mcp` supports JSON-RPC `initialize`, `ping`, `tools/list`, and `tools/call`. REST product endpoints and MCP tools share the same runtime, scope checks, grant checks, idempotency handling, audit events, and Tasker adapter calls. `npm run smoke:mcp:e2e` verifies real local Tasker writes and idempotent replay.
## Phase 6. Agent identity

View File

@ -308,7 +308,15 @@ All write tools must accept:
idempotency_key
```
Agent Gateway stores result mapping and returns the prior result for duplicate keys.
Agent Gateway requires this value for all write tools. The key is scoped by agent, hashed together with the tool name and normalized arguments, and stored with a processing/completed state.
Behavior:
- first successful request stores the tool result for 24 hours;
- duplicate key with identical arguments returns the stored result and does not call Tasker again;
- duplicate key with different arguments returns `idempotency_key_conflict`;
- duplicate key while the first request is still processing returns `idempotency_key_in_progress`;
- failed writes release the key so the same operation can be retried.
## Denied tools

View File

@ -92,7 +92,9 @@ Risk: network retry creates duplicate cards/comments.
Mitigation:
- required idempotency keys for write tools;
- store operation result by token and idempotency key.
- store operation result by agent and idempotency key;
- reject same key with different arguments;
- release failed writes so safe retries can run again.
### Reporting mode false confidence

View File

@ -0,0 +1,12 @@
ALTER TABLE idempotency_keys
ALTER COLUMN response_body DROP NOT NULL;
ALTER TABLE idempotency_keys
ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'completed'
CHECK (status IN ('processing', 'completed'));
ALTER TABLE idempotency_keys
ADD COLUMN IF NOT EXISTS locked_until timestamptz;
CREATE INDEX IF NOT EXISTS idempotency_keys_status_idx ON idempotency_keys(status);
CREATE INDEX IF NOT EXISTS idempotency_keys_locked_until_idx ON idempotency_keys(locked_until);

View File

@ -3,6 +3,7 @@ import { ZodError } from "zod";
import type { AppConfig } from "./config.js";
import { createPool, DatabaseNotConfiguredError } from "./db/pool.js";
import { ToolExecutionInputError } from "./mcp/tool-runtime.js";
import { AgentsRepository } from "./repositories/agents.js";
import { registerAgentRoutes } from "./routes/agents.js";
import { registerHealthRoutes } from "./routes/health.js";
@ -66,6 +67,16 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
return;
}
if (error instanceof ToolExecutionInputError) {
void reply.status(error.httpStatus).send({
ok: false,
error: error.code,
message: error.message,
details: error.details,
});
return;
}
if (error instanceof TaskerAdapterNotConfiguredError) {
void reply.status(503).send({
ok: false,

View File

@ -1,8 +1,10 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import type { AgentScope } from "../domain/scopes.js";
import { structuredBlocksSchema } from "../domain/structured-blocks.js";
import type { AgentSessionRecord } from "../repositories/agents.js";
import type { AgentsRepository, AgentSessionRecord } from "../repositories/agents.js";
import { ForbiddenError, requireProjectGrant, requireScope } from "../security/authorization.js";
import type { TaskerClient } from "../tasker/client.js";
@ -27,9 +29,15 @@ export type McpToolResult = {
};
type ExecuteToolDeps = {
agentsRepository?: AgentsRepository | null;
taskerClient: TaskerClient;
};
type ExecuteToolOptions = {
source?: "mcp" | "rest";
idempotencyKey?: string | null;
};
const emptyInputSchema = {
type: "object",
additionalProperties: false,
@ -151,6 +159,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
description: { type: "string" },
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
structured_blocks: structuredBlocksJsonSchema,
idempotency_key: { type: "string" },
},
required: ["project_id", "title"],
additionalProperties: false,
@ -172,6 +181,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
description: { type: "string" },
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
structured_blocks: structuredBlocksJsonSchema,
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id"],
additionalProperties: false,
@ -188,6 +198,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
properties: {
...projectAndIssueInputSchema.properties,
structured_blocks: structuredBlocksJsonSchema,
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "structured_blocks"],
},
@ -203,6 +214,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
properties: {
...projectAndIssueInputSchema.properties,
state_id: { type: "string" },
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "state_id"],
},
@ -218,6 +230,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
properties: {
...projectAndIssueInputSchema.properties,
body: { type: "string" },
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "body"],
},
@ -233,6 +246,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
properties: {
...projectAndIssueInputSchema.properties,
label_ids: { type: "array", items: { type: "string" } },
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "label_ids"],
},
@ -248,6 +262,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
properties: {
...projectAndIssueInputSchema.properties,
member_ids: { type: "array", items: { type: "string" } },
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "member_ids"],
},
@ -271,6 +286,7 @@ const createIssueArgsSchema = z.object({
description: z.string().max(20000).optional(),
priority: prioritySchema.optional(),
structured_blocks: structuredBlocksSchema.optional(),
idempotency_key: z.string().optional(),
});
const issueArgsSchema = z.object({
issue_id: z.string().min(1),
@ -307,9 +323,81 @@ export async function executeMcpTool(
session: AgentSessionRecord,
name: string,
rawArguments: unknown,
deps: ExecuteToolDeps,
options: ExecuteToolOptions = {}
): Promise<McpToolResult> {
const tool = mcpRuntimeTools.find((candidate) => candidate.name === name);
if (!tool) {
throw new Error(`Unknown MCP tool: ${name}`);
}
const { args, idempotencyKey } = prepareToolArguments(rawArguments, options.idempotencyKey);
const isWriteTool = tool.annotations?.readOnlyHint !== true;
if (!isWriteTool) {
return executeMcpToolOnce(session, name, args, deps);
}
if (!deps.agentsRepository) {
throw new ToolExecutionInputError("idempotency_unavailable", "Agent Gateway persistence is required for write tools.", 503);
}
if (!idempotencyKey) {
throw new ToolExecutionInputError("idempotency_key_required", "Write tools require an idempotency key.", 400);
}
const requestHash = hashToolRequest(name, args);
const claim = await deps.agentsRepository.claimIdempotencyKey(session.agent.id, idempotencyKey, requestHash);
if (claim.status === "replay") {
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.replayed", session.agent.ownerUserId, {
source: options.source ?? "mcp",
toolName: name,
idempotencyKey,
});
return claim.responseBody as McpToolResult;
}
if (claim.status === "conflict") {
throw new ToolExecutionInputError("idempotency_key_conflict", "Idempotency key was already used with different arguments.", 409);
}
if (claim.status === "in_progress") {
throw new ToolExecutionInputError("idempotency_key_in_progress", "Idempotency key is currently processing.", 409, {
lockedUntil: claim.lockedUntil,
});
}
try {
const result = await executeMcpToolOnce(session, name, args, deps);
await deps.agentsRepository.completeIdempotencyKey(session.agent.id, idempotencyKey, result);
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.executed", session.agent.ownerUserId, {
source: options.source ?? "mcp",
toolName: name,
idempotencyKey,
arguments: summarizeToolArguments(args),
});
return result;
} catch (error) {
await deps.agentsRepository.releaseIdempotencyKey(session.agent.id, idempotencyKey);
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.failed", session.agent.ownerUserId, {
source: options.source ?? "mcp",
toolName: name,
idempotencyKey,
error: error instanceof Error ? error.name : "unknown_error",
message: error instanceof Error ? error.message : "Unknown tool execution error.",
});
throw error;
}
}
async function executeMcpToolOnce(
session: AgentSessionRecord,
name: string,
args: unknown,
deps: ExecuteToolDeps
): Promise<McpToolResult> {
const args = rawArguments ?? {};
switch (name) {
case "tasker_get_agent_instructions":
@ -421,6 +509,85 @@ function asToolResult(payload: unknown): McpToolResult {
};
}
export class ToolExecutionInputError extends Error {
constructor(
readonly code: string,
message: string,
readonly httpStatus: number,
readonly details?: Record<string, unknown>
) {
super(message);
this.name = "ToolExecutionInputError";
}
}
function prepareToolArguments(rawArguments: unknown, headerIdempotencyKey?: string | null): { args: unknown; idempotencyKey: string | null } {
if (!isPlainRecord(rawArguments)) {
return {
args: rawArguments ?? {},
idempotencyKey: normalizeIdempotencyKey(headerIdempotencyKey),
};
}
const { idempotency_key: bodyIdempotencyKey, ...args } = rawArguments;
return {
args,
idempotencyKey: normalizeIdempotencyKey(headerIdempotencyKey ?? (typeof bodyIdempotencyKey === "string" ? bodyIdempotencyKey : null)),
};
}
function normalizeIdempotencyKey(value?: string | null): string | null {
const normalized = value?.trim();
if (!normalized) {
return null;
}
if (normalized.length > 200) {
throw new ToolExecutionInputError("idempotency_key_invalid", "Idempotency key must be 200 characters or fewer.", 400);
}
return normalized;
}
function hashToolRequest(name: string, args: unknown): string {
return createHash("sha256").update(stableStringify({ name, args })).digest("hex");
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
}
if (isPlainRecord(value)) {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
function summarizeToolArguments(args: unknown): Record<string, unknown> {
if (!isPlainRecord(args)) {
return {};
}
return {
workspace_slug: args.workspace_slug,
project_id: args.project_id,
issue_id: args.issue_id,
state_id: args.state_id,
has_structured_blocks: Array.isArray(args.structured_blocks),
};
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function buildAgentInstructions(session: AgentSessionRecord): Record<string, unknown> {
return {
agent: {

View File

@ -92,6 +92,17 @@ type SessionRow = AgentRow & {
token_created_at: Date;
};
type IdempotencyRow = {
key: string;
agent_id: string;
request_hash: string;
response_body: unknown | null;
status: "processing" | "completed";
created_at: Date;
expires_at: Date;
locked_until: Date | null;
};
export type CreateAgentInput = {
ownerUserId: string;
ownerEmail?: string | null;
@ -113,6 +124,22 @@ export type CreateTokenInput = {
expiresAt?: string | null;
};
export type IdempotencyClaim =
| {
status: "claimed";
}
| {
status: "replay";
responseBody: unknown;
}
| {
status: "in_progress";
lockedUntil: string | null;
}
| {
status: "conflict";
};
export class AgentsRepository {
constructor(private readonly pool: Pool) {}
@ -322,6 +349,101 @@ export class AgentsRepository {
[agentId, eventType, actorUserId ?? null, metadata]
);
}
async claimIdempotencyKey(agentId: string, idempotencyKey: string, requestHash: string): Promise<IdempotencyClaim> {
const storageKey = buildIdempotencyStorageKey(agentId, idempotencyKey);
const client = await this.pool.connect();
try {
await client.query("BEGIN");
await client.query("DELETE FROM idempotency_keys WHERE key = $1 AND expires_at <= now()", [storageKey]);
const inserted = await client.query<IdempotencyRow>(
`
INSERT INTO idempotency_keys(key, agent_id, request_hash, response_body, status, expires_at, locked_until)
VALUES ($1, $2, $3, NULL, 'processing', now() + interval '24 hours', now() + interval '5 minutes')
ON CONFLICT (key) DO NOTHING
RETURNING *
`,
[storageKey, agentId, requestHash]
);
if (inserted.rows[0]) {
await client.query("COMMIT");
return { status: "claimed" };
}
const existing = await client.query<IdempotencyRow>("SELECT * FROM idempotency_keys WHERE key = $1 AND agent_id = $2 FOR UPDATE", [
storageKey,
agentId,
]);
const row = existing.rows[0];
if (!row) {
await client.query("COMMIT");
return { status: "conflict" };
}
if (row.request_hash !== requestHash) {
await client.query("COMMIT");
return { status: "conflict" };
}
if (row.status === "completed") {
await client.query("COMMIT");
return {
status: "replay",
responseBody: row.response_body,
};
}
if (row.locked_until && row.locked_until > new Date()) {
await client.query("COMMIT");
return {
status: "in_progress",
lockedUntil: row.locked_until.toISOString(),
};
}
await client.query(
`
UPDATE idempotency_keys
SET locked_until = now() + interval '5 minutes'
WHERE key = $1 AND agent_id = $2
`,
[storageKey, agentId]
);
await client.query("COMMIT");
return { status: "claimed" };
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
async completeIdempotencyKey(agentId: string, idempotencyKey: string, responseBody: unknown): Promise<void> {
await this.pool.query(
`
UPDATE idempotency_keys
SET response_body = $3, status = 'completed', locked_until = NULL
WHERE key = $1 AND agent_id = $2
`,
[buildIdempotencyStorageKey(agentId, idempotencyKey), agentId, responseBody]
);
}
async releaseIdempotencyKey(agentId: string, idempotencyKey: string): Promise<void> {
await this.pool.query("DELETE FROM idempotency_keys WHERE key = $1 AND agent_id = $2 AND status = 'processing'", [
buildIdempotencyStorageKey(agentId, idempotencyKey),
agentId,
]);
}
}
function buildIdempotencyStorageKey(agentId: string, idempotencyKey: string): string {
return `${agentId}:${idempotencyKey}`;
}
function assertAllowedScopes(scopes: AgentScope[]): void {

View File

@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import type { FastifyInstance, FastifyReply } from "fastify";
import { ZodError, z } from "zod";
import { executeMcpTool, getToolsForSession } from "../mcp/tool-runtime.js";
import { ToolExecutionInputError, executeMcpTool, getToolsForSession } from "../mcp/tool-runtime.js";
import type { AgentsRepository } from "../repositories/agents.js";
import { ForbiddenError } from "../security/authorization.js";
import { UnauthorizedError } from "../security/bearer.js";
@ -113,7 +113,10 @@ export async function registerMcpRoutes(app: FastifyInstance, deps: McpRouteDeps
case "tools/call": {
const session = await authenticateAgent(request, deps);
const params = toolsCallParamsSchema.parse(message.params);
const result = await executeMcpTool(session, params.name, params.arguments, deps);
const result = await executeMcpTool(session, params.name, params.arguments, deps, {
source: "mcp",
idempotencyKey: readHeader(request.headers["idempotency-key"]),
});
return sendJsonRpc(reply, {
jsonrpc: "2.0",
id,
@ -147,6 +150,10 @@ function mapError(id: JsonRpcId, error: unknown): JsonRpcResponse {
return makeToolExecutionError(id, "forbidden", error.message);
}
if (error instanceof ToolExecutionInputError) {
return makeToolExecutionError(id, error.code, error.message, error.details);
}
if (error instanceof Error && error.message.startsWith("Unknown MCP tool:")) {
return makeError(id, -32602, error.message);
}
@ -170,11 +177,12 @@ function makeError(id: JsonRpcId, code: number, message: string, data?: unknown)
};
}
function makeToolExecutionError(id: JsonRpcId, code: string, message: string): JsonRpcResponse {
function makeToolExecutionError(id: JsonRpcId, code: string, message: string, details?: Record<string, unknown>): JsonRpcResponse {
const payload = {
ok: false,
error: code,
message,
details,
};
return {
@ -192,3 +200,11 @@ function makeToolExecutionError(id: JsonRpcId, code: string, message: string): J
},
};
}
function readHeader(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null;
}
return value ?? null;
}

View File

@ -13,7 +13,7 @@ type ToolRouteDeps = {
export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDeps): Promise<void> {
app.get("/api/v1/tools/projects", async (request) => {
const session = await authenticateAgent(request, deps);
const result = await executeMcpTool(session, "tasker_list_projects", {}, deps);
const result = await executeMcpTool(session, "tasker_list_projects", {}, deps, toolOptions(request));
return result.structuredContent;
});
@ -28,7 +28,8 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
project_id: params.projectId,
workspace_slug: query.workspace_slug,
},
deps
deps,
toolOptions(request)
);
return result.structuredContent;
});
@ -36,13 +37,13 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
app.get("/api/v1/tools/issues", async (request) => {
const session = await authenticateAgent(request, deps);
const query = request.query as { project_id?: string; workspace_slug?: string; query?: string };
const result = await executeMcpTool(session, "tasker_search_issues", query, deps);
const result = await executeMcpTool(session, "tasker_search_issues", query, deps, toolOptions(request));
return result.structuredContent;
});
app.post("/api/v1/tools/issues", async (request) => {
const session = await authenticateAgent(request, deps);
const result = await executeMcpTool(session, "tasker_create_issue", request.body, deps);
const result = await executeMcpTool(session, "tasker_create_issue", request.body, deps, toolOptions(request));
return result.structuredContent;
});
@ -53,10 +54,11 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
session,
"tasker_update_issue",
{
...(request.body as Record<string, unknown>),
...requestBodyRecord(request.body),
issue_id: params.issueId,
},
deps
deps,
toolOptions(request)
);
return result.structuredContent;
});
@ -68,10 +70,11 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
session,
"tasker_move_issue",
{
...(request.body as Record<string, unknown>),
...requestBodyRecord(request.body),
issue_id: params.issueId,
},
deps
deps,
toolOptions(request)
);
return result.structuredContent;
});
@ -83,10 +86,11 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
session,
"tasker_append_comment",
{
...(request.body as Record<string, unknown>),
...requestBodyRecord(request.body),
issue_id: params.issueId,
},
deps
deps,
toolOptions(request)
);
return result.structuredContent;
});
@ -98,10 +102,11 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
session,
"tasker_set_issue_labels",
{
...(request.body as Record<string, unknown>),
...requestBodyRecord(request.body),
issue_id: params.issueId,
},
deps
deps,
toolOptions(request)
);
return result.structuredContent;
});
@ -113,11 +118,35 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
session,
"tasker_assign_issue",
{
...(request.body as Record<string, unknown>),
...requestBodyRecord(request.body),
issue_id: params.issueId,
},
deps
deps,
toolOptions(request)
);
return result.structuredContent;
});
}
function toolOptions(request: { headers: Record<string, string | string[] | undefined> }): { source: "rest"; idempotencyKey: string | null } {
return {
source: "rest",
idempotencyKey: readHeader(request.headers["idempotency-key"]),
};
}
function requestBodyRecord(body: unknown): Record<string, unknown> {
if (typeof body === "object" && body !== null && !Array.isArray(body)) {
return body as Record<string, unknown>;
}
return {};
}
function readHeader(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null;
}
return value ?? null;
}

View File

@ -38,7 +38,7 @@ try {
`/api/v1/tools/projects/${projectId}/context?workspace_slug=${encodeURIComponent(workspaceSlug)}`,
authHeaders
);
const issue = await requestJson("POST", "/api/v1/tools/issues", authHeaders, {
const createIssuePayload = {
project_id: projectId,
workspace_slug: workspaceSlug,
title: `NODE.DC Codex API smoke ${suffix}`,
@ -69,10 +69,15 @@ try {
],
},
],
});
};
const createHeaders = { ...authHeaders, "Idempotency-Key": `rest-create-${suffix}` };
const issue = await requestJson("POST", "/api/v1/tools/issues", createHeaders, createIssuePayload);
const replayedIssue = await requestJson("POST", "/api/v1/tools/issues", createHeaders, createIssuePayload);
const issueId = issue.issue.id as string;
await requestJson("POST", `/api/v1/tools/issues/${issueId}/comments`, authHeaders, {
assert(replayedIssue.issue.id === issueId, "idempotent REST create returns the original issue");
await requestJson("POST", `/api/v1/tools/issues/${issueId}/comments`, { ...authHeaders, "Idempotency-Key": `rest-comment-${suffix}` }, {
project_id: projectId,
workspace_slug: workspaceSlug,
body: "Smoke comment from Agent Gateway.",
@ -81,11 +86,16 @@ try {
const states = Array.isArray(context.states) ? context.states : [];
const targetState = states.find((state) => typeof state?.id === "string");
if (targetState) {
await requestJson("POST", `/api/v1/tools/issues/${issueId}/move`, authHeaders, {
project_id: projectId,
workspace_slug: workspaceSlug,
state_id: targetState.id,
});
await requestJson(
"POST",
`/api/v1/tools/issues/${issueId}/move`,
{ ...authHeaders, "Idempotency-Key": `rest-move-${suffix}` },
{
project_id: projectId,
workspace_slug: workspaceSlug,
state_id: targetState.id,
}
);
}
console.log(
@ -97,6 +107,7 @@ try {
project_id: projectId,
visible_projects: Array.isArray(projects.projects) ? projects.projects.length : null,
issue_id: issueId,
idempotent_replay: "passed",
moved: Boolean(targetState),
},
null,
@ -180,3 +191,9 @@ function readRequiredEnv(key: string): string {
}
return value;
}
function assert(condition: unknown, message: string): void {
if (!condition) {
throw new Error(`E2E smoke assertion failed: ${message}`);
}
}

View File

@ -76,6 +76,7 @@ try {
url: "/api/v1/tools/issues",
headers: {
Authorization: `Bearer ${token}`,
"Idempotency-Key": `gateway-denied-create-${suffix}`,
},
payload: {
project_id: projectId,
@ -85,6 +86,20 @@ try {
});
assertStatus(deniedCreateResponse.statusCode, 403, deniedCreateResponse.body);
const missingIdempotencyResponse = 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 write because idempotency key is missing",
},
});
assertStatus(missingIdempotencyResponse.statusCode, 400, missingIdempotencyResponse.body);
const writeGrantResponse = await app.inject({
method: "POST",
url: `/api/v1/agents/${agentId}/grants`,
@ -103,6 +118,7 @@ try {
url: "/api/v1/tools/issues",
headers: {
Authorization: `Bearer ${token}`,
"Idempotency-Key": `gateway-allowed-no-adapter-${suffix}`,
},
payload: {
project_id: projectId,
@ -121,6 +137,7 @@ try {
checks: {
session_auth: "passed",
denied_without_scope: "passed",
idempotency_required: "passed",
allowed_request_reaches_tasker_boundary: "passed",
},
},

View File

@ -59,53 +59,54 @@ try {
},
headers
);
const createIssue = await callTool(
5,
"tasker_create_issue",
{
project_id: projectId,
workspace_slug: workspaceSlug,
title: `NODE.DC MCP Codex smoke ${suffix}`,
description: "Created through MCP Streamable HTTP JSON-RPC.",
priority: "medium",
structured_blocks: [
{
id: "mcp-current-architecture",
type: "text",
title: "Текущая архитектура",
body: "MCP client called Agent Gateway; Gateway routed into real Tasker internal adapter.",
},
{
id: "mcp-checker",
type: "checker",
title: "Чекер MCP smoke",
items: [
{
id: "mcp",
text: "MCP tools/call accepted",
checked: true,
},
{
id: "tasker",
text: "Real Tasker issue created",
checked: true,
},
],
},
],
},
headers
);
const createIssueArguments = {
project_id: projectId,
workspace_slug: workspaceSlug,
title: `NODE.DC MCP Codex smoke ${suffix}`,
description: "Created through MCP Streamable HTTP JSON-RPC.",
priority: "medium",
idempotency_key: `mcp-create-${suffix}`,
structured_blocks: [
{
id: "mcp-current-architecture",
type: "text",
title: "Текущая архитектура",
body: "MCP client called Agent Gateway; Gateway routed into real Tasker internal adapter.",
},
{
id: "mcp-checker",
type: "checker",
title: "Чекер MCP smoke",
items: [
{
id: "mcp",
text: "MCP tools/call accepted",
checked: true,
},
{
id: "tasker",
text: "Real Tasker issue created",
checked: true,
},
],
},
],
};
const createIssue = await callTool(5, "tasker_create_issue", createIssueArguments, headers);
const replayedCreateIssue = await callTool(6, "tasker_create_issue", createIssueArguments, headers);
const issueId = createIssue.structuredContent.issue.id as string;
assert(replayedCreateIssue.structuredContent.issue.id === issueId, "idempotent MCP create returns the original issue");
await callTool(
6,
7,
"tasker_append_comment",
{
issue_id: issueId,
project_id: projectId,
workspace_slug: workspaceSlug,
body: "MCP e2e smoke comment from Agent Gateway.",
idempotency_key: `mcp-comment-${suffix}`,
},
headers
);
@ -114,13 +115,14 @@ try {
const targetState = states.find((state: any) => typeof state?.id === "string");
if (targetState) {
await callTool(
7,
8,
"tasker_move_issue",
{
issue_id: issueId,
project_id: projectId,
workspace_slug: workspaceSlug,
state_id: targetState.id,
idempotency_key: `mcp-move-${suffix}`,
},
headers
);
@ -136,6 +138,7 @@ try {
project_id: projectId,
visible_projects: Array.isArray(projects.structuredContent.projects) ? projects.structuredContent.projects.length : null,
issue_id: issueId,
idempotent_replay: "passed",
moved: Boolean(targetState),
},
null,

View File

@ -69,12 +69,31 @@ try {
project_id: projectId,
workspace_slug: workspaceSlug,
title: "Should be denied by MCP smoke",
idempotency_key: `mcp-denied-create-${suffix}`,
},
},
headers
);
assert(deniedCreate.result?.isError === true, "create call returns MCP tool error without issue:create scope");
const missingIdempotencyKey = await mcpRequest(
5,
"tools/call",
{
name: "tasker_create_issue",
arguments: {
project_id: projectId,
workspace_slug: workspaceSlug,
title: "Should be denied before write because idempotency key is missing",
},
},
headers
);
assert(
missingIdempotencyKey.result?.structuredContent?.error === "idempotency_key_required",
"write tools require idempotency key"
);
await upsertGrant(agentId, workspaceSlug, projectId, ["workspace:read", "project:read", "issue:read", "issue:update"]);
await upsertGrant(agentId, workspaceSlug, `${projectId}-structured`, [
"workspace:read",
@ -82,7 +101,7 @@ try {
"issue:structured_blocks:write",
]);
const deniedSplitGrantStructuredUpdate = await mcpRequest(
5,
6,
"tools/call",
{
name: "tasker_update_structured_blocks",
@ -91,6 +110,7 @@ try {
project_id: projectId,
workspace_slug: workspaceSlug,
structured_blocks: [],
idempotency_key: `mcp-split-grant-${suffix}`,
},
},
headers
@ -102,7 +122,7 @@ try {
await upsertGrant(agentId, workspaceSlug, projectId, ["workspace:read", "project:read", "issue:read", "issue:create"]);
const allowedButNoAdapter = await mcpRequest(
6,
7,
"tools/call",
{
name: "tasker_create_issue",
@ -110,6 +130,7 @@ try {
project_id: projectId,
workspace_slug: workspaceSlug,
title: "Allowed by MCP, waiting for Tasker adapter token",
idempotency_key: `mcp-allowed-no-adapter-${suffix}`,
},
},
headers
@ -132,6 +153,7 @@ try {
tools_list: "passed",
instructions_call: "passed",
denied_without_scope: "passed",
idempotency_required: "passed",
denied_split_grant_structured_blocks: "passed",
allowed_request_reaches_tasker_boundary: "passed",
},