@bridge4dev/runner 0.45.1 → 0.47.0

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/config.d.ts CHANGED
@@ -15,12 +15,12 @@ declare const ConfigSchema: z.ZodObject<{
15
15
  name: z.ZodString;
16
16
  token: z.ZodString;
17
17
  }, "strip", z.ZodTypeAny, {
18
- id: string;
19
18
  name: string;
19
+ id: string;
20
20
  token: string;
21
21
  }, {
22
- id: string;
23
22
  name: string;
23
+ id: string;
24
24
  token: string;
25
25
  }>;
26
26
  mcp: z.ZodOptional<z.ZodObject<{
@@ -83,14 +83,36 @@ declare const ConfigSchema: z.ZodObject<{
83
83
  }, {
84
84
  enabled?: boolean | undefined;
85
85
  }>>;
86
+ /**
87
+ * #371: the machine owner's veto over installing agent CLIs.
88
+ *
89
+ * `agent_install` downloads and runs a vendor's software on this server on a
90
+ * word from the dashboard. The payload is not the server's to choose — the
91
+ * package and the installer URL are compiled into this runner — but «which
92
+ * version of Codex runs here» is still a decision about somebody else's
93
+ * machine, and its owner gets the last word on whether we may make it at all.
94
+ *
95
+ * Same shape and same rule as `[verify]` and `[checkpoints]`:
96
+ * `install_enabled = false` means the capability is not announced and
97
+ * `agent_install` is not offered as a command, so the dashboard draws no
98
+ * button rather than a button that answers «refused». The server cannot
99
+ * switch it back on: it is read from a root-owned file, here.
100
+ */
101
+ agents: z.ZodOptional<z.ZodObject<{
102
+ install_enabled: z.ZodDefault<z.ZodBoolean>;
103
+ }, "strip", z.ZodTypeAny, {
104
+ install_enabled: boolean;
105
+ }, {
106
+ install_enabled?: boolean | undefined;
107
+ }>>;
86
108
  }, "strip", z.ZodTypeAny, {
87
109
  api: {
88
110
  url: string;
89
111
  ws_url: string;
90
112
  };
91
113
  server: {
92
- id: string;
93
114
  name: string;
115
+ id: string;
94
116
  token: string;
95
117
  };
96
118
  checkpoints?: {
@@ -109,14 +131,17 @@ declare const ConfigSchema: z.ZodObject<{
109
131
  verify?: {
110
132
  enabled: boolean;
111
133
  } | undefined;
134
+ agents?: {
135
+ install_enabled: boolean;
136
+ } | undefined;
112
137
  }, {
113
138
  api: {
114
139
  url: string;
115
140
  ws_url: string;
116
141
  };
117
142
  server: {
118
- id: string;
119
143
  name: string;
144
+ id: string;
120
145
  token: string;
121
146
  };
122
147
  checkpoints?: {
@@ -135,10 +160,25 @@ declare const ConfigSchema: z.ZodObject<{
135
160
  verify?: {
136
161
  enabled?: boolean | undefined;
137
162
  } | undefined;
163
+ agents?: {
164
+ install_enabled?: boolean | undefined;
165
+ } | undefined;
138
166
  }>;
139
167
  export type RunnerConfig = z.infer<typeof ConfigSchema>;
140
168
  export declare function loadConfig(): RunnerConfig | null;
141
169
  export declare function requireConfig(): RunnerConfig;
170
+ /**
171
+ * The config a re-pairing should write: the new server's identity, everything
172
+ * else as the machine's owner left it.
173
+ *
174
+ * Lives here, beside the schema, rather than inline in `cmdPair` — because the
175
+ * failure mode is a section that is simply not mentioned. `saveConfig` re-parses
176
+ * and rewrites the whole file, so a key forgotten in this merge is not «left
177
+ * alone», it is **deleted from disk**: re-pairing a machine would silently lift
178
+ * the owner's veto over installing agents, or move Codex back to a shared
179
+ * credential store. Every optional section of `ConfigSchema` must be listed.
180
+ */
181
+ export declare function mergeIntoPairedConfig(existing: RunnerConfig | null, paired: Pick<RunnerConfig, 'api' | 'server'>): RunnerConfig;
142
182
  export declare function saveConfig(config: RunnerConfig): void;
143
183
  export {};
144
184
  //# sourceMappingURL=config.d.ts.map
package/dist/config.js CHANGED
@@ -76,6 +76,26 @@ const ConfigSchema = z.object({
76
76
  enabled: z.boolean().default(true),
77
77
  })
78
78
  .optional(),
79
+ /**
80
+ * #371: the machine owner's veto over installing agent CLIs.
81
+ *
82
+ * `agent_install` downloads and runs a vendor's software on this server on a
83
+ * word from the dashboard. The payload is not the server's to choose — the
84
+ * package and the installer URL are compiled into this runner — but «which
85
+ * version of Codex runs here» is still a decision about somebody else's
86
+ * machine, and its owner gets the last word on whether we may make it at all.
87
+ *
88
+ * Same shape and same rule as `[verify]` and `[checkpoints]`:
89
+ * `install_enabled = false` means the capability is not announced and
90
+ * `agent_install` is not offered as a command, so the dashboard draws no
91
+ * button rather than a button that answers «refused». The server cannot
92
+ * switch it back on: it is read from a root-owned file, here.
93
+ */
94
+ agents: z
95
+ .object({
96
+ install_enabled: z.boolean().default(true),
97
+ })
98
+ .optional(),
79
99
  });
80
100
  export function loadConfig() {
81
101
  const file = configFilePath();
@@ -91,6 +111,29 @@ export function requireConfig() {
91
111
  }
92
112
  return config;
93
113
  }
114
+ /**
115
+ * The config a re-pairing should write: the new server's identity, everything
116
+ * else as the machine's owner left it.
117
+ *
118
+ * Lives here, beside the schema, rather than inline in `cmdPair` — because the
119
+ * failure mode is a section that is simply not mentioned. `saveConfig` re-parses
120
+ * and rewrites the whole file, so a key forgotten in this merge is not «left
121
+ * alone», it is **deleted from disk**: re-pairing a machine would silently lift
122
+ * the owner's veto over installing agents, or move Codex back to a shared
123
+ * credential store. Every optional section of `ConfigSchema` must be listed.
124
+ */
125
+ export function mergeIntoPairedConfig(existing, paired) {
126
+ return {
127
+ api: paired.api,
128
+ server: paired.server,
129
+ ...(existing?.mcp ? { mcp: existing.mcp } : {}),
130
+ ...(existing?.codex ? { codex: existing.codex } : {}),
131
+ ...(existing?.limits ? { limits: existing.limits } : {}),
132
+ ...(existing?.verify ? { verify: existing.verify } : {}),
133
+ ...(existing?.checkpoints ? { checkpoints: existing.checkpoints } : {}),
134
+ ...(existing?.agents ? { agents: existing.agents } : {}),
135
+ };
136
+ }
94
137
  export function saveConfig(config) {
95
138
  ConfigSchema.parse(config);
96
139
  const dir = configDir();
@@ -0,0 +1,27 @@
1
+ export interface PublishFileArgs {
2
+ /** Корень рабочей копии сессии. */
3
+ root: string;
4
+ /** Путь относительно корня. */
5
+ relPath: string;
6
+ /** Одноразовый слот, выданный API. */
7
+ slotId: string;
8
+ /** Сколько байт разрешено, по мнению API. */
9
+ maxBytes: number;
10
+ /** Адрес API, с которым спарен ЭТОТ раннер. */
11
+ apiUrl: string;
12
+ /** Токен этого раннера. */
13
+ token: string;
14
+ fetchImpl?: typeof fetch;
15
+ }
16
+ export interface PublishFileResult {
17
+ fileName: string;
18
+ size: number;
19
+ }
20
+ /** Прочитать файл по правилам просмотра и убедиться, что его можно отдать. */
21
+ export declare function readFileForPublish(root: string, relPath: string, maxBytes: number): {
22
+ buffer: Buffer;
23
+ fileName: string;
24
+ displayPath: string;
25
+ };
26
+ export declare function publishFile(args: PublishFileArgs): Promise<PublishFileResult>;
27
+ //# sourceMappingURL=file-publish.d.ts.map
@@ -0,0 +1,82 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { resolveInsideRoot } from './fsview.js';
4
+ import { isSecretPath } from './policy.js';
5
+ import { log } from './log.js';
6
+ /**
7
+ * Выложить файл с этой машины в хранилище платформы (0.46.0).
8
+ *
9
+ * Что здесь происходит и почему именно так:
10
+ *
11
+ * - **Адрес собирается из СВОЕГО `apiUrl`, а не из присланного.** Команда с
12
+ * той стороны говорит только «какой файл» и «в какой слот», но не «куда
13
+ * отправить». Тот же принцип, по которому раннер отказывается брать код
14
+ * откуда угодно, кроме сервера, с которым спарен: иначе одна подделанная
15
+ * команда превращается в способ вытащить файл с чужой машины на чужой хост.
16
+ *
17
+ * - **Правила пути — те же, что у просмотра.** `resolveInsideRoot` (выход за
18
+ * корень, символические ссылки, `.git`) плюс `isSecretPath` (`.env`, ключи).
19
+ * Своих правил здесь нет ни одного.
20
+ *
21
+ * - **Содержимое НЕ маскируется.** `fs_view` заменяет секреты в предпросмотре,
22
+ * но опубликованный архив с подменёнными байтами — битый архив, а документ с
23
+ * тихо изменённым текстом — ложь. Защита здесь — отказ по `isSecretPath` и
24
+ * то, что файл выбирает человек, а не агент.
25
+ */
26
+ /**
27
+ * Свой потолок поверх присланного.
28
+ *
29
+ * Присланное число — просьба, а не приказ: сторона API может однажды попросить
30
+ * прочитать гигабайт, и машина владельца не обязана соглашаться.
31
+ */
32
+ const HARD_MAX_BYTES = 64 * 1024 * 1024;
33
+ const UPLOAD_TIMEOUT_MS = 120_000;
34
+ /** Прочитать файл по правилам просмотра и убедиться, что его можно отдать. */
35
+ export function readFileForPublish(root, relPath, maxBytes) {
36
+ const target = resolveInsideRoot(root, relPath);
37
+ if (isSecretPath(target) || isSecretPath(path.basename(target))) {
38
+ throw new Error('This path is protected by runner policy');
39
+ }
40
+ const stat = fs.statSync(target);
41
+ if (!stat.isFile())
42
+ throw new Error('Not a regular file');
43
+ if (stat.size === 0)
44
+ throw new Error('The file is empty');
45
+ const ceiling = Math.min(maxBytes, HARD_MAX_BYTES);
46
+ if (stat.size > ceiling) {
47
+ throw new Error(`The file is larger than ${Math.round(ceiling / 1024 / 1024)}MB and cannot be published`);
48
+ }
49
+ const rootReal = fs.realpathSync(root);
50
+ return {
51
+ buffer: fs.readFileSync(target),
52
+ fileName: path.basename(target),
53
+ displayPath: path.relative(rootReal, target) || path.basename(target),
54
+ };
55
+ }
56
+ export async function publishFile(args) {
57
+ const doFetch = args.fetchImpl ?? fetch;
58
+ const { buffer, fileName, displayPath } = readFileForPublish(args.root, args.relPath, args.maxBytes);
59
+ const base = args.apiUrl.replace(/\/$/, '');
60
+ const response = await doFetch(`${base}/api/v1/dev/runner/uploads/${encodeURIComponent(args.slotId)}`, {
61
+ method: 'POST',
62
+ headers: {
63
+ Authorization: `Bearer ${args.token}`,
64
+ 'Content-Type': 'application/octet-stream',
65
+ // Имя файла и путь — в заголовках, а не в теле: тело здесь это ровно
66
+ // байты файла и ничего больше. Кодирование обязательно — в заголовок
67
+ // нельзя положить произвольный UTF-8 и уж точно нельзя перевод строки.
68
+ 'X-Devbridge-File-Name': encodeURIComponent(fileName),
69
+ 'X-Devbridge-File-Path': encodeURIComponent(displayPath),
70
+ },
71
+ body: new Uint8Array(buffer),
72
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
73
+ });
74
+ if (!response.ok) {
75
+ const body = (await response.json().catch(() => null));
76
+ const reason = body?.error?.message ?? `HTTP ${response.status}`;
77
+ log.warn('file-publish: upload rejected', { slotId: args.slotId, reason });
78
+ throw new Error(reason);
79
+ }
80
+ return { fileName, size: buffer.length };
81
+ }
82
+ //# sourceMappingURL=file-publish.js.map
package/dist/fsview.d.ts CHANGED
@@ -16,5 +16,13 @@ export interface FsEntry {
16
16
  type: 'dir' | 'file';
17
17
  size: number | null;
18
18
  }
19
+ /**
20
+ * Разрешение пути внутри корня рабочей копии.
21
+ *
22
+ * Экспортируется, потому что публикация файла (`file-publish.ts`) обязана
23
+ * применять РОВНО те же правила, что и просмотр: своя копия этой функции — это
24
+ * второй набор правил, который однажды разойдётся с первым.
25
+ */
26
+ export declare function resolveInsideRoot(root: string, relPath: string): string;
19
27
  export declare function fsView(root: string, relPath?: string): FsViewResult;
20
28
  //# sourceMappingURL=fsview.d.ts.map
package/dist/fsview.js CHANGED
@@ -18,7 +18,14 @@ function assertRepoRoot(rootReal) {
18
18
  throw new Error('The workspace path is not a git repository');
19
19
  }
20
20
  }
21
- function resolveInsideRoot(root, relPath) {
21
+ /**
22
+ * Разрешение пути внутри корня рабочей копии.
23
+ *
24
+ * Экспортируется, потому что публикация файла (`file-publish.ts`) обязана
25
+ * применять РОВНО те же правила, что и просмотр: своя копия этой функции — это
26
+ * второй набор правил, который однажды разойдётся с первым.
27
+ */
28
+ export function resolveInsideRoot(root, relPath) {
22
29
  let rootReal;
23
30
  try {
24
31
  rootReal = fs.realpathSync(root);
package/dist/index.js CHANGED
@@ -8,8 +8,8 @@ import { promisify } from 'node:util';
8
8
  import { ClaudeAdapter } from './adapters/claude.js';
9
9
  import { CodexAdapter } from './adapters/codex.js';
10
10
  import { ensureCodexHome } from './adapters/codex-home.js';
11
- import { claudeCliPath } from './agent-binary.js';
12
- import { loadConfig, requireConfig, saveConfig } from './config.js';
11
+ import { sessionClaudePath } from './agent-binary.js';
12
+ import { loadConfig, mergeIntoPairedConfig, requireConfig, saveConfig, } from './config.js';
13
13
  import { log } from './log.js';
14
14
  import { installIsWritable, installPrefixFor, isSupervisedProcess, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
15
15
  import { applyStoredClaudeToken } from './agent-auth.js';
@@ -63,6 +63,11 @@ function argValue(args, flag) {
63
63
  * create a session that immediately fails.
64
64
  */
65
65
  function installedAgents() {
66
+ // #371: this list and the `agent_versions` frame answer about the same file
67
+ // as of C2 — `sessionClaudePath()` follows `USE_BUNDLED_CLAUDE`, so while the
68
+ // constant is true it still reports the SDK's bundled binary (today's
69
+ // behaviour, unchanged), and the moment it flips both speak about the system
70
+ // `claude` on PATH. One switch, not two lists drifting apart.
66
71
  const agents = [];
67
72
  // Claude comes with the Agent SDK — but «comes with» is a claim about THIS
68
73
  // installation, not a law (ticket #225). The CLI is an optional platform
@@ -70,7 +75,7 @@ function installedAgents() {
70
75
  // Claude, accepts Claude sessions, and cannot start a single one. Reported as
71
76
  // measured, so the dashboard greys the agent out instead of offering a
72
77
  // session that dies before its first word.
73
- if (claudeCliPath())
78
+ if (sessionClaudePath())
74
79
  agents.push('claude');
75
80
  if (hasExecutable('codex'))
76
81
  agents.push('codex');
@@ -138,6 +143,7 @@ function runnerCapabilities(apiUrlOverride) {
138
143
  const verifyEnabled = config?.verify?.enabled !== false;
139
144
  // Ticket #126: the same veto, for restore points. Default on.
140
145
  const checkpointsEnabled = config?.checkpoints?.enabled !== false;
146
+ const agentInstallEnabled = config?.agents?.install_enabled !== false;
141
147
  return {
142
148
  agents: installedAgents(),
143
149
  git: true,
@@ -145,6 +151,14 @@ function runnerCapabilities(apiUrlOverride) {
145
151
  resumeEpoch: true,
146
152
  /** Session 8: understands `maxSessions` and runs sessions side by side. */
147
153
  parallelSessions: true,
154
+ /**
155
+ * 0.46.0: reports which sessions are actually holding a seat here, so the
156
+ * API stops having to guess the occupancy of this machine from its own
157
+ * rows. Announced rather than inferred from the version, because the API
158
+ * runs a compatibility sweep for runners that cannot say it — and that
159
+ * sweep must switch off the moment this one can.
160
+ */
161
+ sessionSlots: true,
148
162
  /**
149
163
  * Session 9: can update itself on command. Reported as a capability rather
150
164
  * than inferred from the version, because the dashboard must not offer a
@@ -178,6 +192,27 @@ function runnerCapabilities(apiUrlOverride) {
178
192
  claude: hasExecutable('claude'),
179
193
  codex: hasExecutable('codex'),
180
194
  },
195
+ /**
196
+ * This build measures agent versions and can be told to install one (0.47.0).
197
+ *
198
+ * One flag for both halves on purpose. The dashboard needs to know whether
199
+ * an empty version row means «not installed» or «this runner cannot say»,
200
+ * and the API needs to know whether `agent_install` will be understood —
201
+ * and there is no build where one is true and the other is not.
202
+ *
203
+ * Kept out of the version data itself: capabilities are computed once per
204
+ * process and replayed on every reconnect (`ws-client.ts`), so a version
205
+ * put here would freeze at daemon start. Versions travel in the
206
+ * `agent_versions` frame.
207
+ *
208
+ * Withheld entirely when the machine owner said no (`[agents]
209
+ * install_enabled = false`), like `[verify]` and `[checkpoints]`: a control
210
+ * that is switched off must not look like a control that is broken. The
211
+ * measuring half keeps working — reading a version is not installing one.
212
+ */
213
+ ...(agentInstallEnabled
214
+ ? { agentInstall: true }
215
+ : { agentInstallBlocked: 'disabled-by-config' }),
181
216
  /**
182
217
  * Where npm put this package, and the command that updates it here
183
218
  * (0.27.0).
@@ -234,6 +269,13 @@ function runnerCapabilities(apiUrlOverride) {
234
269
  * anything the previous version could not.
235
270
  */
236
271
  gitRefs: true,
272
+ /**
273
+ * 0.46.0: умеет выложить файл из рабочей копии в хранилище платформы
274
+ * (`fs_publish`). Без флага дашборд не рисует кнопку «Опубликовать», а
275
+ * объясняет, что раннер надо обновить — кнопка, чей фрейм старый раннер
276
+ * молча уронит, хуже отсутствующей кнопки.
277
+ */
278
+ filePublish: true,
237
279
  /**
238
280
  * Session 14: reads `.devbridge/project.json`. Announced even when
239
281
  * verification is switched off — «this machine will not run recipes» and
@@ -422,6 +464,7 @@ function runnerCapabilities(apiUrlOverride) {
422
464
  ...(verifyEnabled
423
465
  ? ['verify_start', 'verify_status', 'verify_cancel', 'preview_checkout', 'preview_stop']
424
466
  : []),
467
+ ...(agentInstallEnabled ? ['agent_install'] : []),
425
468
  ],
426
469
  };
427
470
  }
@@ -449,15 +492,14 @@ async function cmdPair(args) {
449
492
  fail(`pairing failed (HTTP ${response.status}): ${body?.error?.message ?? 'unknown error'}. ` +
450
493
  'The code is one-time and expires in 10 minutes — generate a fresh one in the dashboard if needed.');
451
494
  }
452
- const existing = loadConfig();
453
- const config = {
495
+ // Everything the machine's owner wrote by hand survives the re-pairing —
496
+ // `saveConfig` rewrites the whole file, so a section not carried across is a
497
+ // section deleted. The rule lives in `config.ts` beside the schema it has to
498
+ // keep up with, and is tested there.
499
+ const config = mergeIntoPairedConfig(loadConfig(), {
454
500
  api: { url: apiUrl, ws_url: body.data.wsUrl },
455
501
  server: { id: body.data.serverId, name: body.data.serverName, token: body.data.token },
456
- ...(existing?.mcp ? { mcp: existing.mcp } : {}),
457
- // Carry settings the user wrote by hand — saveConfig re-parses through the
458
- // schema, so a section dropped here is a section deleted from disk.
459
- ...(existing?.codex ? { codex: existing.codex } : {}),
460
- };
502
+ });
461
503
  saveConfig(config);
462
504
  print(`Paired as "${body.data.serverName}" (server ${body.data.serverId}).`);
463
505
  print('Start the daemon with: devbridge-runner install-service (or: devbridge-runner daemon)');
@@ -693,6 +735,7 @@ async function cmdDaemon() {
693
735
  // frame from an API that has not noticed still cannot start a run.
694
736
  verifyEnabled: config.verify?.enabled !== false,
695
737
  checkpointsEnabled: config.checkpoints?.enabled !== false,
738
+ agentInstallEnabled: config.agents?.install_enabled !== false,
696
739
  apiUrl: config.api.url,
697
740
  // Used to fetch the files a user attaches to a message (session 10) — the
698
741
  // same token the WS connection authenticates with, never passed onwards.
@@ -1047,13 +1090,19 @@ async function agentChecks() {
1047
1090
  checks.push({
1048
1091
  ok: cliPresent.claude,
1049
1092
  name: 'claude cli',
1093
+ // Since 0.47.0 this is not a warning about signing in — it is «no Claude
1094
+ // sessions on this machine at all». The bundled binary is switched off
1095
+ // (`USE_BUNDLED_CLAUDE`), so the CLI on PATH is the one every session runs.
1050
1096
  detail: cliPresent.claude
1051
1097
  ? `on ${me.user}'s PATH`
1052
- : `not installed for ${me.user} — sessions still run (the SDK bundles its own), but signing in needs the CLI`,
1098
+ : `not installed for ${me.user} — Claude sessions cannot start on this server`,
1053
1099
  ...(cliPresent.claude
1054
1100
  ? {}
1055
1101
  : {
1056
- fix: `sudo -iu ${me.user} sh -lc 'curl -fsSL https://claude.ai/install.sh | bash'`,
1102
+ // The dashboard is the way in (Р9): it picks the version, checks the
1103
+ // disk first and puts the old binary back if the new one will not run.
1104
+ // The command stays for a machine nobody can reach the dashboard from.
1105
+ fix: `press «Install» on the Claude row of this server's card in DevBridge — or, by hand: sudo -iu ${me.user} sh -lc 'curl -fsSL https://claude.ai/install.sh | bash'`,
1057
1106
  }),
1058
1107
  });
1059
1108
  // Codex is optional: plenty of machines only ever run Claude sessions, and a