Читать 6 мин

Может ли шлюз повторно выполнить вызов, отправленный при заблокированном хранилище?

Проверьте обработку запросов при заблокированном хранилище: отклоненные вызовы AI-агента должны удаляться, а не оставаться в очереди и выполняться после разблокировки.

Может ли шлюз повторно выполнить вызов, отправленный при заблокированном хранилище?

Заблокированное хранилище должно создавать жесткую временную границу. Запрос, который попал в шлюз действий до снятия этой границы, должен завершиться отказом сразу. Он не должен оставаться в очереди, переживать переподключение или становиться допустимым после того, как кто-то позднее пройдет аутентификацию в хранилище.

Звучит очевидно, пока не начинаешь тестировать настоящий стек агента. Агенты повторяют попытки. Клиенты MCP переподключаются. HTTP-библиотеки повторяют запрос после потери ответа. Воркеры сохраняют задания. Интерфейс может показать отказ, пока другой компонент уже сохранил достаточно состояния, чтобы выполнить вызов позже. Если смотреть только на карточку одобрения или расшифровку диалога с агентом, опасную часть легко пропустить.

Описанный здесь тест отвечает на один узкий вопрос: шлюз удалил вызовы, полученные при заблокированном хранилище, или после разблокировки выполнился один из старых вызовов? Для этого нужны контролируемый HTTP-приемник, два уникальных идентификатора запросов и свидетельства с обеих сторон шлюза. Проведите тест до того, как доверите автономному агенту любую конечную точку, способную менять деньги, инфраструктуру, исходный код или данные клиентов.

Блокировка хранилища должна прекращать действие, а не откладывать его

Ожидаемое правило простое: когда хранилище заблокировано, шлюз запрещает любое действие, которому оно нужно. Разблокировка меняет ответ для последующего вызова. Она не меняет ответ для вызова, который уже пришел.

Это различие важно, потому что у запроса есть жизненный цикл. Процесс записывает байты в stdin. MCP-обертка разбирает сообщения JSON-RPC. Шлюз находит настроенное действие, проверяет, можно ли использовать секрет, при разрешении подставляет учетные данные, открывает исходящее соединение и возвращает результат. Ошибка может сохранить запрос на нескольких этапах этого пути.

Безопасная архитектура считает решение «заблокировано» окончательным для данного вызова. Шлюз может записать отказ, но не должен сохранять исполняемое замыкание, сериализованное тело запроса, исходящее задание или токен повторной попытки, которые смогут выполниться после открытия хранилища.

Часто смешивают два разных поведения:

  • Новая попытка - это новый вызов, который агент отправляет после получения ошибки или изменения состояния.
  • Повторное выполнение - это запуск исходного вызова, который шлюз или один из его помощников сохранил после отказа в доступе.

Новая попытка может быть законной, хотя для нее по-прежнему нужна обычная авторизация. Повторное выполнение пересекает границу безопасности без нового решения. Если перепутать эти случаи, тест можно ошибочно признать успешным: открыть хранилище, увидеть один запрос на стороне назначения и решить, что он пришел из осознанной новой попытки.

Model Context Protocol не избавляет от этой архитектурной проблемы. Его stdio-транспорт использует сообщения JSON-RPC, разделенные переводом строки, между запущенным клиентом серверным процессом и самим клиентом. Запросы JSON-RPC с id получают связанный ответ, а уведомления ответа не получают. Эти правила протокола дают удобный способ сопоставлять сообщения, но не определяют, может ли шлюз сохранить отклоненное действие для последующего выполнения. Это решение шлюз должен принять явно.

Момент получения раньше исходящего HTTP-вызова

Запрос считается полученным, когда шлюзу хватает информации, чтобы решить, выполнять его или нет, а не когда сервер назначения видит трафик. Если в этот момент хранилище заблокировано, отказ должен произойти до подстановки учетных данных и до передачи работы компоненту, который может пережить это решение.

