@bridge4dev/runner 0.42.0 → 0.44.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude-usage.d.ts +35 -4
- package/dist/adapters/claude-usage.js +138 -14
- package/dist/adapters/claude.js +218 -12
- package/dist/adapters/codex.js +46 -0
- package/dist/adapters/error-policy.d.ts +178 -0
- package/dist/adapters/error-policy.js +370 -0
- package/dist/adapters/rate-limits.d.ts +22 -0
- package/dist/adapters/rate-limits.js +24 -0
- package/dist/adapters/types.d.ts +30 -0
- package/dist/claude-settings.d.ts +107 -0
- package/dist/claude-settings.js +415 -0
- package/dist/index.js +88 -1
- package/dist/recipe-schema.d.ts +6 -6
- package/dist/regex-guard-hook.d.ts +3 -0
- package/dist/regex-guard-hook.js +48 -0
- package/dist/regex-guard.d.ts +86 -0
- package/dist/regex-guard.js +359 -0
- package/dist/supervisor.d.ts +16 -0
- package/dist/supervisor.js +151 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Безопасное слияние привратника в `~/.claude/settings.json`.
|
|
3
|
+
*
|
|
4
|
+
* Этот файл принадлежит РАЗРАБОТЧИКУ, а не нам: там его модель, тема, плагины и
|
|
5
|
+
* его собственные хуки. Мы добавляем ровно один ключ и обязаны уметь снять ровно
|
|
6
|
+
* его. Требования целиком — `docs/plans/active/claude-code-regex-guard.md` §4.
|
|
7
|
+
*
|
|
8
|
+
* Прецедент в проекте — `merge-mcp.cjs` в установщике: он тоже сливает наше в
|
|
9
|
+
* чужой файл и тоже отказывается работать с тем, что не разбирается.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* ВЫКЛЮЧАТЕЛЬ ПРИВРАТНИКА — одна строка, которой всё снимается.
|
|
13
|
+
*
|
|
14
|
+
* Поставь `false`, выпусти раннер — и каждая машина при первом же запуске демона
|
|
15
|
+
* СНИМЕТ запись из `~/.claude/settings.json` и вернёт файл как было. Ничего
|
|
16
|
+
* больше менять не нужно: ни установщик, ни настройки на машинах, ни руками.
|
|
17
|
+
*
|
|
18
|
+
* Когда это делать и как сначала проверить, что дефект действительно починили, —
|
|
19
|
+
* тикет «Привратник шаблонов поиска» и `docs/plans/active/claude-code-regex-guard.md` §3.3.
|
|
20
|
+
*/
|
|
21
|
+
export declare const SEARCH_GUARD_ENABLED = true;
|
|
22
|
+
/** По этой строке наша запись опознаётся при снятии. Меняя её, сломаешь удаление. */
|
|
23
|
+
export declare const GUARD_MARKER = "devbridge-regex-guard";
|
|
24
|
+
/** Версия записи. Растёт, когда меняется правило или путь до хука. */
|
|
25
|
+
export declare const GUARD_VERSION = 1;
|
|
26
|
+
export interface MergeResult {
|
|
27
|
+
text: string;
|
|
28
|
+
/** Файл действительно изменился. `false` — переписывать нечего. */
|
|
29
|
+
changed: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** Добавляет привратника, не трогая ничего чужого. */
|
|
32
|
+
export declare function mergeGuardIntoSettings(text: string, command: string, nodeExists?: (p: string) => boolean): MergeResult;
|
|
33
|
+
/** Снимает привратника и возвращает файл к прежнему виду. */
|
|
34
|
+
export declare function removeGuardFromSettings(text: string): MergeResult;
|
|
35
|
+
/**
|
|
36
|
+
* Чей `~/.claude` мы правим.
|
|
37
|
+
*
|
|
38
|
+
* Через `systemdUserHome()`, а не `os.homedir()` напрямую — потому что раннер сюда
|
|
39
|
+
* ПИШЕТ. `paths.ts` описывает ровно эту ловушку: `service-unit.ts` когда-то брал
|
|
40
|
+
* домашний каталог напрямую, и первый же тестовый прогон начал переписывать
|
|
41
|
+
* сервис самого разработчика. Здесь цена ошибки такая же — чужой файл настроек.
|
|
42
|
+
*/
|
|
43
|
+
export declare function searchGuardHome(): string;
|
|
44
|
+
/** Путь к файлу настроек Claude Code. `home` подменяется в тестах. */
|
|
45
|
+
export declare function claudeSettingsPath(home: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* Путь к исполняемому привратнику рядом с самим раннером.
|
|
48
|
+
*
|
|
49
|
+
* Считается от запущенного файла, а не зашивается: префикс установки бывает
|
|
50
|
+
* `/usr`, `/usr/local` и домашний, и жёсткий путь развалился бы на первой же
|
|
51
|
+
* машине с другим префиксом.
|
|
52
|
+
*
|
|
53
|
+
* `realpathSync` здесь обязателен, а не «на всякий случай». Глобальная установка
|
|
54
|
+
* npm кладёт в `bin` СИМЛИНК:
|
|
55
|
+
*
|
|
56
|
+
* /usr/bin/devbridge-runner -> ../lib/node_modules/@bridge4dev/runner/dist/index.js
|
|
57
|
+
*
|
|
58
|
+
* а `process.argv[1]` отдаёт именно симлинк — node его не разворачивает
|
|
59
|
+
* (проверено на живой машине). Без разворачивания путь считался бы как
|
|
60
|
+
* `/usr/bin/regex-guard-hook.js`, файла там нет, и привратник МОЛЧА не
|
|
61
|
+
* установился бы ни на одной реальной машине. Тот же приём и по той же причине
|
|
62
|
+
* применяет `unitExecTarget` в `service-unit.ts`.
|
|
63
|
+
*/
|
|
64
|
+
export declare function searchGuardHookPath(entry?: string): string;
|
|
65
|
+
/**
|
|
66
|
+
* Готовая команда для настроек Claude Code: node плюс путь к хуку.
|
|
67
|
+
*
|
|
68
|
+
* Через `node`, а не файлом напрямую, по измеренной причине: `tsc` не ставит бит
|
|
69
|
+
* исполняемости на выходные файлы, поэтому прямой запуск отдаёт
|
|
70
|
+
* «Permission denied». Claude Code при этом НЕ блокирует команду — он просто
|
|
71
|
+
* считает хук сбойным, и машина молча остаётся без защиты.
|
|
72
|
+
*
|
|
73
|
+
* `process.execPath` — тот самый node, которым запущен раннер. Голое `node`
|
|
74
|
+
* зависело бы от PATH оболочки, в которой Claude Code исполняет хук, а он там
|
|
75
|
+
* не обязан быть (nvm, fnm, systemd-юнит с урезанным окружением).
|
|
76
|
+
*/
|
|
77
|
+
export declare function searchGuardCommand(entry?: string): string;
|
|
78
|
+
type Warn = (message: string) => void;
|
|
79
|
+
/**
|
|
80
|
+
* Ставит привратника в настройки Claude Code. Возвращает `false`, когда
|
|
81
|
+
* менять нечего или файл трогать нельзя.
|
|
82
|
+
*
|
|
83
|
+
* Отказ никогда не является аварией: раннер обязан подняться и работать даже
|
|
84
|
+
* на машине, где файл настроек сломан руками.
|
|
85
|
+
*/
|
|
86
|
+
export declare function installSearchGuard(hookPath: string, home: string, warn?: Warn): boolean;
|
|
87
|
+
/** Снимает привратника. Возвращает `false`, если снимать было нечего. */
|
|
88
|
+
export declare function removeSearchGuard(home: string, warn?: Warn): boolean;
|
|
89
|
+
export interface SearchGuardStatus {
|
|
90
|
+
path: string;
|
|
91
|
+
/** Файл настроек прочитан и разобран. `false` — нет файла либо он сломан. */
|
|
92
|
+
settingsReadable: boolean;
|
|
93
|
+
/** Наша запись присутствует. */
|
|
94
|
+
installed: boolean;
|
|
95
|
+
/** Версия установленной записи; `null`, если её нет или прочитать не вышло. */
|
|
96
|
+
version: number | null;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Состояние привратника на этой машине — то, что печатает `doctor`.
|
|
100
|
+
*
|
|
101
|
+
* Существует ради проверки «дефект починили, можно снимать»: чтобы через месяц
|
|
102
|
+
* можно было пройтись по паркам и увидеть, где привратник ещё стоит, а где уже
|
|
103
|
+
* снят, не разбирая JSON руками.
|
|
104
|
+
*/
|
|
105
|
+
export declare function searchGuardStatus(home: string): SearchGuardStatus;
|
|
106
|
+
export {};
|
|
107
|
+
//# sourceMappingURL=claude-settings.d.ts.map
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { systemdUserHome } from './paths.js';
|
|
4
|
+
/**
|
|
5
|
+
* Безопасное слияние привратника в `~/.claude/settings.json`.
|
|
6
|
+
*
|
|
7
|
+
* Этот файл принадлежит РАЗРАБОТЧИКУ, а не нам: там его модель, тема, плагины и
|
|
8
|
+
* его собственные хуки. Мы добавляем ровно один ключ и обязаны уметь снять ровно
|
|
9
|
+
* его. Требования целиком — `docs/plans/active/claude-code-regex-guard.md` §4.
|
|
10
|
+
*
|
|
11
|
+
* Прецедент в проекте — `merge-mcp.cjs` в установщике: он тоже сливает наше в
|
|
12
|
+
* чужой файл и тоже отказывается работать с тем, что не разбирается.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* ВЫКЛЮЧАТЕЛЬ ПРИВРАТНИКА — одна строка, которой всё снимается.
|
|
16
|
+
*
|
|
17
|
+
* Поставь `false`, выпусти раннер — и каждая машина при первом же запуске демона
|
|
18
|
+
* СНИМЕТ запись из `~/.claude/settings.json` и вернёт файл как было. Ничего
|
|
19
|
+
* больше менять не нужно: ни установщик, ни настройки на машинах, ни руками.
|
|
20
|
+
*
|
|
21
|
+
* Когда это делать и как сначала проверить, что дефект действительно починили, —
|
|
22
|
+
* тикет «Привратник шаблонов поиска» и `docs/plans/active/claude-code-regex-guard.md` §3.3.
|
|
23
|
+
*/
|
|
24
|
+
export const SEARCH_GUARD_ENABLED = true;
|
|
25
|
+
/** По этой строке наша запись опознаётся при снятии. Меняя её, сломаешь удаление. */
|
|
26
|
+
export const GUARD_MARKER = 'devbridge-regex-guard';
|
|
27
|
+
/**
|
|
28
|
+
* Пометка «контейнер `hooks` в этом файле создали мы».
|
|
29
|
+
*
|
|
30
|
+
* Без неё снятие приходилось выбирать между двумя потерями: либо удалять
|
|
31
|
+
* опустевший `hooks` — и тогда пропадал чужой пустой `"hooks": {}`, стоявший там
|
|
32
|
+
* до нас; либо не удалять — и тогда на машине, где файла не было вовсе, после
|
|
33
|
+
* снятия оставался `{"hooks":{}}` вместо `{}` (QA MEDIUM-7). Пометка снимает
|
|
34
|
+
* выбор: удаляем ровно то, что сами и завели.
|
|
35
|
+
*/
|
|
36
|
+
const OWNS_HOOKS = 'owns-hooks';
|
|
37
|
+
/** Версия записи. Растёт, когда меняется правило или путь до хука. */
|
|
38
|
+
export const GUARD_VERSION = 1;
|
|
39
|
+
/** На какие инструменты Claude Code вешается привратник. */
|
|
40
|
+
const GUARD_MATCHER = 'Bash|Grep';
|
|
41
|
+
/**
|
|
42
|
+
* Потолок времени на хук. Привратник — чистый разбор строки, ему хватает
|
|
43
|
+
* миллисекунд; секунды здесь на случай холодного старта node.
|
|
44
|
+
*/
|
|
45
|
+
const GUARD_TIMEOUT_SEC = 10;
|
|
46
|
+
function detectFormat(text) {
|
|
47
|
+
const indented = text.split('\n').find((l) => /^\s+\S/.test(l));
|
|
48
|
+
const lead = indented?.match(/^\s+/)?.[0] ?? '';
|
|
49
|
+
// Таб считать «одним пробелом» — значит переформатировать весь чужой файл при
|
|
50
|
+
// снятии привратника (QA MEDIUM-7). `JSON.stringify` принимает строку отступа,
|
|
51
|
+
// так что таб отдаётся как таб.
|
|
52
|
+
const indent = lead.startsWith('\t') ? '\t' : lead.length > 0 ? lead.length : 2;
|
|
53
|
+
return { indent, trailingNewline: text.endsWith('\n') };
|
|
54
|
+
}
|
|
55
|
+
function serialize(value, format) {
|
|
56
|
+
return JSON.stringify(value, null, format.indent) + (format.trailingNewline ? '\n' : '');
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Разбирает файл настроек. Всё, что не объект, — отказ.
|
|
60
|
+
*
|
|
61
|
+
* Отказ, а не «починим»: пустой или сломанный файл настроек чинится
|
|
62
|
+
* разработчиком, а перезапись поверх непонятного содержимого — это ровно тот
|
|
63
|
+
* способ потерять чужую работу, от которого написан весь этот модуль.
|
|
64
|
+
*/
|
|
65
|
+
function parseSettings(text) {
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(text);
|
|
69
|
+
}
|
|
70
|
+
catch (cause) {
|
|
71
|
+
throw new Error('settings.json does not parse — left untouched', { cause });
|
|
72
|
+
}
|
|
73
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
74
|
+
throw new Error('settings.json is not an object — left untouched');
|
|
75
|
+
}
|
|
76
|
+
return parsed;
|
|
77
|
+
}
|
|
78
|
+
function guardCommand(hookPath, ownsHooks = false) {
|
|
79
|
+
// Маркер — обычный комментарий оболочки: он виден в файле, не меняет
|
|
80
|
+
// выполнение команды и не требует от схемы настроек знать наши поля.
|
|
81
|
+
return `${hookPath} # ${GUARD_MARKER}:${GUARD_VERSION}${ownsHooks ? ` ${OWNS_HOOKS}` : ''}`;
|
|
82
|
+
}
|
|
83
|
+
function isOurs(group) {
|
|
84
|
+
return (group.hooks ?? []).some((h) => typeof h.command === 'string' && h.command.includes(GUARD_MARKER));
|
|
85
|
+
}
|
|
86
|
+
/** Команда нашей записи, если она в этой группе есть. */
|
|
87
|
+
function ourCommand(group) {
|
|
88
|
+
return (group.hooks ?? []).find((h) => h.command?.includes(GUARD_MARKER))?.command ?? null;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Достаточно ли свежа уже стоящая запись, чтобы её не переписывать?
|
|
92
|
+
*
|
|
93
|
+
* Сравнение НЕ побайтовое, и это важно. Команда содержит путь к `node`
|
|
94
|
+
* (`process.execPath`), а на машине с nvm/fnm демон и запущенный из оболочки
|
|
95
|
+
* `doctor` легко работают разными интерпретаторами. Побайтовое сравнение
|
|
96
|
+
* заставляло бы их переписывать ЧУЖОЙ файл по очереди на каждом запуске, засыпая
|
|
97
|
+
* каталог разработчика резервными копиями.
|
|
98
|
+
*
|
|
99
|
+
* Значение имеют только путь к хуку и версия. Но если записанный `node` исчез
|
|
100
|
+
* (снесли ту версию nvm), запись переписать НАДО: иначе хук молча не запускается
|
|
101
|
+
* и машина остаётся без защиты, а `doctor` продолжает показывать «installed».
|
|
102
|
+
*/
|
|
103
|
+
function guardEntryIsCurrent(existingCommand, desiredCommand, nodeExists) {
|
|
104
|
+
const parse = (command) => {
|
|
105
|
+
const [invocation = '', tail = ''] = command.split(` # ${GUARD_MARKER}:`);
|
|
106
|
+
// Версия — первое слово после маркера; за ней может стоять пометка
|
|
107
|
+
// `owns-hooks`, и на «свежесть» записи она не влияет.
|
|
108
|
+
const version = tail.split(' ')[0] ?? '';
|
|
109
|
+
// Основная форма — оба пути в одинарных кавычках. Разбор по кавычкам, а не по
|
|
110
|
+
// последнему пробелу: путь с пробелом иначе рвался посередине, и запись
|
|
111
|
+
// считалась устаревшей на КАЖДОМ запуске (QA MEDIUM-6).
|
|
112
|
+
const quoted = invocation.match(/^'([^']*)'\s+'([^']*)'$/);
|
|
113
|
+
if (quoted)
|
|
114
|
+
return { node: quoted[1] ?? '', hook: quoted[2] ?? '', version };
|
|
115
|
+
const cut = invocation.lastIndexOf(' ');
|
|
116
|
+
// Без кавычек и без пробела — просто путь к хуку. Так задают тесты и так
|
|
117
|
+
// выглядели бы записи, дописанные руками.
|
|
118
|
+
return cut < 0
|
|
119
|
+
? { node: '', hook: invocation, version }
|
|
120
|
+
: { node: invocation.slice(0, cut), hook: invocation.slice(cut + 1), version };
|
|
121
|
+
};
|
|
122
|
+
const was = parse(existingCommand);
|
|
123
|
+
const wants = parse(desiredCommand);
|
|
124
|
+
if (was.hook !== wants.hook || was.version !== wants.version)
|
|
125
|
+
return false;
|
|
126
|
+
// Интерпретатор в записи не указан — сравнивать нечего, запись считается свежей.
|
|
127
|
+
return was.node === '' || nodeExists(was.node);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* `hooks` как объект — или отказ.
|
|
131
|
+
*
|
|
132
|
+
* Без проверки спред строки давал `{ "0":".", "1":"/", … }`, то есть ЧУЖОЕ
|
|
133
|
+
* значение уничтожалось (QA HIGH-3). Дисциплина та же, что у `parseSettings`:
|
|
134
|
+
* что не понимаем — не трогаем.
|
|
135
|
+
*/
|
|
136
|
+
function asHooksObject(value) {
|
|
137
|
+
if (value === undefined || value === null)
|
|
138
|
+
return {};
|
|
139
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
140
|
+
throw new Error('settings.json has a "hooks" value we do not understand — left untouched');
|
|
141
|
+
}
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
/** Добавляет привратника, не трогая ничего чужого. */
|
|
145
|
+
export function mergeGuardIntoSettings(text, command, nodeExists = (p) => fs.existsSync(p)) {
|
|
146
|
+
const format = detectFormat(text);
|
|
147
|
+
const settings = parseSettings(text);
|
|
148
|
+
const hooksExisted = settings['hooks'] !== undefined && settings['hooks'] !== null;
|
|
149
|
+
const hooks = { ...asHooksObject(settings['hooks']) };
|
|
150
|
+
const existing = hooks['PreToolUse'] ?? [];
|
|
151
|
+
const foreign = existing.filter((g) => !isOurs(g));
|
|
152
|
+
const mine = existing.filter(isOurs);
|
|
153
|
+
// «Наш» контейнер один раз и навсегда: на втором запуске `hooks` уже есть —
|
|
154
|
+
// потому что его завели мы, — и потерять этот факт значило бы оставить след.
|
|
155
|
+
const ownsHooks = (ourCommand(mine[0] ?? {}) ?? '').includes(OWNS_HOOKS) || !hooksExisted;
|
|
156
|
+
const ours = {
|
|
157
|
+
matcher: GUARD_MATCHER,
|
|
158
|
+
hooks: [
|
|
159
|
+
{ type: 'command', command: guardCommand(command, ownsHooks), timeout: GUARD_TIMEOUT_SEC },
|
|
160
|
+
],
|
|
161
|
+
};
|
|
162
|
+
const alreadyCurrent = mine.length === 1 &&
|
|
163
|
+
guardEntryIsCurrent(ourCommand(mine[0]) ?? '', guardCommand(command, ownsHooks), nodeExists);
|
|
164
|
+
if (alreadyCurrent)
|
|
165
|
+
return { text, changed: false };
|
|
166
|
+
// Наша запись идёт последней: чужие проверки должны отработать раньше нашей.
|
|
167
|
+
hooks['PreToolUse'] = [...foreign, ours];
|
|
168
|
+
return { text: serialize({ ...settings, hooks }, format), changed: true };
|
|
169
|
+
}
|
|
170
|
+
/** Снимает привратника и возвращает файл к прежнему виду. */
|
|
171
|
+
export function removeGuardFromSettings(text) {
|
|
172
|
+
const format = detectFormat(text);
|
|
173
|
+
const settings = parseSettings(text);
|
|
174
|
+
const hooksValue = settings['hooks'];
|
|
175
|
+
if (!hooksValue)
|
|
176
|
+
return { text, changed: false };
|
|
177
|
+
const existing = hooksValue['PreToolUse'] ?? [];
|
|
178
|
+
const foreign = existing.filter((g) => !isOurs(g));
|
|
179
|
+
if (foreign.length === existing.length)
|
|
180
|
+
return { text, changed: false };
|
|
181
|
+
const hooks = { ...hooksValue };
|
|
182
|
+
// Пустой массив — тоже след, убираем ключ целиком.
|
|
183
|
+
if (foreign.length === 0)
|
|
184
|
+
delete hooks['PreToolUse'];
|
|
185
|
+
else
|
|
186
|
+
hooks['PreToolUse'] = foreign;
|
|
187
|
+
const next = { ...settings };
|
|
188
|
+
// Опустевший `hooks` удаляем ТОЛЬКО если его завели мы — это записано
|
|
189
|
+
// пометкой в самой записи. Чужой пустой `"hooks": {}` остаётся на месте.
|
|
190
|
+
const weOwnHooks = existing
|
|
191
|
+
.filter(isOurs)
|
|
192
|
+
.some((g) => (ourCommand(g) ?? '').includes(OWNS_HOOKS));
|
|
193
|
+
if (Object.keys(hooks).length === 0 && weOwnHooks)
|
|
194
|
+
delete next['hooks'];
|
|
195
|
+
else
|
|
196
|
+
next['hooks'] = hooks;
|
|
197
|
+
return { text: serialize(next, format), changed: true };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Чей `~/.claude` мы правим.
|
|
201
|
+
*
|
|
202
|
+
* Через `systemdUserHome()`, а не `os.homedir()` напрямую — потому что раннер сюда
|
|
203
|
+
* ПИШЕТ. `paths.ts` описывает ровно эту ловушку: `service-unit.ts` когда-то брал
|
|
204
|
+
* домашний каталог напрямую, и первый же тестовый прогон начал переписывать
|
|
205
|
+
* сервис самого разработчика. Здесь цена ошибки такая же — чужой файл настроек.
|
|
206
|
+
*/
|
|
207
|
+
export function searchGuardHome() {
|
|
208
|
+
return systemdUserHome();
|
|
209
|
+
}
|
|
210
|
+
/** Путь к файлу настроек Claude Code. `home` подменяется в тестах. */
|
|
211
|
+
export function claudeSettingsPath(home) {
|
|
212
|
+
return path.join(home, '.claude', 'settings.json');
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Путь к исполняемому привратнику рядом с самим раннером.
|
|
216
|
+
*
|
|
217
|
+
* Считается от запущенного файла, а не зашивается: префикс установки бывает
|
|
218
|
+
* `/usr`, `/usr/local` и домашний, и жёсткий путь развалился бы на первой же
|
|
219
|
+
* машине с другим префиксом.
|
|
220
|
+
*
|
|
221
|
+
* `realpathSync` здесь обязателен, а не «на всякий случай». Глобальная установка
|
|
222
|
+
* npm кладёт в `bin` СИМЛИНК:
|
|
223
|
+
*
|
|
224
|
+
* /usr/bin/devbridge-runner -> ../lib/node_modules/@bridge4dev/runner/dist/index.js
|
|
225
|
+
*
|
|
226
|
+
* а `process.argv[1]` отдаёт именно симлинк — node его не разворачивает
|
|
227
|
+
* (проверено на живой машине). Без разворачивания путь считался бы как
|
|
228
|
+
* `/usr/bin/regex-guard-hook.js`, файла там нет, и привратник МОЛЧА не
|
|
229
|
+
* установился бы ни на одной реальной машине. Тот же приём и по той же причине
|
|
230
|
+
* применяет `unitExecTarget` в `service-unit.ts`.
|
|
231
|
+
*/
|
|
232
|
+
export function searchGuardHookPath(entry = process.argv[1] ?? '') {
|
|
233
|
+
let script;
|
|
234
|
+
try {
|
|
235
|
+
script = fs.realpathSync(entry);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// Путь не существует (тесты, странная установка) — работаем с тем, что дали.
|
|
239
|
+
script = path.resolve(entry);
|
|
240
|
+
}
|
|
241
|
+
return path.join(path.dirname(script), 'regex-guard-hook.js');
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Готовая команда для настроек Claude Code: node плюс путь к хуку.
|
|
245
|
+
*
|
|
246
|
+
* Через `node`, а не файлом напрямую, по измеренной причине: `tsc` не ставит бит
|
|
247
|
+
* исполняемости на выходные файлы, поэтому прямой запуск отдаёт
|
|
248
|
+
* «Permission denied». Claude Code при этом НЕ блокирует команду — он просто
|
|
249
|
+
* считает хук сбойным, и машина молча остаётся без защиты.
|
|
250
|
+
*
|
|
251
|
+
* `process.execPath` — тот самый node, которым запущен раннер. Голое `node`
|
|
252
|
+
* зависело бы от PATH оболочки, в которой Claude Code исполняет хук, а он там
|
|
253
|
+
* не обязан быть (nvm, fnm, systemd-юнит с урезанным окружением).
|
|
254
|
+
*/
|
|
255
|
+
export function searchGuardCommand(entry = process.argv[1] ?? '') {
|
|
256
|
+
// Кавычки обязательны: путь установки может содержать пробел, и без них
|
|
257
|
+
// оболочка выполнит `node /home/dev` вместо хука — молча, а `doctor` при этом
|
|
258
|
+
// продолжит рапортовать «installed» (QA MEDIUM-6).
|
|
259
|
+
return `'${process.execPath}' '${searchGuardHookPath(entry)}'`;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Запись «через временный файл и переименование».
|
|
263
|
+
*
|
|
264
|
+
* `rename` внутри одной файловой системы атомарен: читатель видит либо старый
|
|
265
|
+
* файл целиком, либо новый целиком. Обычная запись поверх оставила бы при обрыве
|
|
266
|
+
* половину файла — то есть сломанный Claude Code у разработчика.
|
|
267
|
+
*/
|
|
268
|
+
function writeAtomically(target, contents, mode) {
|
|
269
|
+
// Пишем ПО РЕАЛЬНОМУ пути. `settings.json` часто оказывается симлинком на
|
|
270
|
+
// dotfiles-репозиторий, и `rename` поверх симлинка заменил бы саму ссылку
|
|
271
|
+
// обычным файлом: связь с репозиторием рвётся молча, `git status` там чист
|
|
272
|
+
// (QA MEDIUM-4). Временный файл кладём рядом с целью, иначе `rename` уедет
|
|
273
|
+
// через границу файловых систем и перестанет быть атомарным.
|
|
274
|
+
let real = target;
|
|
275
|
+
try {
|
|
276
|
+
real = fs.realpathSync(target);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
// Файла ещё нет — пишем по исходному пути.
|
|
280
|
+
}
|
|
281
|
+
const tmp = path.join(path.dirname(real), `.${path.basename(real)}.devbridge-${process.pid}`);
|
|
282
|
+
try {
|
|
283
|
+
fs.writeFileSync(tmp, contents, { mode });
|
|
284
|
+
// `writeFileSync` пропускает режим через umask (0640 при umask 077 давало
|
|
285
|
+
// 0600), поэтому права выставляем отдельно и уже наверняка (QA LOW-10).
|
|
286
|
+
fs.chmodSync(tmp, mode);
|
|
287
|
+
fs.renameSync(tmp, real);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
try {
|
|
291
|
+
fs.unlinkSync(tmp);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
// Временный файл мог не появиться вовсе — это не отдельная беда.
|
|
295
|
+
}
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** Копия рядом с оригиналом, до любых изменений (§4.4). */
|
|
300
|
+
function backup(target) {
|
|
301
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
302
|
+
// `copyFileSync` читает по ссылке — копия получается настоящая, а не битая.
|
|
303
|
+
fs.copyFileSync(target, `${target}.devbridge-backup-${stamp}`);
|
|
304
|
+
}
|
|
305
|
+
function applyOwnership(target, from) {
|
|
306
|
+
try {
|
|
307
|
+
fs.chownSync(target, from.uid, from.gid);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// Не root — владельца не сменить. Файл и так остаётся своего владельца,
|
|
311
|
+
// потому что мы пишем от его имени; молчим намеренно.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function readExisting(file) {
|
|
315
|
+
try {
|
|
316
|
+
return { text: fs.readFileSync(file, 'utf8'), stats: fs.statSync(file) };
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Ставит привратника в настройки Claude Code. Возвращает `false`, когда
|
|
324
|
+
* менять нечего или файл трогать нельзя.
|
|
325
|
+
*
|
|
326
|
+
* Отказ никогда не является аварией: раннер обязан подняться и работать даже
|
|
327
|
+
* на машине, где файл настроек сломан руками.
|
|
328
|
+
*/
|
|
329
|
+
export function installSearchGuard(hookPath, home, warn = () => { }) {
|
|
330
|
+
const file = claudeSettingsPath(home);
|
|
331
|
+
const existing = readExisting(file);
|
|
332
|
+
let merged;
|
|
333
|
+
try {
|
|
334
|
+
merged = mergeGuardIntoSettings(existing?.text ?? '{}\n', hookPath);
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
warn(`search guard: ${file} left untouched — ${error.message}`);
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
if (!merged.changed)
|
|
341
|
+
return false;
|
|
342
|
+
try {
|
|
343
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
344
|
+
if (existing)
|
|
345
|
+
backup(file);
|
|
346
|
+
writeAtomically(file, merged.text, existing ? existing.stats.mode & 0o777 : 0o600);
|
|
347
|
+
if (existing)
|
|
348
|
+
applyOwnership(file, existing.stats);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
warn(`search guard: could not write ${file} — ${error.message}`);
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
/** Снимает привратника. Возвращает `false`, если снимать было нечего. */
|
|
357
|
+
export function removeSearchGuard(home, warn = () => { }) {
|
|
358
|
+
const file = claudeSettingsPath(home);
|
|
359
|
+
const existing = readExisting(file);
|
|
360
|
+
if (!existing)
|
|
361
|
+
return false;
|
|
362
|
+
let cleaned;
|
|
363
|
+
try {
|
|
364
|
+
cleaned = removeGuardFromSettings(existing.text);
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
warn(`search guard: ${file} left untouched — ${error.message}`);
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
if (!cleaned.changed)
|
|
371
|
+
return false;
|
|
372
|
+
try {
|
|
373
|
+
backup(file);
|
|
374
|
+
writeAtomically(file, cleaned.text, existing.stats.mode & 0o777);
|
|
375
|
+
applyOwnership(file, existing.stats);
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
warn(`search guard: could not write ${file} — ${error.message}`);
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Состояние привратника на этой машине — то, что печатает `doctor`.
|
|
385
|
+
*
|
|
386
|
+
* Существует ради проверки «дефект починили, можно снимать»: чтобы через месяц
|
|
387
|
+
* можно было пройтись по паркам и увидеть, где привратник ещё стоит, а где уже
|
|
388
|
+
* снят, не разбирая JSON руками.
|
|
389
|
+
*/
|
|
390
|
+
export function searchGuardStatus(home) {
|
|
391
|
+
const file = claudeSettingsPath(home);
|
|
392
|
+
const existing = readExisting(file);
|
|
393
|
+
if (!existing)
|
|
394
|
+
return { path: file, settingsReadable: false, installed: false, version: null };
|
|
395
|
+
let settings;
|
|
396
|
+
try {
|
|
397
|
+
settings = parseSettings(existing.text);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return { path: file, settingsReadable: false, installed: false, version: null };
|
|
401
|
+
}
|
|
402
|
+
const groups = settings['hooks']?.['PreToolUse'] ?? [];
|
|
403
|
+
const ours = groups.find(isOurs);
|
|
404
|
+
if (!ours)
|
|
405
|
+
return { path: file, settingsReadable: true, installed: false, version: null };
|
|
406
|
+
const command = (ours.hooks ?? []).map((h) => h.command).find((c) => c?.includes(GUARD_MARKER));
|
|
407
|
+
const version = Number.parseInt(command?.split(`${GUARD_MARKER}:`)[1] ?? '', 10);
|
|
408
|
+
return {
|
|
409
|
+
path: file,
|
|
410
|
+
settingsReadable: true,
|
|
411
|
+
installed: true,
|
|
412
|
+
version: Number.isFinite(version) ? version : null,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
//# sourceMappingURL=claude-settings.js.map
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './
|
|
|
18
18
|
import { RunnerWsClient } from './ws-client.js';
|
|
19
19
|
import { RUNNER_VERSION } from './version.js';
|
|
20
20
|
import { buildUnit, cpuQuotaPercent, limitsOverrideIsOutdated, limitsOverridePath, memoryPolicy, readMemoryFacts, unitExecTarget, unitPath, writeLimitsOverride, LIMITS_VERSION, } from './service-unit.js';
|
|
21
|
+
import { SEARCH_GUARD_ENABLED, claudeSettingsPath, installSearchGuard, removeSearchGuard, searchGuardCommand, searchGuardHome, searchGuardHookPath, searchGuardStatus, } from './claude-settings.js';
|
|
21
22
|
import { readOomKills, recordCrash, takeLastExit } from './crash-note.js';
|
|
22
23
|
import { agentAuthStatuses } from './auth-relay.js';
|
|
23
24
|
import { addSafeDirectory, agentConfigContour, dockerCheck, ensureAgentPath, firstUnreachableAncestor, hasSafeDirectory, inspectPath, knownWorkspacePaths, lingerEnabled, nodeCheck, otherHomeWithAgents, runnerIdentity, safeDirectoryCommand, systemctlHint, systemdUserBusReachable, systemdUserEnv, } from './environment.js';
|
|
@@ -534,6 +535,57 @@ async function repairResourceLimits() {
|
|
|
534
535
|
});
|
|
535
536
|
}
|
|
536
537
|
}
|
|
538
|
+
/**
|
|
539
|
+
* Приводит настройки Claude Code в соответствие с тем, что решает
|
|
540
|
+
* `SEARCH_GUARD_ENABLED`. Одна функция на все три точки вызова — старт демона,
|
|
541
|
+
* `install-service` и `doctor --fix`, — чтобы они не разъехались в поведении.
|
|
542
|
+
*
|
|
543
|
+
* Никогда не бросает: раннер обязан подняться и на машине, где файл настроек
|
|
544
|
+
* сломан руками.
|
|
545
|
+
*/
|
|
546
|
+
function applySearchGuard(report) {
|
|
547
|
+
try {
|
|
548
|
+
if (!SEARCH_GUARD_ENABLED) {
|
|
549
|
+
return removeSearchGuard(searchGuardHome(), report) ? 'removed' : 'unchanged';
|
|
550
|
+
}
|
|
551
|
+
// Раннер может быть запущен из дерева разработки, где `dist` не собран.
|
|
552
|
+
// Запись, указывающая в пустоту, хуже отсутствующей: Claude Code будет
|
|
553
|
+
// звать несуществующий файл перед каждой командой.
|
|
554
|
+
if (!fs.existsSync(searchGuardHookPath()))
|
|
555
|
+
return 'unchanged';
|
|
556
|
+
return installSearchGuard(searchGuardCommand(), searchGuardHome(), report)
|
|
557
|
+
? 'installed'
|
|
558
|
+
: 'unchanged';
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
report(`search guard: ${String(error instanceof Error ? error.message : error)}`);
|
|
562
|
+
return 'unchanged';
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Ставит (или снимает) привратника шаблонов поиска на старте демона.
|
|
567
|
+
*
|
|
568
|
+
* Живёт рядом с `repairResourceLimits` и по тем же правилам: идемпотентно по
|
|
569
|
+
* маркеру, никогда не фатально. Разница в том, что здесь мы пишем в ЧУЖОЙ файл —
|
|
570
|
+
* поэтому любое сомнение решается в пользу «не трогать» (`claude-settings.ts`).
|
|
571
|
+
*
|
|
572
|
+
* СНЯТИЕ на всём парке: `SEARCH_GUARD_ENABLED = false` и выпуск раннера.
|
|
573
|
+
*/
|
|
574
|
+
function repairSearchGuard() {
|
|
575
|
+
const outcome = applySearchGuard((message) => log.warn(`daemon: ${message}`));
|
|
576
|
+
if (outcome === 'installed') {
|
|
577
|
+
log.warn('daemon: search-pattern guard installed into Claude Code settings', {
|
|
578
|
+
settings: claudeSettingsPath(searchGuardHome()),
|
|
579
|
+
command: searchGuardCommand(),
|
|
580
|
+
why: 'docs/standards/project-gotchas.md §345',
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
else if (outcome === 'removed') {
|
|
584
|
+
log.warn('daemon: search-pattern guard removed from Claude Code settings', {
|
|
585
|
+
settings: claudeSettingsPath(searchGuardHome()),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
537
589
|
/**
|
|
538
590
|
* How often the daemon re-measures the machine after the first time.
|
|
539
591
|
*
|
|
@@ -615,6 +667,7 @@ async function cmdDaemon() {
|
|
|
615
667
|
log.info('daemon starting', { version: RUNNER_VERSION, server: config.server.name });
|
|
616
668
|
sweepOrphanedMcpConfigs();
|
|
617
669
|
await repairResourceLimits();
|
|
670
|
+
repairSearchGuard();
|
|
618
671
|
// A Claude token this runner captured through the sign-in relay. Applied
|
|
619
672
|
// BEFORE any adapter exists, because `scrubbedEnv()` copies it out of this
|
|
620
673
|
// process's environment for every session it starts.
|
|
@@ -787,6 +840,16 @@ async function cmdInstallService() {
|
|
|
787
840
|
// service rather than from `/proc/self`, which here is the installing shell.
|
|
788
841
|
writeLimitsOverride(true, undefined, readMemoryFacts(await serviceMemoryCurrent()));
|
|
789
842
|
print(`Wrote ${limitsOverridePath()}`);
|
|
843
|
+
// Привратник шаблонов поиска — та же логика, что и у политики ресурсов:
|
|
844
|
+
// свежая установка должна получить его сразу. Пишем в ЧУЖОЙ файл настроек,
|
|
845
|
+
// поэтому молча пропускаем, если тронуть его нельзя (`claude-settings.ts`).
|
|
846
|
+
const guardOutcome = applySearchGuard(print);
|
|
847
|
+
if (guardOutcome === 'installed') {
|
|
848
|
+
print(`Installed search-pattern guard into ${claudeSettingsPath(searchGuardHome())}`);
|
|
849
|
+
}
|
|
850
|
+
else if (guardOutcome === 'removed') {
|
|
851
|
+
print(`Removed search-pattern guard from ${claudeSettingsPath(searchGuardHome())}`);
|
|
852
|
+
}
|
|
790
853
|
if (!exec.viaCommand) {
|
|
791
854
|
// Worth saying out loud: a unit pinned to a file inside the package directory
|
|
792
855
|
// breaks if that directory ever moves (which a package rename does).
|
|
@@ -1249,6 +1312,19 @@ async function cmdDoctor(args) {
|
|
|
1249
1312
|
print('Effective (systemd)');
|
|
1250
1313
|
for (const line of effective)
|
|
1251
1314
|
print(` ${line}`);
|
|
1315
|
+
// Привратник шаблонов поиска. Печатается всегда, в том числе когда он ВЫКЛЮЧЕН
|
|
1316
|
+
// в коде: «стоит, хотя выключен» — это и есть та машина, которую пропустило
|
|
1317
|
+
// обновление, и увидеть её больше неоткуда.
|
|
1318
|
+
const guard = searchGuardStatus(searchGuardHome());
|
|
1319
|
+
const guardMismatch = SEARCH_GUARD_ENABLED !== guard.installed;
|
|
1320
|
+
print('');
|
|
1321
|
+
print('Search-pattern guard (Claude Code §345)');
|
|
1322
|
+
print(` enabled in this runner: ${SEARCH_GUARD_ENABLED ? 'yes' : 'NO — should be removed'}`);
|
|
1323
|
+
print(` installed on this machine: ${guard.installed ? `yes (v${guard.version ?? '?'})` : 'no'}`);
|
|
1324
|
+
print(` settings file: ${guard.settingsReadable ? guard.path : `${guard.path} (unreadable or broken)`}`);
|
|
1325
|
+
if (guardMismatch) {
|
|
1326
|
+
print(' MISMATCH — run `devbridge-runner doctor --fix` to reconcile');
|
|
1327
|
+
}
|
|
1252
1328
|
// The two settings that turned one agent's OOM into a dead server.
|
|
1253
1329
|
const bad = effective.filter((l) => (l.startsWith('OOMPolicy=') && !l.endsWith('=continue')) ||
|
|
1254
1330
|
(l.startsWith('MemoryMax=') && !l.endsWith('=infinity')) ||
|
|
@@ -1286,9 +1362,13 @@ async function cmdDoctor(args) {
|
|
|
1286
1362
|
print(`Previous process exit: ${LAST_EXIT.kind}${LAST_EXIT.at ? ` at ${LAST_EXIT.at}` : ''}`);
|
|
1287
1363
|
}
|
|
1288
1364
|
if (!fix) {
|
|
1289
|
-
if (outdated || bad.length > 0) {
|
|
1365
|
+
if (outdated || bad.length > 0 || guardMismatch) {
|
|
1290
1366
|
print('');
|
|
1291
1367
|
print('Run `devbridge-runner doctor --fix` to write the drop-in, then restart the service.');
|
|
1368
|
+
// `guardMismatch` попадает сюда намеренно: весь смысл `searchGuardStatus` —
|
|
1369
|
+
// обойти парк и найти машины, которых обновление не достало. Машина, где
|
|
1370
|
+
// привратник не встал, не должна выглядеть здоровой для автоматики
|
|
1371
|
+
// (QA LOW-12).
|
|
1292
1372
|
process.exit(1);
|
|
1293
1373
|
}
|
|
1294
1374
|
// A readiness problem is a real finding too: exiting 0 over «the agent has
|
|
@@ -1302,6 +1382,13 @@ async function cmdDoctor(args) {
|
|
|
1302
1382
|
writeLimitsOverride(true, undefined, fixFacts);
|
|
1303
1383
|
print('');
|
|
1304
1384
|
print(`Wrote ${limitsOverridePath()}`);
|
|
1385
|
+
const guardOutcome = applySearchGuard(print);
|
|
1386
|
+
if (guardOutcome === 'installed') {
|
|
1387
|
+
print(`Installed search-pattern guard into ${claudeSettingsPath(searchGuardHome())}`);
|
|
1388
|
+
}
|
|
1389
|
+
else if (guardOutcome === 'removed') {
|
|
1390
|
+
print(`Removed search-pattern guard from ${claudeSettingsPath(searchGuardHome())}`);
|
|
1391
|
+
}
|
|
1305
1392
|
// Say it out loud when the ceiling had to be raised above the honest answer to
|
|
1306
1393
|
// avoid killing whatever is running right now. Lowering a ceiling under load is
|
|
1307
1394
|
// a decision, not a side effect of running a diagnostic — and without this line
|