Граница файловой системы рабочей области
Обзор
HTTP-маршруты файлов демона и делегированные вызовы ACP readTextFile / writeTextFile проходят через границу WorkspaceFileSystem (packages/cli/src/serve/fs/), которая предоставляет:
- Разрешение путей — канонизация путей и отклонение всего, что выходит за пределы привязанной рабочей области, включая символические ссылки.
- Шлюз доверия — отказ в записи, если рабочая область не является доверенной (
untrusted_workspace). - Политика размера и содержимого — лимит на полный снимок/вывод (
MAX_READ_BYTES = 256 КиБ), окна для большого текста с ограничением и вывода, и стоимости сканирования (MAX_TEXT_SCAN_BYTES = 8 МиБ), лимит на запись (MAX_WRITE_BYTES = 5 МиБ), обнаружение двоичных файлов. - Атомарность — запись с последующим переименованием, с сохранением режима целевого файла и режимом по умолчанию
0o600для новых файлов, или следует umask процесса при политике режима новых файловsystemфабрики (QWEN_SERVE_NEW_FILE_MODE). - Аудит — каждый доступ / отказ генерирует структурированное событие для
PermissionAuditRing/ мониторинга. - Типизированные ошибки — замкнутый объединенный тип
FsErrorKind, сопоставленный с HTTP-статусами.
HTTP-маршруты файлов (GET /file, GET /file/bytes, POST /file/write, POST /file/edit, GET /list, GET /glob, GET /stat) используют эту границу и никогда не получают исключение для одного хоста. В продакшен-демоне делегированные вызовы ACP достигают WFS через внедрённый адаптер моста; общие вызывающие моста используют WFS только когда они внедряют такой адаптер. Продакшен-среды выполнения qwen serve на одном хосте объявляют readTextFile: false, поэтому все потребители FileSystemService.readTextFile в дочернем процессе используют обычный сервис файловой системы CLI. Итоговые записи текста ACP writeTextFile остаются делегированными: цели рабочего пространства используют WFS, тогда как строгий маркер встроенного инструмента может выбрать эквивалентный хост-писатель для внешнего пути только на адаптерах для одного хоста, созданных демоном. См. дизайн внешних записей.
Этот срез возможности чтения текста покрывает прямой read_file плюс общие предварительные чтения, используемые операциями записи, редактирования, блокнота, sed и артефактов:
- Он намеренно принимает обычное поведение чтения CLI, а не гарантии чтения WFS. Документ по дизайну содержит точный список того, от чего отказываются.
- Тот же документ записывает ограниченное чувство, в котором сохранённый путь чтения адаптера “fail closed”; отдельный дизайн внешних записей записывает, как завершается сбой одобренной итоговой записи.
- Прямой внешний
read_fileсохраняет обычные правила разрешений CLI и телеметрию основных файловых операций. - HTTP-маршруты файловой системы остаются ограниченными рабочим пространством, и поведение инструмента обнаружения агента не изменяется этой возможностью.
- Вспомогательные действия, такие как создание родительского каталога и shell-команды, — это отдельные существующие пути, не охваченные этой границей.
qwen serveпредполагает принципал безопасности на одном хосте с одним UID и не является песочницей ОС.
Обязанности
- Разрешать пути, предоставленные пользователем, в типизированные значения
ResolvedPath, которые остальная часть границы может безопасно использовать. - Отказывать в работе с путями за пределами привязанной рабочей области (
path_outside_workspace) и путями, цель которых — символическая ссылка (symlink_escape). - Отказывать в чтении полного снимка, если размер превышает
MAX_READ_BYTES, при этом разрешать явные окна с выводом, ограниченнымMAX_READ_BYTES, и стоимостью сканирования, ограниченнойMAX_TEXT_SCAN_BYTES; отказывать в записи, если размер превышаетMAX_WRITE_BYTES, а также в работе с двоичными файлами (binary_file). - Отказывать в записи/редактировании, если рабочая область не доверена (
untrusted_workspace) — проверка черезassertTrustedForIntent(trusted, intent). - Учитывать шаблоны
.gitignore/.qwenignoreчерезshouldIgnore. - Выполнять атомарные операции записи с последующим переименованием с сохранением режима целевого файла; режим нового файла по умолчанию —
0o600(или0o666 & ~umaskпри политике режима новых файловsystem). - Генерировать события аудита
fs.access/fs.deniedпри каждой операции. - Сопоставлять каждый сбой с
FsErrorс указанием вида и HTTP-статуса; обработчики маршрутов сериализуют их единообразно.
Архитектура
Структура модулей
| Файл | Назначение |
|---|---|
index.ts | Баррель: реэкспортирует WorkspaceFileSystem, WorkspaceFileSystemFactory, FsError, FsErrorKind, ResolvedPath, RequestContext. |
workspace-file-system.ts | Основной оркестратор: разрешение путей, проверки политик, атомарные записи, аудит. |
paths.ts | Канонизация путей, resolveWithinWorkspace, отклонение symlink. |
policy.ts | Проверки размера (enforceReadSize, enforceWriteSize), доверия (assertTrustedForIntent), бинарности (detectBinary). |
audit.ts | createAuditPublisher, типы событий аудита. |
errors.ts | FsError, FsErrorKind, FsErrorStatus. |
types.ts | ResolvedPath, RequestContext, ReadOptions, WriteOptions. |
WorkspaceFileSystemFactory
Фабрика строится один раз при запуске демона (runQwenServe → resolveBridgeFsFactory → адаптер). Она владеет:
trusted: boolean— сигнал доверия для этой рабочей области.workspaceRoot: string— канонизированный корень рабочей области.auditRing: PermissionAuditRing— общее кольцо аудита.newFileMode: 'owner' | 'system'— политика режима новых файлов (по умолчаниюowner;0o600для новых файлов, независимо от umask). Приsystemновые файлы следуют0o666 & ~umask. Существующие файлы всегда сохраняют свой режим.
Каждый запрос создает RequestContext и вызывает оркестратор фабрики только для этого вызова — долгоживущего состояния на файл нет.
FsError и FsErrorKind
FsError — это типизированная ошибка с { kind, message, status, cause? }. FsErrorKind — замкнутый объединенный тип:
| Вид | HTTP | Причина |
|---|---|---|
path_outside_workspace | 400 | Разрешенный путь выходит за пределы привязанной рабочей области. |
symlink_escape | 400 | Цель является символической ссылкой. |
path_not_found | 404 | ENOENT. |
binary_file | 422 | Содержимое определено как двоичное при запросе через текстовый маршрут, либо большой текст в кодировке, которую текстовый маршрут не может декодировать. |
file_too_large | 413 | Текст без окна/полного снимка выше MAX_READ_BYTES, смещение строк за пределами MAX_TEXT_SCAN_BYTES или запись выше MAX_WRITE_BYTES. |
hash_mismatch | 409 | Ошибка оптимистичной конкурентности expectedSha256, или файл изменился во время стабильного чтения. |
file_already_exists | 409 | Режим 'create' для существующего файла. |
text_not_found | 422 | Строка поиска в POST /file/edit не найдена в файле. |
ambiguous_text_match | 422 | Несколько совпадений, когда требовалось ровно одно. |
untrusted_workspace | 403 | Попытка записи в недоверенной рабочей области. |
permission_denied | 403 | Ошибка ОС EACCES / EPERM. |
io_error | 503 | ENOSPC / EIO / EBUSY / ETXTBSY / ENAMETOOLONG / EMFILE / ENFILE. Отличается от permission_denied, чтобы пайплайны мониторинга не вызывали команду реагирования на инциденты безопасности из-за “диск заполнен”. |
internal_error | 500 | Ошибка, не связанная с errno, достигшая границы (TypeError, программная ошибка). |
parse_error | 400 / 422 | Ошибка разбора тела запроса (400) или нарушение инварианта уровня сервиса (422). |
BridgeFileSystem (ACP-адаптер)
packages/acp-bridge/src/bridgeFileSystem.ts определяет:
interface BridgeFileSystem {
readText(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
writeText(params: WriteTextFileRequest): Promise<WriteTextFileResponse>;
}Это точка внедрения для ACP readTextFile / writeTextFile. Тесты моста и встроенные вызывающие в режиме A могут опустить его в BridgeOptions; BridgeClient использует свой встроенный прокси fs.readFile / fs.writeFile (сохраняет поведение до F1). Продакшен qwen serve подключает BridgeFileSystem через createBridgeFileSystemAdapter(fsFactory) (packages/cli/src/serve/bridge-file-system-adapter.ts) и устанавливает delegateReadTextFileToClient: false. Соответствующие возможности дочерние процессы поэтому читают текст локально и делегируют итоговые записи текста ACP. Адаптер сохраняет свою реализацию чтения, чтобы неожиданные или нарушающие возможности делегированные чтения всё равно сталкивались с границей рабочего пространства WFS. Его внешний путь хост-писателя отключён по умолчанию и выбирается только по точному версионному происхождению на адаптерах для одного хоста, принадлежащих демону; внедрённые bridge, реестры и фабрики рабочего пространства, общий ACP и HTTP сохраняют обычную границу.
Два защитных шлюза, которые адаптер ОБЯЗАН воспроизвести (потому что встроенный прокси полностью обходится при внедрении адаптера):
- Отклонение нерегулярных файлов — сокеты/каналы/символьные устройства/файлы procfs/sysfs могут передавать неограниченные потоки данных, несмотря на
stats.size === 0. Встроенный путь выбрасывает исключение сdescribeStatKind(stats)в сообщении. - Избегать неограниченной буферизации всего файла. Встроенный фолбэк ограничивает буферизованное чтение
READ_FILE_SIZE_CAP = 100 МиБ. Внедрённый адаптер вместо этого применяет более строгий контракт WorkspaceFileSystem: полные снимки останавливаются на 256 КиБ, тогда как для больших UTF-8 файлов требуется конечныйlimit, и они потоково читаются из привязанного к inode handles с возвратом не более 256 КиБ. Адаптер не должен читать весь 500-мегабайтный лог только для того, чтобы вернуть{ line: 1, limit: 10 }.
Адаптер идет дальше: он использует WorkspaceFileSystem.writeTextOverwrite (примитив PR 18) для записей рабочего пространства и эквивалент, принадлежащий фабрике, для строго помеченных внешних записей встроенных инструментов. Оба используют атомарные записи во временный файл с переименованием с сохранением режима, значением по умолчанию 0o600 и отклонением символьных ссылок в рамках общей блокировки канонического пути. Это отличие от встроенного прокси до F1, который разрешал символьные ссылки и записывал через них в целевой файл — агенты, которые полагались на запись через символьные ссылки в dot-файлы, теперь должны обращаться непосредственно к разрешенному пути.
Сохранение FsError через ACP-проводку
Когда адаптер BridgeFileSystem выбрасывает FsError (kind: 'untrusted_workspace' / 'symlink_escape' / 'file_too_large' / и т.д.), стандартный RPC-путь ошибок ACP SDK сериализует только error.message как общую ошибку -32603 "Internal error" — kind / status / hint отбрасываются. Клиенту RPC агента пришлось бы сопоставлять человекочитаемое сообщение с регулярным выражением, чтобы диспетчеризовать типизированные действия (повтор аутентификации, выбор файла, подсказка прокси).
BridgeClient.writeTextFile и BridgeClient.readTextFile устанавливают тонкую защиту (packages/acp-bridge/src/bridgeClient.ts), которая перехватывает исключения, похожие на FsError, и пробрасывает их как ACP RequestError:
function isFsErrorShape(err: unknown): err is FsErrorShape {
return (
err instanceof Error &&
err.name === 'FsError' &&
typeof (err as { kind?: unknown }).kind === 'string'
);
}
function preserveFsErrorOverAcp(err: unknown): never {
if (isFsErrorShape(err)) {
throw new RequestError(-32603, err.message, {
errorKind: err.kind,
...(err.hint !== undefined ? { hint: err.hint } : {}),
...(err.status !== undefined ? { status: err.status } : {}),
});
}
throw err;
}Теперь клиент RPC агента получает data.errorKind (замкнутое значение FsErrorKind) и опциональные data.hint и data.status, так что потребители SDK могут ветвиться по типизированному перечислению вместо сопоставления с регулярным выражением по сообщению.
Два замечания по дизайну:
- Утиная типизация вместо импорта —
FsErrorнаходится вpackages/cli/src/serve/fs/errors.ts, аBridgeClient— вpackages/acp-bridge. Прямойimport { FsError }инвертировал бы зависимость. Проверка через «утку» (name === 'FsError'+kind: string) повторяет то, чтоmapDomainErrorToErrorKind(status.ts) уже делает дляTrustGateError/SkillErrorпо той же причине кросс-пакетной сборки. - Код JSON-RPC остается -32603 — мост не может надежно сопоставить
FsError.kindс формой кода ошибки JSON-RPC, поэтому структурированное полеdataнесет семантическую информацию для потребителей SDK. Код статуса проводки (-32603“internal error”) не изменяется; клиенты маршрутизируют на основеdata.errorKind.
Шлюз доверия
assertTrustedForIntent(trusted, intent) потребляет булево значение доверия, переданное вызывающей стороной; уровень политики не читает Config.isTrustedFolder() напрямую. Чтение/вывод списка/статистика/поиск по шаблону всегда разрешены (доверие требуется только для записи). Попытки записи в недоверенных рабочих областях выбрасывают FsError('untrusted_workspace', ..., status: 403). Сигнал доверия поступает через WorkspaceFileSystemFactoryDeps.trusted: boolean — runQwenServe передает true, потому что оператор запустил демона для рабочей области, которой неявно доверяет; createServeApp (прямое встраивание без runQwenServe) по умолчанию устанавливает false и выводит предупреждение один раз на процесс (см. 02-serve-runtime.md).
Рабочий процесс
Чтение
readText не пропускает и не отклоняет чтение из-за правил игнорирования. Он читает файл в обычном режиме и записывает соответствующую классификацию игнорирования в meta.matchedIgnore. list и glob фильтруют игнорируемые результаты только если includeIgnored не включено.
Запись
Атомарная запись с переименованием гарантирует, что SIGKILL / OOM в середине записи НЕ оставит целевой файл обрезанным. Режим 'create' прерывается с file_already_exists при lstat; режим 'overwrite' продолжается; expectedSha256 включает оптимистичную блокировку (hash_mismatch при несовпадении).
POST /file/edit (однократная замена текста)
Добавляет два дополнительных режима сбоя поверх записи:
text_not_found(422) — строка поиска не найдена в файле.ambiguous_text_match(422) — несколько совпадений, когда требовалось ровно одно (контракт маршрута).
Разветвление аудита
FS_ACCESS_EVENT_TYPE / FS_DENIED_EVENT_TYPE содержат контекст (ctx), путь, намерение, результат, errorKind?, прочитано/записано байт, sha256?.
Состояние и жизненный цикл
- Фабрика создается один раз при запуске демона (
runQwenServe→resolveBridgeFsFactory→ адаптер). - Каждый запрос создает
RequestContextи вызывает оркестратор фабрики только для этого вызова — долгоживущего состояния на файл нет. - Блокировки на путь живут только на время операции записи (без блокировок между вызовами; одновременные записи в один и тот же путь конкурируют за блокировку и сериализуются).
- Кольцо аудита принадлежит
runQwenServeи используется совместно с издателем аудита разрешений.
Зависимости
@qwen-code/qwen-code-core—Ignore,isBinaryFile,Config.isTrustedFolder().node:fs,node:path,node:crypto.@qwen-code/acp-bridge— контрактBridgeFileSystemна стороне ACP.- HTTP-маршруты:
packages/cli/src/serve/routes/workspace-file-read.ts,workspace-file-write.ts.
Конфигурация
| Источник | Параметр | Эффект |
|---|---|---|
WorkspaceFileSystemFactoryDeps.trusted: boolean | Входной параметр конструктора | Разрешена ли запись; по умолчанию true от runQwenServe, false от createServeApp (с предупреждением). |
| Константа | MAX_READ_BYTES = 256 КиБ | Лимит полного снимка и возвращаемого текста; больший текст требует явного аргумента окна. |
| Константа | MAX_TEXT_SCAN_BYTES = 8 МиБ | Количество байт, которое может просканировать чтение большого текста для поиска смещения строки; при превышении — file_too_large. |
| Константа | MAX_WRITE_BYTES = 5 МиБ | Лимит записи; размер меньше express.json({ limit: '10mb' }). |
| Константа | MAX_UPLOAD_BYTES = 50 МиБ | Лимит загрузки двоичных файлов для POST /file/upload; загрузки никогда не перезаписывают и автоматически нумеруют занятые имена. |
| Константа | BINARY_PROBE_BYTES = 4096 | Размер выборки для определения двоичного содержимого. |
| Теги возможностей | workspace_file_read, workspace_file_bytes, workspace_file_write, workspace_file_upload | См. 11-capabilities-versioning.md. |
| Файлы рабочей области | .gitignore, .qwenignore | Игнорируемые пути отмечаются как ignored: true от shouldIgnore. |
Оговорки и известные ограничения
- Символические ссылки отклоняются, а не обрабатываются. Это отличие от встроенного прокси
BridgeClient.writeTextFileдо F1, который разрешал символьные ссылки. Агентам, записывающим данные через символические ссылки в dot-файлы, необходимо обращаться непосредственно к разрешенному пути. io_errorиpermission_deniedразличимы. Не путайте их. Пайплайны мониторинга используютerrorKindдля оповещения — сведение ENOSPC к permission_denied вызовет вызов команды реагирования на проблемы сdf -h.- Режим нового файла по умолчанию —
0o600, а не по умолчанию umask. Аргументmodeсистемного вызова записи обходит umask. Агенты не могут передать переопределение режима для каждой записи. Операторы, которые хотят, чтобы файлы, созданные агентом, следовали umask демона, могут включить это для каждого демона с помощьюQWEN_SERVE_NEW_FILE_MODE=system(существующие файлы всё ещё сохраняют свой режим); см.17-configuration.md. createServeAppпо умолчаниюtrusted: falseмолча отклоняет ACP-записи сuntrusted_workspaceдля встраивающих систем, которые не внедряют собственнуюfsFactoryилиbridge. Одноразовое предупреждение в stderr выводится при первом вызове; последующие вызывающие не увидят напоминания. См.02-serve-runtime.md.- Большой текст требует явного аргумента окна — любого из
line/limit/maxBytes. Чтение без них остаётсяfile_too_large, потому что вызывающий, который считает, что владеет всем файлом, может записать его обратно обрезанным. Окна потоково читаются из привязанного к inode handle и никогда не возвращают большеMAX_READ_BYTES. MAX_READ_BYTESограничивает то, что возвращает чтение;MAX_TEXT_SCAN_BYTESограничивает то, что оно стоит. Смещения строк разрешаются сканированием от байта 0, поэтому{ line: 900_000_000, limit: 20 }возвращает почти ничего и всё равно проходит по файлу. После 8 МиБ сканирования чтение отклоняется сfile_too_large, указывающим наreadBytes, который достигает любого смещения за O(1).- Потоковые окна допускают добавления, но не усечение. Путь полного снимка может требовать побайтовую стабильность, потому что он возвращает весь файл; окно префикса не может, иначе каждое чтение живого лога завершается сбоем. Потоковый путь утверждает идентичность inode плюс “не уменьшался”, поэтому добавления проходят, а усечение / замена всё ещё отклоняются.
sizeBytesсообщает размер приopen, описывая снимок, из которого было вырезано окно. - Большие частичные чтения опускают хэш полного файла.
originalLineCountопускается, когда потоковая передача останавливается до EOF. - Пейджинг выполняется по байтовому курсору, а не по строке. Чтение, которое оставляет содержимое, возвращает
hasMoreи, где байтовое смещение выводимо, непрозрачныйnextCursor. Возобновление от него — O(1); возобновление поlineповторно сканирует от байта 0 и отклоняется послеMAX_TEXT_SCAN_BYTES. Курсор несет{dev, ino, size}, поэтому заменённый или усечённый файл даётhash_mismatchвместо байт из неправильного места, тогда как добавление оставляет его действительным. Чтения снимков не-UTF-8 сообщаютhasMore, но без курсора — их декодированный текст является перекодировкой UTF-8, длины которой не отображаются обратно в файловые смещения. BridgeFileSystemадаптер ДОЛЖЕН воспроизвести оба шлюза встроенного прокси (отказ от нерегулярных файлов + ограниченная буферизация/потоковая передача). Встроенный путь полностью обходится при внедрении адаптера.
Ссылки
packages/cli/src/serve/fs/index.ts(barrel)packages/cli/src/serve/fs/paths.tspackages/cli/src/serve/fs/policy.tspackages/cli/src/serve/fs/errors.tspackages/cli/src/serve/fs/audit.tspackages/cli/src/serve/fs/workspace-file-system.tspackages/cli/src/serve/bridge-file-system-adapter.tspackages/acp-bridge/src/bridgeFileSystem.ts- HTTP route reference:
../qwen-serve-protocol.md.