Именно здесь тесты часто становятся поверхностными. Кто-то блокирует хранилище, просит агента вызвать API, ждет ошибки, разблокирует хранилище и проверяет, не появился ли запрос сразу. Такой тест пропускает отложенные повторы, заблокированные воркеры, пулы соединений и таймеры повторных попыток клиента. Он также не показывает, не дошел ли вызов до назначения еще до того, как интерфейс отобразил ошибку.

Используйте три временные отметки, записанные в независимых местах:

  1. T_lock: момент, когда блокировка хранилища подтверждена.
  2. T_attempt: момент, когда агент отправил старый идентификатор запроса.
  3. T_unlock: момент повторного открытия хранилища.

Продолжайте наблюдать за приемником после T_unlock. Ожидание должно превышать все настроенные в клиенте, шлюзе и промежуточных компонентах интервалы повторных попыток и таймаутов. Если вы не знаете эти значения, не выбирайте успокаивающе короткую задержку. Сначала выясните их или используйте приемник, который остается доступным достаточно долго, чтобы выявить отложенную доставку.

Полезная формулировка критерия приемки точнее, чем «заблокированный запрос завершился ошибкой»:

Для идентификатора запроса locked-... контролируемый приемник не фиксирует ни одного выполнения до и после T_unlock; для идентификатора fresh-..., отправленного только после T_unlock, приемник фиксирует ровно одно выполнение.

Эта формулировка выявляет обе стороны проблемы. Она показывает старый вызов, выполненный позже, и доказывает, что тест не провалился лишь потому, что приемник или настройки действия были сломаны.

Не используйте для обоих вызовов одинаковые данные. Если оба запроса содержат deploy=true, вы не сможете понять, какой именно дошел. Передайте идентификатор запроса в пути URL, безобидном поле JSON и заголовке, если настроенное действие это позволяет. Здесь полезно дублирование: оно выявляет случайное переписывание или кэширование.

В очередях и механизмах повторных попыток прячутся дефекты повторного выполнения

Самые опасные дефекты повторного выполнения не выглядят драматично. Обычно они возникают из обычного кода повышения надежности, написанного человеком, который решил, что ошибка авторизации ведет себя так же, как временная сетевая ошибка.

Рассмотрим типичную плохую последовательность. Агент отправляет вызов инструмента MCP, пока хранилище заблокировано. Обертка принимает сообщение и создает внутреннее рабочее задание. Проверка хранилища возвращает ошибку блокировки, но воркер считает ее повторяемой, потому что до назначения запрос не дошел. Вызывающая сторона отключается или завершает сеанс. Позже пользователь открывает хранилище. Воркер просыпается, находит доступные учетные данные и отправляет исходный HTTP-запрос.

Интерфейс может выглядеть правильно на всем протяжении этой последовательности. Исходный агент получил ошибку. Пользователь увидел сообщение о блокировке хранилища. Назначение получило действительные учетные данные только после разблокировки. Но шлюз перенес действие через границу, за которой оно должно было завершиться.

Вот какие шаблоны стоит искать:

  • Универсальная оболочка повторных попыток перехватывает все ошибки, кроме некорректного ввода.
  • Постоянная очередь заданий сохраняет намерение до решения хранилища.
  • Future или promise ждет разблокировки вместо возврата окончательной ошибки.
  • При переподключении путь повторно отправляет запрос из памяти после завершения клиентского процесса.
  • Фоновый помощник хранит состояние повторных попыток независимо от проверки хранилища.

Популярная рекомендация «повторять каждую неудачную сетевую операцию» на этой границе неверна. Она популярна потому, что сбои сетевого транспорта случаются часто, а повторы нередко улучшают доставку. Заблокированное хранилище не является сбоем транспорта. Это явный отказ использовать полномочия. Для данного вызова его нужно считать окончательным.

То же относится к отмене. Отключение клиента не обязательно означает отмену HTTP- или SSE-запроса. Спецификация транспорта MCP говорит, что отключение может произойти в любой момент и само по себе не должно трактоваться как отмена; для явной отмены клиенту следует отправить отдельное уведомление. Для долгих операций это разумно, но такое поведение делает локальное состояние шлюза еще важнее: отклоненный вызов не должен оставаться исполняемым только потому, что состояние транспорта стало неоднозначным.

