diff --git a/Dockerfile b/Dockerfile index f070660..baa9d62 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1.7 -FROM eclipse-temurin:25-jdk@sha256:e787e08ef76f4c16866108cd7f9fcd96a68eef3ac6cc76866897d4d02d5a2262 AS build +FROM eclipse-temurin:25-jdk@sha256:119a3d18f160a3e7655a66034d0f43beee31cd7b3b9142d57a5de29772011de6 AS build WORKDIR /workspace COPY gradlew gradlew.bat settings.gradle.kts build.gradle.kts gradle.properties ./ COPY gradle ./gradle @@ -7,8 +7,14 @@ RUN ./gradlew dependencies --no-daemon COPY src ./src RUN ./gradlew bootJar --no-daemon -FROM eclipse-temurin:25-jre@sha256:f9e65324a37f28209ce7dd0e5149a7aa954520ed936fb87813cf6ded2400a112 -RUN rm -- /usr/bin/pebble \ +FROM eclipse-temurin:25-jre@sha256:8da0490fa9a3c26867012019565948eef0ee69438f5c75ac28146967bae984b5 +RUN apt-get update \ + && apt-get install --only-upgrade --yes --no-install-recommends \ + libssl3t64 \ + openssl \ + openssl-provider-legacy \ + && rm -rf /var/lib/apt/lists/* \ + && rm -- /usr/bin/pebble \ && useradd --system --uid 10001 --create-home app USER 10001 WORKDIR /app diff --git a/README.md b/README.md index 06ebcee..84b97ae 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ Сервис хранит действия Portable Agent и безопасно выполняет подтверждённые действия. Он принимает действие, сохраняет payload вместе с записью outbox и передаёт идентификатор в Temporal. Worker ждёт -решение пользователя и после `CONFIRM` вызывает MCP Gateway. В первом продуктовом срезе разрешено -только `calendar.create_event` через `fake-calendar`. +решение пользователя и после `CONFIRM` вызывает MCP Gateway. Сейчас разрешено действие +`calendar.create_event` через `fake-calendar` или `google-calendar`. ## Стек @@ -54,7 +54,7 @@ HTTP-интерфейс Action API и сетевые модели MCP Gateway с contracts: ```powershell -pwsh ./scripts/update-contract.ps1 -Version 2.0.0 +pwsh ./scripts/update-contract.ps1 -Version 3.0.0 ``` Миграция `V2` один раз удаляет тестовые записи старого pre-MVP-каркаса: в схеме `V1` payload не @@ -67,7 +67,7 @@ pwsh ./scripts/update-contract.ps1 -Version 2.0.0 `start`, `succeed` и `fail` также идемпотентны: повтор не меняет версию и время действия. Другой `eventId` после успеха отклоняется как конфликт. -Action API и outbound-вызов MCP Gateway соответствуют bundle `portable-agent/contracts` версии `2.6.0`. +Action API и outbound-вызов MCP Gateway соответствуют bundle `portable-agent/contracts` версии `3.0.0`. При выполнении `context.actorId` берётся из сохранённого Action и передаётся отдельно от аргументов MCP tool. Модель и пользовательский payload не могут подменить владельца подключения. diff --git a/build.gradle.kts b/build.gradle.kts index 76dfc6b..00488a3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,8 @@ group = "dev.portableagent" version = "0.1.0-SNAPSHOT" extra["tomcat.version"] = "11.0.25" +extra["jackson-2-bom.version"] = "2.21.6" +extra["jackson-bom.version"] = "3.1.6" java { toolchain { diff --git a/docs/api.md b/docs/api.md index a153d03..16e1a30 100644 --- a/docs/api.md +++ b/docs/api.md @@ -9,14 +9,14 @@ - `GET /api/v1/actions/{actionId}` — получить действие своего tenant; - `POST /api/v1/actions/{actionId}/decisions` — передать решение. -В первом продуктовом срезе создание принимает только `calendar.create_event` с коннектором -`fake-calendar`. Ответ содержит сохранённый `payload` и его `payloadHash`. Повтор с тем же `requestKey` -возвращает ранее созданное действие вместе с тем же payload. +Создание принимает `calendar.create_event` с коннектором `fake-calendar` или `google-calendar`. +Ответ содержит сохранённый `payload` и его `payloadHash`. Повтор с тем же `requestKey` возвращает ранее +созданное действие вместе с тем же payload. Точный повтор решения с тем же `payloadHash` возвращает текущее действие без новой записи в БД. Попытка изменить уже принятое решение или подменить hash возвращает конфликт. -Сервис использует контракт `portable-agent/contracts v2.6.0`. После успешного выполнения ответ содержит +Сервис использует контракт `portable-agent/contracts v3.0.0`. После успешного выполнения ответ содержит `result.eventId`. До состояния `SUCCEEDED` поле `result` отсутствует. Внутренний `POST /api/v1/calls` MCP Gateway также берётся из этого release. Его request/response DTO diff --git a/docs/architecture.md b/docs/architecture.md index 57a36f1..9fcc494 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,10 +35,12 @@ OpenAPI из одного release `portable-agent/contracts` хранится в ## Текущий продуктовый срез -`ActionService` разрешает только `calendar.create_event` через `fake-calendar`. Проверка находится в -service-слое до вычисления hash и сохранения нового действия. Повтор с существующим `requestKey` -по-прежнему возвращает ранее сохранённое действие. Конкурентные повторы защищены ограничением БД и -jOOQ `ON CONFLICT DO NOTHING`; запись outbox создаёт только запрос, который сохранил действие. +`ActionService` передаёт проверку в `ActionRules`. Реестр выбирает правило по `kind` через `Map`, поэтому +новое действие добавляется отдельной стратегией, а не новой веткой `if`. `CalendarActionRule` разрешает +`fake-calendar` и `google-calendar` и проверяет общий payload встречи до вычисления hash и сохранения. +Повтор с существующим `requestKey` по-прежнему возвращает ранее сохранённое действие. Конкурентные +повторы защищены ограничением БД и jOOQ `ON CONFLICT DO NOTHING`; запись outbox создаёт только запрос, +который сохранил действие. Точный payload хранится в PostgreSQL как `JSONB` и возвращается вместе с `payloadHash`. Поэтому виджет и будущий workflow используют данные сохранённого действия, а не новый ответ AI. diff --git a/src/main/java/dev/portableagent/action/service/ActionRule.java b/src/main/java/dev/portableagent/action/service/ActionRule.java new file mode 100644 index 0000000..11ba5af --- /dev/null +++ b/src/main/java/dev/portableagent/action/service/ActionRule.java @@ -0,0 +1,9 @@ +package dev.portableagent.action.service; + +import java.util.Map; + +public interface ActionRule { + String kind(); + + void check(String connector, Map payload); +} diff --git a/src/main/java/dev/portableagent/action/service/ActionRules.java b/src/main/java/dev/portableagent/action/service/ActionRules.java new file mode 100644 index 0000000..b61bd5d --- /dev/null +++ b/src/main/java/dev/portableagent/action/service/ActionRules.java @@ -0,0 +1,24 @@ +package dev.portableagent.action.service; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +@Component +public class ActionRules { + private final Map byKind; + + public ActionRules(List rules) { + this.byKind = rules.stream().collect(Collectors.toUnmodifiableMap(ActionRule::kind, Function.identity())); + } + + public void check(CreateActionCommand request) { + var rule = byKind.get(request.kind()); + if (rule == null) { + throw new IllegalArgumentException("Unsupported action kind: " + request.kind()); + } + rule.check(request.connector(), request.payload()); + } +} diff --git a/src/main/java/dev/portableagent/action/service/ActionService.java b/src/main/java/dev/portableagent/action/service/ActionService.java index a6c0a15..07f2dba 100644 --- a/src/main/java/dev/portableagent/action/service/ActionService.java +++ b/src/main/java/dev/portableagent/action/service/ActionService.java @@ -16,26 +16,24 @@ @Service public class ActionService { - private static final String CALENDAR_ACTION = "calendar.create_event"; - private static final String FAKE_CALENDAR = "fake-calendar"; private static final int CHANGE_TRIES = 3; private final ActionRepository actionRepository; private final OutboxRepository outboxRepository; private final PayloadHash payloadHash; - private final CalendarInputCheck calendarInputCheck; + private final ActionRules actionRules; private final Clock clock; public ActionService( ActionRepository actionRepository, OutboxRepository outboxRepository, PayloadHash payloadHash, - CalendarInputCheck calendarInputCheck, + ActionRules actionRules, Clock clock) { this.actionRepository = actionRepository; this.outboxRepository = outboxRepository; this.payloadHash = payloadHash; - this.calendarInputCheck = calendarInputCheck; + this.actionRules = actionRules; this.clock = clock; } @@ -46,8 +44,7 @@ public Action create(UUID tenantId, UUID userId, CreateActionCommand request) { return oldAction.get(); } - checkAllowed(request); - calendarInputCheck.check(request.payload()); + actionRules.check(request); var now = clock.instant(); var action = Action.create( @@ -68,15 +65,6 @@ public Action create(UUID tenantId, UUID userId, CreateActionCommand request) { return action; } - private void checkAllowed(CreateActionCommand request) { - if (!CALENDAR_ACTION.equals(request.kind())) { - throw new IllegalArgumentException("Only calendar.create_event is supported"); - } - if (!FAKE_CALENDAR.equals(request.connector())) { - throw new IllegalArgumentException("Only fake-calendar is supported"); - } - } - @Transactional(readOnly = true) public Action get(UUID tenantId, UUID actionId) { return actionRepository.findById(tenantId, actionId).orElseThrow(() -> new ActionNotFound(actionId)); diff --git a/src/main/java/dev/portableagent/action/service/CalendarActionRule.java b/src/main/java/dev/portableagent/action/service/CalendarActionRule.java new file mode 100644 index 0000000..d75bb9b --- /dev/null +++ b/src/main/java/dev/portableagent/action/service/CalendarActionRule.java @@ -0,0 +1,30 @@ +package dev.portableagent.action.service; + +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Component; + +@Component +public class CalendarActionRule implements ActionRule { + private static final String KIND = "calendar.create_event"; + private static final Set CONNECTORS = Set.of("fake-calendar", "google-calendar"); + + private final CalendarInputCheck inputCheck; + + public CalendarActionRule(CalendarInputCheck inputCheck) { + this.inputCheck = inputCheck; + } + + @Override + public String kind() { + return KIND; + } + + @Override + public void check(String connector, Map payload) { + if (!CONNECTORS.contains(connector)) { + throw new IllegalArgumentException("Unsupported connector: " + connector); + } + inputCheck.check(payload); + } +} diff --git a/src/main/openapi/action-api.yaml b/src/main/openapi/action-api.yaml index a19bdab..6ff7f1f 100644 --- a/src/main/openapi/action-api.yaml +++ b/src/main/openapi/action-api.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Portable Agent Action API - version: 2.6.0 + version: 3.0.0 description: Создание, просмотр и подтверждение безопасных действий агента. license: name: Apache-2.0 @@ -103,7 +103,7 @@ components: enum: [calendar.create_event] connector: type: string - enum: [fake-calendar] + enum: [fake-calendar, google-calendar] payload: $ref: "#/components/schemas/CalendarCreateEventPayload" requestKey: diff --git a/src/main/openapi/mcp-gateway-api.yaml b/src/main/openapi/mcp-gateway-api.yaml index e87cc12..93e1071 100644 --- a/src/main/openapi/mcp-gateway-api.yaml +++ b/src/main/openapi/mcp-gateway-api.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Portable Agent MCP Gateway API - version: 2.6.0 + version: 3.0.0 description: Безопасный внутренний вызов разрешённых tools в настроенных MCP-сервисах. license: name: Apache-2.0 diff --git a/src/test/java/dev/portableagent/action/service/ActionServiceTest.java b/src/test/java/dev/portableagent/action/service/ActionServiceTest.java index d0d5b61..60bf960 100644 --- a/src/test/java/dev/portableagent/action/service/ActionServiceTest.java +++ b/src/test/java/dev/portableagent/action/service/ActionServiceTest.java @@ -48,7 +48,8 @@ class ActionServiceTest { @BeforeEach void setUp() { - service = new ActionService(actionRepository, outboxRepository, payloadHash, calendarInputCheck, clock); + var rules = new ActionRules(java.util.List.of(new CalendarActionRule(calendarInputCheck))); + service = new ActionService(actionRepository, outboxRepository, payloadHash, rules, clock); } @Test @@ -121,22 +122,37 @@ void create_whenKindIsNotCalendar_shouldRejectRequest() { assertThatThrownBy(() -> service.create(tenantId, UUID.randomUUID(), request)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Only calendar.create_event is supported"); + .hasMessage("Unsupported action kind: task.create"); verify(actionRepository, never()).saveIfMissing(any(Action.class)); verifyNoInteractions(outboxRepository, payloadHash); } @Test - void create_whenConnectorIsNotFakeCalendar_shouldRejectRequest() { + void create_whenConnectorIsGoogleCalendar_shouldSaveAction() { var tenantId = UUID.randomUUID(); var request = new CreateActionCommand( "calendar.create_event", "google-calendar", Map.of("title", "Demo"), "request-123"); when(actionRepository.findByRequestKey(tenantId, request.requestKey())).thenReturn(Optional.empty()); + when(payloadHash.make(request.payload())).thenReturn("a".repeat(64)); + when(actionRepository.saveIfMissing(any(Action.class))).thenReturn(true); + + var result = service.create(tenantId, UUID.randomUUID(), request); + + assertThat(result.getConnector()).isEqualTo("google-calendar"); + verify(actionRepository).saveIfMissing(result); + } + + @Test + void create_whenConnectorIsUnknown_shouldRejectRequest() { + var tenantId = UUID.randomUUID(); + var request = + new CreateActionCommand("calendar.create_event", "unknown", Map.of("title", "Demo"), "request-123"); + when(actionRepository.findByRequestKey(tenantId, request.requestKey())).thenReturn(Optional.empty()); assertThatThrownBy(() -> service.create(tenantId, UUID.randomUUID(), request)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Only fake-calendar is supported"); + .hasMessage("Unsupported connector: unknown"); verify(actionRepository, never()).saveIfMissing(any(Action.class)); verifyNoInteractions(outboxRepository, payloadHash);