ADR 0072 — Проект выбирает, где живёт код: папка пользователя или управляемая папка
Статус
Принят.
Этот ADR пересматривает часть ADR 0065, который остаётся принятым и не редактируется, и затрагивает территорию, описываемую ADR 0055. Оба выбрали ПРОТИВОПОЛОЖНОЕ этому документу направление — идти к стене контейнера, — и поэтому раздел последствий здесь самый важный: он объявляет, не смягчая, что теряется.
Контекст
Запрос
«Пересечь границу, при которой код пишется только в контейнере, и иметь возможность писать код из папки пользователя. При создании проекта выделить Local для этой цели — быть какой-то папкой пользователя — и Container с той опцией, что есть сегодня.»
И о форме пути — вариант, явно выбранный владельцем продукта, осознающим предупреждение, что он работает, только если папка смонтирована в контейнер: свободный путь, вводимый пользователем.
Территория: единственная граница, существующая сегодня
До этого ADR корнем проекта была буквально одна строка:
join(PROJECT_WORKSPACES_ROOT, workspaceDirName)
с workspaceDirName, проверяемым против ^[A-Za-z0-9_-]{1,64}$
(apps/api/src/infrastructure/filesystem/project-workspaces-root.ts). Сила
этой формы не в проверке, а в join. С сегментом без / и без .. результат
не может выйти за управляемый корень, что бы ни случилось с колонкой. Это
СТРУКТУРНАЯ изоляция, и она держит четырёх потребителей, которые должны
согласовываться между собой:
| потребитель | где | что ломается, если корни разойдутся |
|---|---|---|
permissions.json | fs-permissions-file-store.ts | политика читается из одного места, применяется к другому |
| область терминала (ADR 0055) | propose-action.use-case.ts → decide.ts | область, РАЗРЕШАЮЩАЯ команду, указывает не на ту папку |
| чтение вкладки Code (RN-095) | caminhoDeRepositorioContido | изоляция чтения над не тем деревом |
| рабочее дерево engine | apps/engine/lib/engine/actions/workspace.ex | агент пишет туда, где api не читает |
Что меняется
projects получает пару (workspace_mode, workspace_path). В режиме
container — по умолчанию, и это поведение каждого уже существующего проекта
— ничего не меняется. В режиме local корнем становится абсолютный путь,
введённый пользователем, а join уходит со сцены.
Решение
-
Режим принадлежит ПРОЕКТУ, выбирается при создании, по умолчанию
container. Миграция0043.containerкак значение колонки по умолчанию — то, что не даёт существующему проекту сменить место, — и решение NOT NULL, какstory_promotion(ADR 0046), потому что значение И ЕСТЬ решение, а решение о полномочиях не остаётся неявным. -
Режим и путь — ОДНО решение, закреплённое в базе через CHECK (
(workspace_mode = 'local') = (workspace_path IS NOT NULL)). Замок не остаётся только в use case, потому что колонку читают ДВА процесса (api и engine) и пишут скрипты seed и backfill, не проходящие через него.localбез пути — это область терминала, указывающая в никуда;containerс путём — это второй источник истины, ждущий расхождения с первым. -
Вывод остаётся ЕДИНСТВЕННЫМ.
projectScopeRootстал принимать РАСПОЛОЖЕНИЕ ({workspaceDirName, workspaceMode, workspacePath}) вместо имени папки, и именно он выбирает ветку. Ни один вызывающий не получил собственной проверки: причина, по которой функция существует, — два вывода должны согласовываться, — действует ещё сильнее теперь, когда веток две. Действует правило PÓS-FASE 15: дублировать её в каждом вызывающем значило бы проверку, которая однажды разойдётся. -
Охрана создания отказывает, а не даёт сломаться потом (RN-170). Путь, не смонтированный в контейнер, даёт проект, который застревает на первом инструменте первого агента, далеко от экрана, где было принято решение. Создание проверяет и отказывает с 400 и с инструкцией, как смонтировать: абсолютный путь, без
.., существующий, папка, доступная процессу на запись (access(W_OK|X_OK)— образы работают non-root, ADR 0024, а папка хоста с другим владельцем на практике приходит только для чтения). -
Отказ покрывает корень системы и checkout Brabo, в обе стороны.
/, системные папки и всё под ними остаются вне, потому что корень проекта — это область, разрешающая терминал агента: проект с корнем в/etcпревращает «агент может писать в свой проект» в «агент может переписать контейнер». Перекрытие с checkout Brabo отклоняется в обоих направлениях — папка, СОДЕРЖАЩАЯ монорепозиторий (буквальный случай запроса), и папка ВНУТРИ него, — это проблема, о которой ADR 0055 сообщает как о реально происходящей. Отклонить одно и разрешить другое значило бы закрыть дверь и оставить окно. -
Лексическая часть охраны выполняется и при ЧТЕНИИ. Проверка создания — это затвор, но единственный способ её обойти — писать прямо в базу, — и когда это происходит, выигрыш — область терминала в
/. ПоэтомуprojectScopeRootповторно применяет лексический предикат при каждом выводе и громко падает. Что там НЕ перепроверяется — дисковая часть (существует, доступна на запись): это I/O, а функция на горячем пути. -
Затвор образа Arquiteto (RN-105) не действует для проекта Local. Он существует потому, что контейнер придаёт смысл чтению кода; проект Local не поднимает никакого контейнера. Без этого решения вкладка Code вечно отвечала бы 409 в проекте, где решение Arquiteto никогда не произойдёт, — вкладка закрыта побочным эффектом, а не выбором. Освобождение живёт в той же воронке, что и затвор, никогда не разбросано по маршрутам.
-
Engine читает обе колонки и разрешает локатор в ЗАПРОСЕ.
Engine.Projects.Projectвозвращает ИМЯ папки в режимеcontainerи АБСОЛЮТНЫЙ ПУТЬ в режимеlocal, аWorkspace.workspace_dir/2различает их по начальной косой черте — которая однозначна, потому что имя папки проверяется regex, не допускающим/. Разрешать в запросе, а не в каждом вызывающем, — тот же довод, что в пункте 3.
Чего этот ADR НЕ делает
- Не поднимает контейнер на проект. ФАЗА 25b остаётся отрезанной, а ADR
0065 продолжает действовать как есть. Проект
localработает в ТОМ ЖЕ контейнере, что и сегодня; изменилась лишь папка. - Не меняет границу внешнего эффекта (RN-106).
git push, открытие PR и деплой остаютсяdenyв терминале, внутри области и вне её, в режиме Local, как и в Container. - Не меняет политику терминала (ADR 0055). Область пути и узкий allowlist остаются границей; меняется то, где начинается область.
- Не предлагает селектор папки. Путь вводится, по объявленному решению владельца продукта. Селектор потребовал бы от api перечислять браузеру файловую систему контейнера, а это новая поверхность ради решения эргономики.
Последствия
Болезненное, и это объявленная цена этой поставки
Структурная изоляция join перестаёт существовать для проектов Local. Там,
где раньше ни одна повреждённая колонка не могла дать корень вне
PROJECT_WORKSPACES_ROOT, теперь корень — то, что скажет колонка. На его месте
остаются список отказов (пункт 5) и повторная проверка при чтении (пункт 6), — а
список отказов — тот вид барьера, который, как доказали находки Z и AD, не
сходится для глаголов команд. Разница, на которой держится решение, —
пространственная: абсолютный путь — закрытое упорядоченное пространство, где
«находится под этим корнем» решается по префиксу, тогда как
глагол/форма/вызов — три открытых пространства. Это не одна и та же проблема,
но и не стена.
Вектор symlink, объявленный в ADR 0055, остаётся открытым и теперь указывает
на машину пользователя. Нормализация лексическая, по контракту (decide()
чист, ноль IO), поэтому symlink ВНУТРИ папки проекта, указывающий наружу, не
обнаруживается, — а в режиме Local «наружу» может быть $HOME того, кто
работает. Закрытие этого — изоляция, а не политика, и по-прежнему зависит от
ФАЗЫ 25b.
Папка пользователя становится доступной агенту на запись. Это запрос, а не
побочный эффект: агент пишет код в папку пользователя. Стоит сказать вслух, что
permissions.json этого проекта тоже начинает жить внутри неё и что удаление
папки удаляет и политику.
Помогающие
- Ничего не меняется для того, кто не выбирает. Значение по умолчанию — привычное поведение, и существующая suite прошла без изменения поведения — только сигнатуры.
- Отказ датирован и обучает. Наиболее вероятный режим отказа (несмонтированная папка) становится 400 со строкой compose, которую нужно добавить, на экране, где принимается решение, а не застрявшим агентом потом.
- Внешний контракт gates, RBAC и замок merge (RN-014) не затронуты.
Что требуется от оператора
Смонтировать папку в ОБА сервиса, по ОДНОМУ абсолютному пути — см.
docs/runbook.md, раздел «Projeto no modo Local», и
docs/reference/configuration.md. Монтирование только в api даёт проект,
который api принимает, а engine не видит: api проверяет то, что видит она, и у
неё нет способа узнать, что смонтировано в другом контейнере.
Рассмотренные альтернативы
- Корень папок пользователя, где проект выбирает ПОДКАТАЛОГ
(
LOCAL_WORKSPACES_ROOT+ имя). Сохранила быjoinи всю структурную изоляцию и была самой сильной альтернативой — но это не запрос: владелец продукта выбрал свободный путь, осознавая предупреждение. - Принимать путь без проверки и дать упасть при использовании. Отброшено: это ровно тот режим отказа, предотвратить который существует RN-170, и он проявляется далеко от экрана, где было принято решение.
- Пометить проект Local как «без вкладки Code» вместо освобождения от затвора RN-105. Это значило бы наказать новый режим правилом, которое о нём не говорит.