Создайте приемник, который делает каждое выполнение видимым

Контролируемый приемник дает более надежные свидетельства, чем переписка с агентом. Он показывает, действительно ли исходящий вызов дошел, какой идентификатор он содержал и когда именно пришел. Изолируйте его от рабочей среды и оставьте ему единственный побочный эффект: добавление строк в локальный журнал.

Запустите этот небольшой приемник на Python на машине и порту, доступных шлюзу. Он принимает POST-запросы, записывает для каждого поступления одну строку JSON и возвращает безобидный успешный ответ. В журнал попадает только короткая метка из заголовка авторизации, а не сам секрет.

# receiver.py
from http.server import BaseHTTPRequestHandler, HTTPServer
from datetime import datetime, timezone
import hashlib
import json

LOG = "receiver-events.jsonl"

class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length).decode("utf-8", errors="replace")
        auth = self.headers.get("Authorization", "")
        auth_marker = hashlib.sha256(auth.encode()).hexdigest()[:12] if auth else None
        event = {
            "received_at": datetime.now(timezone.utc).isoformat(),
            "method": self.command,
            "path": self.path,
            "request_id": self.headers.get("X-Replay-Test-Id"),
            "auth_marker": auth_marker,
            "body": body,
        }
        with open(LOG, "a", encoding="utf-8") as log:
            log.write(json.dumps(event) + "\n")
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(b'{"received":true}')

    def log_message(self, format, *args):
        return

HTTPServer(("127.0.0.1", 8787), Receiver).serve_forever()

Запустите его командой:

python3 receiver.py

Выходной файл будет выглядеть примерно так:

{"received_at":"2026-07-22T16:42:12.103841+00:00","method":"POST","path":"/replay-test/fresh-8f1c","request_id":"fresh-8f1c","auth_marker":"a4d7e02c1b9f","body":"{\"kind\":\"fresh\"}"}

Не помещайте действующий секрет в тело запроса. Настройте тестовое действие с отдельными учетными данными с минимальными привилегиями в хранилище и позвольте шлюзу подставить их обычным способом в HTTP-канал. Отпечаток заголовка приемника подтверждает лишь то, что пришло некоторое значение авторизации; сам секрет не попадет в файл, который может сохраниться после теста.

До теста с заблокированным хранилищем отправьте один обычный запрос, пока хранилище открыто. Убедитесь, что приемник записал его и путь настроенной конечной точки указан верно. Затем удалите receiver-events.jsonl или перенесите его в другое место. Пустой журнал не позволит предыдущему запросу настройки исказить результат.

Проведите тест с двумя намеренно разными вызовами

Смотрите, какой вызов стоит за запуском
Просматривайте отдельные вызовы в журнале Activity вместе с запуском агента, который их выполнил.

Тесту нужен старый идентификатор, запрос с которым выполняется во время блокировки, и новый идентификатор, создаваемый только после разблокировки. Используйте похожие на случайные метки, но запишите их до начала теста. Удобные для человека метки упрощают сопоставление при аудите.

Например:

old request ID:   locked-3d4a
fresh request ID: fresh-91ce

Подготовьте инструкцию для агента или запрос клиента MCP, который вызывает настроенное HTTP-действие с такими данными:

{
  "path": "/replay-test/locked-3d4a",
  "headers": {
    "X-Replay-Test-Id": "locked-3d4a"
  },
  "body": {
    "kind": "locked-period-attempt",
    "request_id": "locked-3d4a"
  }
}

Точное имя инструмента и форма аргументов зависят от настроенного интерфейса действия. Не имитируйте успешный тест, вызывая приемник напрямую из командной строки. Запрос должен пройти через тот же путь агента, MCP-обертки, шлюза, хранилища и HTTP-действия, которому вы собираетесь доверять.

Теперь выполните последовательность без импровизации:

  1. Убедитесь, что журнал приемника пуст, а хранилище заблокировано.
  2. Запустите новый процесс агента и отправьте вызов locked-3d4a.
  3. Зафиксируйте ошибку на стороне агента и время. Не отправляйте запрос повторно.
  4. Оставьте процесс агента работающим на короткий период наблюдения, затем завершите его. Это выявит как немедленные повторы, так и поведение, связанное с процессом.
  5. Откройте хранилище и подождите весь период наблюдения. Несколько раз проверьте журнал приемника. locked-3d4a должен оставаться отсутствующим.
  6. Только после этого ожидания запустите новый процесс агента и отправьте fresh-91ce. Приемник должен записать этот идентификатор один раз.

Старый и новый вызовы должны выполняться в отдельных процессах агента. Иначе результат могут исказить одобрение на уровне сеанса или внутренний кэш клиента. Вы проверяете, способен ли старый вызов пересечь границу блокировки, а не запоминает ли один процесс состояние авторизации.

Если после разблокировки появляется запрос на одобрение заблокированного вызова, хотя агент не отправлял новый вызов, остановитесь. Это свидетельствует о сохраненном намерении. Если приемник в любой момент после разблокировки получает locked-3d4a, считайте тест безопасности проваленным, даже если сервер вернул 200 и не изменил данные.

Проверяйте оба журнала, но не заменяйте приемник логами

Журнал шлюза помогает восстановить картину того, что, по его мнению, произошло. Приемник доказывает, что произошло за пределами шлюза. Нужны оба источника, потому что каждый по отдельности может создать ложную уверенность.

Sallyport хранит журнал Sessions для запусков агентов и журнал Activity для отдельных вызовов. Оба журнала строятся из одного зашифрованного аудита с хеш-цепочкой. Автономная проверка доступна через sp audit verify, и для проверки цепочки ей не нужен ключ хранилища. Выполните эту команду после теста и сохраните результат вместе с журналом приемника и расшифровкой действий агента.

Используйте записи, чтобы ответить на конкретные вопросы:

  • Создал ли старый вызов запись Activity и показывает ли она отказ?
  • Какой процесс агента и какой центр подписи кода указаны в сеансе?
  • Есть ли поздняя активность со старым идентификатором запроса, путем конечной точки или совпадающая по времени?
  • Начался ли новый сеанс для свежего вызова после разблокировки?
  • Успешно ли проходит проверку аудита собранный набор записей?

Не считайте запись «отказано» доказательством того, что запрос не покинул машину. Журнал фиксирует представление шлюза о собственном решении. Независимую проверку дает контролируемый приемник. И наоборот, отсутствие записи может означать, что вы искали не по тем значениям, временные отметки расходятся или тест использовал не то действие. Поэтому важен успешный новый запрос.

Для повторяемого командного теста сохраняйте под одним идентификатором запуска четыре артефакта: расшифровку действий агента, JSONL-файл приемника, экспорт журнала или снимки экрана с идентификаторами вызовов и результат sp audit verify. Не помещайте секреты ни в один из них.

Уведомления, пакеты и переподключения требуют отдельных сценариев

Не передавайте секреты агентам
Sallyport сам подставляет учетные данные для HTTP и SSH, поэтому агенты не получают доступ к секретам.

Один успешный тест запроса не охватывает все формы сообщений, которые может отправлять клиент агента. Запросы с идентификаторами тестировать проще всего, потому что JSON-RPC требует, чтобы ответ содержал тот же идентификатор. У уведомлений идентификаторов нет и ответа они не получают, поэтому исчезает обычное доказательство того, что шлюз их отклонил. В JSON-RPC прямо сказано, что сервер не должен отвечать на уведомления.

Для пути, поддерживающего уведомления, добавьте в исходящие данные метку, которую увидит приемник, например notification-77b2. Заблокируйте хранилище, один раз вызовите отправку уведомления, разблокируйте хранилище и убедитесь, что метка никогда не появилась. Не делайте вывод о безопасности по тишине в интерфейсе клиента, потому что при уведомлении тишина ожидаема по протоколу.

Если клиент или обертка принимает пакетные запросы, протестируйте их отдельно. Поместите в пакет два безобидных вызова: один выполняется при блокировке, а второй отправляется только после разблокировки в отдельном пакете. Не помещайте старый и новый вызовы в один пакет: шлюз, обрабатывающий часть пакета до изменения состояния, может дать результат, который невозможно интерпретировать.

В тестах переподключения меняйте за один раз только один фактор. Проведите отдельные запуски для таких случаев:

  • Оставьте процесс агента работать до разблокировки.
  • Завершите процесс агента до разблокировки.
  • Перезапустите до разблокировки только клиент MCP или обертку.
  • Отключите сетевой путь после ошибки блокировки и восстановите его после разблокировки.

Ожидание остается тем же. Старый идентификатор приемника не должен появиться никогда. Если старый идентификатор появляется только после переподключения, вы нашли путь повторного выполнения, связанный с восстановлением транспорта, а не с интерфейсом хранилища.

Одобрение на уровне сеанса не может исправить сохраненное действие

Проверяйте журнал аудита автономно
Проверяйте зашифрованный журнал аудита Sallyport с хеш-цепочкой в автономном режиме с помощью sp audit verify.

Авторизация на уровне сеанса и одобрение каждого вызова решают, можно ли выполнить текущий вызов. Они не делают безопасным хранение действия, которое отклонили при заблокированном хранилище.

Порядок здесь важен. Проверка хранилища абсолютна: пока оно заблокировано, любое действие отклоняется. Только после открытия хранилища шлюз может проверить процесс на соответствие решению об авторизации сеанса или запросить одобрение конкретного вызова для настроенных учетных данных. Если мысленно поменять порядок, команды начинают обсуждать, может ли прежняя карточка одобрения разрешить отложенное действие. Не может, потому что заблокированный вызов больше не должен существовать как исполняемая работа.

Проверяйте границы отдельно. Сначала докажите, что вызов периода блокировки никогда не выполняется после разблокировки. Затем при открытом хранилище проверьте, что новый процесс агента проходит ожидаемую авторизацию сеанса. Наконец, если для учетных данных включено одобрение каждого вызова, проверьте, что каждое новое использование снова требует решения. Если объединить все три проверки в один длинный запуск, будет трудно понять причину сбоя.

Есть и тонкий вопрос идентичности процесса. Одобрение сеанса относится к конкретному запуску процесса агента, а не к абстрактному «тому же помощнику». После перезапуска считайте процесс новым, пока шлюз не идентифицирует и не авторизует его по собственным правилам. Не позволяйте тестовому скрипту скрывать это, повторно используя старое соединение.

Превратите результат в условие выпуска

Запускайте этот тест при каждом изменении диспетчеризации шлюза, кода жизненного цикла хранилища, MCP-обертки, поведения повторных попыток, настроек HTTP-клиента или помощника, выполняющего SSH-действия. Дефект повторного выполнения часто появляется во время изменений надежности, потому что код выглядит безобидно при проверке: очередь, обработчик переподключения или один широкий блок catch.

Выпуск должен быть запрещен, если хотя бы одно из утверждений неверно:

  • Контролируемый приемник не содержит ни одной записи для каждого идентификатора, отправленного во время блокировки, в том числе после открытия хранилища.
  • Новый идентификатор после разблокировки доходит до приемника один раз через то же настроенное действие.
  • Перезапущенный клиент или агент не может вызвать появление старого идентификатора.
  • В журналах указаны ожидаемые отклоненные и разрешенные события без необъяснимых дублей.
  • Цепочка аудита успешно проходит проверку для сохраненных свидетельств.

По возможности включите отрицательный сценарий в автоматический интеграционный тест. Тестовый стенд должен заблокировать хранилище, отправить старый вызов, открыть хранилище, подождать ограниченное окно повторных попыток и убедиться, что старого идентификатора нет у приемника. Затем он должен отправить новый вызов и проверить одно поступление. Приемник должен быть локальным и одноразовым, чтобы у теста не было полномочий за пределами собственного файла журнала.

Плохой результат легко сформулировать: человек открывает хранилище, чтобы разрешить следующее действие, а вместо него выполняется более раннее. Не принимайте шлюз, который лишь показывает правильную ошибку блокировки. Пусть он докажет с помощью внешнего наблюдателя, что забыл старый запрос.

Вопросы и ответы

Как доказать, что заблокированный запрос удалили, а не отложили?

Отказ показывает лишь, что шлюз отклонил вызов в конкретный момент. Тест на удаление должен идти дальше: откройте хранилище, подождите дольше любого важного окна повторных попыток и убедитесь, что старый идентификатор запроса так и не появился на контролируемом сервере. Затем отправьте новый запрос с другим идентификатором, чтобы убедиться, что сам тестовый путь работает.

Может ли агент просто повторить запрос после открытия хранилища?

Нет. Шлюз не может помешать агенту отправить новый запрос после того, как тот узнает об открытии хранилища. В тесте нужно отличать новый запрос после разблокировки от старого запроса до разблокировки: используйте разные идентификаторы и сервер, который записывает их отдельно.

Достаточно ли таймаута, чтобы доказать, что действие не выполнилось?

Таймаут неоднозначен: клиент мог повторить запрос, шлюз мог продолжить его обработку, а сервер назначения мог получить его еще до появления таймаута. Считайте таймаут ошибкой теста, пока журнал сервера и записи шлюза не покажут, что произошло.

Как тестировать уведомления JSON-RPC при заблокированном хранилище?

Уведомления требуют особого внимания, потому что JSON-RPC не дает вызывающей стороне ответа, с которым можно сопоставить результат. Для чувствительного действия используйте в тесте запрос с идентификатором или добавьте независимую метку на стороне приемника, чтобы выполнение было заметно.

Делает ли перезапуск агента старый запрос безопасным?

Новый процесс агента должен снова пройти авторизацию сеанса, если этот механизм включен, но само по себе это не доказывает, что вызовы периода блокировки были удалены. Тестируйте перезапуск процесса и открытие хранилища как отдельные изменения состояния, затем проверяйте, не высвободило ли какое-либо из них старое намерение.

Решают ли ключи идемпотентности проблему повтора после разблокировки?

Только если конечная точка делает повторную доставку безвредной и записывает ключ идемпотентности. Такой ключ предотвращает повторный эффект на стороне назначения, но не дает шлюзу права хранить действие, полученное во время блокировки хранилища.

Можно ли проводить этот тест на рабочем API?

Используйте контролируемый приемник, отдельный тестовый путь и данные, не имеющие операционной ценности. Не направляйте этот тест на рабочие конечные точки биллинга, развертывания, DNS или системы контроля версий только потому, что они уже существуют.

Как быстро вручную проверить повторное выполнение?

Сначала проверьте контролируемый приемник на наличие старого идентификатора, затем после разблокировки отправьте новый идентификатор и убедитесь, что он появился один раз. Если старый идентификатор появится после разблокировки, считайте результат уязвимостью, даже если конечная точка ничего не изменила.

Какие свидетельства аудита нужно сохранить для этого теста?

Нужна запись, в которой зафиксированы отказ или заблокированное состояние, идентификатор сеанса агента и значение для сопоставления запроса. Защищенный от незаметного изменения журнал аудита помогает подтвердить, что записи позже не редактировали, но он не заменяет свидетельства на стороне приемника.

Что должен гарантировать шлюз хранилища?

Надежный интерфейс означает, что шлюз отклоняет вызов до того, как тот попадет в постоянную очередь заданий или механизм повторных попыток. Открытие хранилища меняет разрешение для запросов, пришедших после этого, но не должно превращать ранее отклоненные вызовы в разрешенные задания.

Sallyport

Sallyport выполняет API-вызовы и SSH-команды за вашего ИИ-агента. Ключи остаются в локальном хранилище на вашем Mac; вы подтверждаете каждый запуск, и каждое действие попадает в запечатанный журнал.

© 2026 Sallyport · Открытый код по лицензии Apache-2.0 · Oleg Sotnikov