@tikoci/centrs 0.1.9-next.0 → 0.1.9-next.22

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +177 -40
  2. package/GLOSSARY.txt +8 -0
  3. package/README.md +12 -8
  4. package/SECURITY.md +11 -3
  5. package/commands/api/README.md +39 -15
  6. package/commands/api/examples.md +44 -6
  7. package/commands/execute/README.md +108 -14
  8. package/commands/execute/examples.md +118 -3
  9. package/commands/explain/README.md +2 -1
  10. package/commands/mcp/README.md +11 -0
  11. package/commands/mcp/examples.md +6 -0
  12. package/commands/retrieve/README.md +30 -13
  13. package/commands/retrieve/examples.md +10 -6
  14. package/commands/transfer/examples.md +9 -0
  15. package/docs/CLI.md +8 -8
  16. package/docs/CONSTITUTION.md +25 -2
  17. package/docs/MATRIX.md +5 -4
  18. package/docs/errors/cdb/empty-group.md +19 -3
  19. package/docs/errors/cdb/empty-selection.md +9 -3
  20. package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
  21. package/docs/errors/cmr/empty-selection.md +17 -3
  22. package/docs/errors/cmr/rule-selects-every-device.md +32 -0
  23. package/docs/errors/cmr/selection-includes-controller.md +27 -0
  24. package/docs/errors/usage/flag-after-separator.md +34 -0
  25. package/package.json +7 -5
  26. package/src/api-fanout.ts +11 -1
  27. package/src/api.ts +205 -65
  28. package/src/btest.ts +7 -2
  29. package/src/cli/api.ts +30 -16
  30. package/src/cli/common.ts +180 -1
  31. package/src/cli/devices.ts +0 -2
  32. package/src/cli/discover.ts +0 -1
  33. package/src/cli/execute.ts +44 -20
  34. package/src/cli/explain.ts +2 -0
  35. package/src/cli/mcp.ts +0 -1
  36. package/src/cli/retrieve.ts +40 -18
  37. package/src/cli/settings.ts +0 -3
  38. package/src/cli/terminal.ts +0 -2
  39. package/src/cli/transfer.ts +10 -8
  40. package/src/cli/version.ts +87 -0
  41. package/src/cli.ts +5 -3
  42. package/src/core/cmr-fleet.ts +486 -0
  43. package/src/core/cmr.ts +78 -0
  44. package/src/core/envelope.ts +32 -3
  45. package/src/core/error-catalog.ts +25 -3
  46. package/src/core/execute-command.ts +144 -19
  47. package/src/core/fanout.ts +130 -6
  48. package/src/errors.ts +2 -1
  49. package/src/execute-cmr.ts +95 -78
  50. package/src/execute-fanout.ts +11 -1
  51. package/src/execute.ts +105 -23
  52. package/src/index.ts +18 -1
  53. package/src/mcp/safety.ts +11 -0
  54. package/src/mcp/tools.ts +4 -1
  55. package/src/resolver/cdb.ts +13 -1
  56. package/src/resolver/index.ts +4 -0
  57. package/src/resolver/selection.ts +37 -1
  58. package/src/resolver/target.ts +96 -8
  59. package/src/retrieve-fanout.ts +4 -1
  60. package/src/retrieve-follow.ts +29 -33
  61. package/src/retrieve-sample.ts +14 -8
  62. package/src/retrieve-stream.ts +38 -6
  63. package/src/retrieve.ts +73 -59
  64. package/src/transfer-fanout.ts +8 -1
  65. package/src/transfer.ts +15 -19
package/src/core/cmr.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { CentrsError } from "../errors.ts";
7
+ import type { ProtocolAdapter } from "../protocols/adapter.ts";
7
8
  import type { Warning } from "./envelope.ts";
8
9
 
9
10
  /** The controller command that runs a script on CMR clients. */
@@ -102,3 +103,80 @@ export function cmrScriptWarnings(script: string): Warning[] {
102
103
  ? [clientScriptNotValidated]
103
104
  : [];
104
105
  }
106
+
107
+ /** The policies CMR gives every `run-script` script on a client (manual; 7.26beta1). */
108
+ export const CMR_SCRIPT_POLICIES = ["read", "write", "test", "reboot"] as const;
109
+
110
+ /** The controller login's own group and its granted policies. */
111
+ export interface CmrCallerPolicy {
112
+ username: string;
113
+ group: string;
114
+ /** Granted policy names; `!name` entries are dropped. */
115
+ policy: readonly string[];
116
+ }
117
+
118
+ /**
119
+ * Read the session user's group policy on the controller, or `undefined` when
120
+ * it cannot be read (a RADIUS login has no `/user` row). Grounded on
121
+ * 7.26beta1 over REST and native API: a login with only `read` and `api`
122
+ * (`!sensitive !policy` too) can read its own `/user` row and `/user/group`.
123
+ */
124
+ export async function readCmrCallerPolicy(
125
+ adapter: Pick<ProtocolAdapter, "apiRequest">,
126
+ username: string,
127
+ ): Promise<CmrCallerPolicy | undefined> {
128
+ const first = async (path: string, name: string) => {
129
+ const result = await adapter.apiRequest({
130
+ verb: "print",
131
+ path,
132
+ query: [`name=${name}`],
133
+ });
134
+ return Array.isArray(result.data)
135
+ ? (result.data[0] as Record<string, string> | undefined)
136
+ : undefined;
137
+ };
138
+ try {
139
+ const group = (await first("/user", username))?.["group"];
140
+ if (!group) return undefined;
141
+ const policy = (await first("/user/group", group))?.["policy"];
142
+ if (policy === undefined) return undefined;
143
+ return {
144
+ username,
145
+ group,
146
+ policy: policy
147
+ .split(",")
148
+ .map((name) => name.trim())
149
+ .filter((name) => name.length > 0 && !name.startsWith("!")),
150
+ };
151
+ } catch {
152
+ return undefined;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * #456: RouterOS does not hold a CMR fleet command to the caller's policy. On
158
+ * 7.26beta1 `run-script` needs only `read` + `api` on the controller login,
159
+ * yet the script runs on each client (the controller's own row included) with
160
+ * {@link CMR_SCRIPT_POLICIES}. A "reader" login wrote and rebooted clients
161
+ * through it. Warn when the login lacks a policy the command will run with,
162
+ * so a caller does not take the login for the limit; centrs' own write gate
163
+ * is the only one. MikroTik says 7.26beta2 refuses this for a `read` group
164
+ * (forum topic 273582, post 139); not yet checked on a beta2 build.
165
+ */
166
+ export function cmrCallerPolicyWarning(
167
+ caller: CmrCallerPolicy,
168
+ runsWith: readonly string[],
169
+ ): Warning | undefined {
170
+ const missing = runsWith.filter((name) => !caller.policy.includes(name));
171
+ if (missing.length === 0) return undefined;
172
+ return {
173
+ code: "cmr/caller-policy-not-enforced",
174
+ message: `The controller login \`${caller.username}\` (group \`${caller.group}\`) lacks ${missing.join(", ")}, but CMR runs this on the clients with ${runsWith.join(", ")}. RouterOS 7.26beta1 does not check the caller's policy here, so the login does not limit what a CMR command can change (MikroTik says 7.26beta2 will).`,
175
+ context: {
176
+ username: caller.username,
177
+ group: caller.group,
178
+ missing,
179
+ runsWith: [...runsWith],
180
+ },
181
+ };
182
+ }
@@ -15,6 +15,10 @@
15
15
  * - `meta` is invariant across commands. Only `data` and `meta.operation` vary
16
16
  * per command. Do not add command-specific keys beside `target`/`via`/
17
17
  * `settings`/`validation`/`timing`/`truncated`; put them in `meta.operation`.
18
+ * - Shared meta is written once (#461, #462, #473). A fan-out target omits
19
+ * what its outer envelope already says ({@link FanoutMemberOperation}); a
20
+ * stream's data lines between `started` and `summary` carry only their
21
+ * `meta.operation` ({@link StreamLineEnvelope}).
18
22
  * - CLI render must be lossless against the JSON envelope; `--format json|yaml`
19
23
  * select serialization, not different shapes. Omit `undefined`; use `null`
20
24
  * only where absence is meaningful and must round-trip (e.g. `via: null` when
@@ -112,10 +116,10 @@ export interface EnvelopeTargetMeta {
112
116
  /**
113
117
  * `execute --cmr`: the CMR client this result belongs to, as the controller
114
118
  * knows it. `id` is the controller's `/cmr/device` row; `deviceKey` is the
115
- * device half of its `ids`. The client is never dialed directly.
119
+ * device half of its `ids`. The client is never dialed directly; the
120
+ * controller is the fan-out's outer `meta.target`.
116
121
  */
117
122
  cmr?: {
118
- controller: string;
119
123
  id: string;
120
124
  deviceKey?: string;
121
125
  address?: string;
@@ -287,9 +291,34 @@ export interface FanoutSummary {
287
291
  failed: number;
288
292
  }
289
293
 
294
+ /**
295
+ * A fan-out target's `meta.operation`. `request` and `auth` are omitted when
296
+ * they equal the outer `meta.operation`'s, and the target's `meta.settings`
297
+ * keeps only the keys whose source differs from the outer `meta.settings`, so
298
+ * a reader takes `target ?? outer` (per key for settings).
299
+ */
300
+ export type FanoutMemberOperation<Operation> = Omit<
301
+ Operation,
302
+ "request" | "auth"
303
+ > &
304
+ Partial<Pick<Operation, Extract<keyof Operation, "request" | "auth">>>;
305
+
290
306
  export interface FanoutData<Data = unknown, Operation = unknown> {
291
307
  summary: FanoutSummary;
292
- targets: readonly CentrsEnvelope<Data, Operation>[];
308
+ targets: readonly CentrsEnvelope<Data, FanoutMemberOperation<Operation>>[];
309
+ }
310
+
311
+ /**
312
+ * A stream data line that carries only its own `meta.operation`; the rest of
313
+ * its meta is on the stream's `started` and `summary` lines (and `synced` for
314
+ * `retrieve --follow`).
315
+ */
316
+ export interface StreamLineEnvelope<Data = unknown, Operation = unknown> {
317
+ ok: true;
318
+ data: Data;
319
+ warnings: readonly Warning[];
320
+ tips: readonly Tip[];
321
+ meta: { operation: Operation };
293
322
  }
294
323
 
295
324
  /** Normalize an optional warnings list to a readonly array. */
@@ -55,11 +55,13 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
55
55
  },
56
56
  {
57
57
  code: "cdb/empty-group",
58
- summary: "The named CDB group contains no records.",
58
+ summary:
59
+ "The named CDB group contains no records: a warning for a read, an error for a write-shaped fan-out.",
59
60
  },
60
61
  {
61
62
  code: "cdb/empty-selection",
62
- summary: "The target selection matched no CDB records.",
63
+ summary:
64
+ "The target selection matched no CDB records: a warning for a read, an error for a write-shaped fan-out.",
63
65
  },
64
66
  {
65
67
  code: "cdb/file-exists",
@@ -268,6 +270,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
268
270
  summary:
269
271
  "The operation was cancelled before the command was sent to a CMR client.",
270
272
  },
273
+ {
274
+ code: "cmr/caller-policy-not-enforced",
275
+ summary:
276
+ "Warning: the controller login lacks a policy the CMR command runs with on the clients, and RouterOS does not check it.",
277
+ },
271
278
  {
272
279
  code: "cmr/client-script-not-validated",
273
280
  summary:
@@ -281,7 +288,7 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
281
288
  {
282
289
  code: "cmr/empty-selection",
283
290
  summary:
284
- "Warning: the `--labels` expression matched no CMR device on the controller.",
291
+ "The CMR selection matched no device on the controller: for `--cmr`, a warning for a read and an error for a write; for a fleet command such as `/cmr/vlan/add`, a warning.",
285
292
  },
286
293
  {
287
294
  code: "cmr/membership-changed",
@@ -313,6 +320,16 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
313
320
  summary:
314
321
  "A CMR client refused a statement because CMR scripts run without the policy it needs; earlier statements already ran.",
315
322
  },
323
+ {
324
+ code: "cmr/rule-selects-every-device",
325
+ summary:
326
+ "Warning: a CMR rule command has no device `labels` (and, for VLAN, no `port-labels`), so the rule applies to every device, the controller included.",
327
+ },
328
+ {
329
+ code: "cmr/selection-includes-controller",
330
+ summary:
331
+ "Warning: a CMR fleet command selects the controller's own row as well as its clients.",
332
+ },
316
333
  {
317
334
  code: "cmr/selection-incomplete",
318
335
  summary:
@@ -611,6 +628,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
611
628
  code: "usage/conflicting-flags",
612
629
  summary: "Two or more supplied flags conflict.",
613
630
  },
631
+ {
632
+ code: "usage/flag-after-separator",
633
+ summary:
634
+ "A centrs flag was written after `--`, where every token is RouterOS input.",
635
+ },
614
636
  {
615
637
  code: "usage/invalid-concurrency",
616
638
  summary: "The --concurrency value must be an integer >= 1.",
@@ -101,7 +101,7 @@ export function canonicalizeExecuteCommand(
101
101
 
102
102
  export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
103
103
  if (command.mode === "structured") {
104
- if (FILE_OUTPUT_ATTRIBUTE in command.attributes) {
104
+ if (Object.keys(command.attributes).some(isFileOutputAttribute)) {
105
105
  return true;
106
106
  }
107
107
  return !READ_ONLY_EXECUTE_VERBS.has(command.verb.toLowerCase());
@@ -110,21 +110,35 @@ export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
110
110
  }
111
111
 
112
112
  /**
113
- * The attribute that turns an otherwise read-only verb into a device write.
113
+ * The attributes that turn an otherwise read-only command into a device write.
114
114
  *
115
115
  * RouterOS routes command output to a file on the device when `file=` is
116
116
  * supplied, so a read verb creates a file: `/ip/address/export file=address`
117
117
  * writes `address.rsc`, and `/file/print file=test` writes `test.txt` (both
118
118
  * from the RouterOS manual — "Configuration Export" and "Scripting Tips and
119
119
  * Tricks", where that idiom is the documented way to create a file and needs
120
- * the script `write` policy). The verb allowlist cannot see that, so the
121
- * attribute is checked independently of it.
120
+ * the script `write` policy). `:serialize` spells it `file-name=` (its
121
+ * `/console/inspect` arguments, 7.13.5 through 7.26beta1). The verb and
122
+ * directive allowlists cannot see either, so they are checked independently.
122
123
  */
123
- const FILE_OUTPUT_ATTRIBUTE = "file";
124
+ const FILE_OUTPUT_ATTRIBUTES: readonly string[] = ["file", "file-name"];
124
125
 
125
- /** True for a `file=<name>` token — the write-shaping output redirect. */
126
+ /**
127
+ * RouterOS accepts argument prefixes (`fi=`, `file-n=`), including on
128
+ * `:serialize` (CHR 7.26beta1). An ambiguous prefix stays write-shaped too:
129
+ * only the device can disambiguate it, so the local gate fails closed.
130
+ */
131
+ function isFileOutputAttribute(name: string): boolean {
132
+ return (
133
+ name.length > 0 &&
134
+ FILE_OUTPUT_ATTRIBUTES.some((full) => full.startsWith(name))
135
+ );
136
+ }
137
+
138
+ /** True for an output-file argument, including its abbreviated spellings. */
126
139
  function isFileOutputToken(token: string): boolean {
127
- return token.startsWith(`${FILE_OUTPUT_ATTRIBUTE}=`);
140
+ const separator = token.indexOf("=");
141
+ return separator > 0 && isFileOutputAttribute(token.slice(0, separator));
128
142
  }
129
143
 
130
144
  /**
@@ -193,27 +207,59 @@ const WRITE_SHAPED_SCRIPT_DIRECTIVES: ReadonlySet<string> = new Set([
193
207
  /**
194
208
  * Script directives that are safe to run without device-write confirmation:
195
209
  * output, control flow and pure value functions, all members of `explain`'s
196
- * grounded root-cmd vocabulary (`ROOT_CMDS` in `src/explain/write.ts`). A
197
- * directive's `do={…}` or `[…]` body is its own command head and is checked on
198
- * its own. Deliberately absent: `:execute` and `:parse` (run built code),
199
- * `:global` and `:set` (outlive the script), and `:import`, `:password`,
200
- * `:undo`, `:redo` (configuration changes).
210
+ * grounded root-cmd vocabulary (`ROOT_CMDS` in `src/explain/write.ts`) and
211
+ * present on 7.26beta1. A directive's `do={…}` or `[…]` body is its own
212
+ * command head and is checked on its own. That holds because a script-typed
213
+ * argument (`:do command=`, `:if do=`, `:retry command=`, `:time`, `:onerror
214
+ * in=`) runs only a `{…}` block: RouterOS refuses a quoted string or a variable
215
+ * holding one there as an invalid value (CHR 7.24.5 and 7.26beta1, #458), so
216
+ * no body can hide in text the gate blanks. The value functions (#458) take
217
+ * only values; `:serialize` alone can write, through `file-name=` (it creates
218
+ * the file on 7.26beta1), which the file-output check catches.
219
+ *
220
+ * Deliberately absent: `:execute` and `:parse` (run built code), `:global` and
221
+ * `:set` (outlive the script), and `:import`, `:password`, `:undo`, `:redo`
222
+ * (configuration changes). A directive missing here costs `--yes`; one wrongly
223
+ * here bypasses the gate.
201
224
  */
202
225
  const READ_ONLY_SCRIPT_DIRECTIVES: ReadonlySet<string> = new Set([
203
226
  ":break",
204
227
  ":continue",
228
+ ":convert",
229
+ ":delay",
230
+ ":deserialize",
205
231
  ":do",
206
232
  ":error",
233
+ ":find",
207
234
  ":for",
208
235
  ":foreach",
209
236
  ":if",
237
+ ":jobname",
210
238
  ":len",
211
239
  ":local",
240
+ ":nothing",
212
241
  ":onerror",
213
242
  ":pick",
214
243
  ":put",
244
+ ":range",
245
+ ":retry",
215
246
  ":return",
247
+ ":rndnum",
248
+ ":rndstr",
249
+ ":serialize",
250
+ ":time",
251
+ ":timestamp",
252
+ ":toarray",
253
+ ":tobool",
254
+ ":tocrlf",
255
+ ":toid",
256
+ ":toip",
257
+ ":toip6",
258
+ ":tolf",
259
+ ":tonsec",
260
+ ":tonum",
216
261
  ":tostr",
262
+ ":totime",
217
263
  ":typeof",
218
264
  ":while",
219
265
  ]);
@@ -249,7 +295,11 @@ function scriptContainsWriteShapedCommand(input: string): boolean {
249
295
  // Every command the statement runs must be proven read-only, not just the
250
296
  // first: `:foreach i in=[/ip address find] do={/user expire-password $i}`
251
297
  // reads with `find` and writes with an unlearned mutator.
252
- if (!commandHeads(statement).every(isReadOnlyCommand)) {
298
+ if (
299
+ !commandHeads(statement).every(
300
+ (head) => !head.spacedFileOutput && isReadOnlyCommand(head.words),
301
+ )
302
+ ) {
253
303
  return true;
254
304
  }
255
305
  }
@@ -264,12 +314,23 @@ function scriptContainsWriteShapedCommand(input: string): boolean {
264
314
  * expressions, not commands, so they do not start one. Each head reads only up
265
315
  * to the next opener, which begins its own head.
266
316
  */
267
- function commandHeads(statement: string): string[][] {
268
- const heads: string[][] = [];
317
+ function commandHeads(
318
+ statement: string,
319
+ ): { words: string[]; spacedFileOutput: boolean }[] {
320
+ const heads: { words: string[]; spacedFileOutput: boolean }[] = [];
269
321
  const starts = [0];
322
+ // Match group ends once. Each command can then skip its nested bodies
323
+ // without walking them again; deeply nested reads stay linear in input size.
324
+ const closes = new Map<number, number>();
325
+ const open: number[] = [];
270
326
  for (let index = 0; index < statement.length; index += 1) {
271
- const char = statement[index];
327
+ const char = statement[index] ?? "";
272
328
  if (char === "[" || char === "{") starts.push(index + 1);
329
+ if ("[{(".includes(char)) open.push(index);
330
+ else if ("]})".includes(char)) {
331
+ const start = open.pop();
332
+ if (start !== undefined) closes.set(start, index);
333
+ }
273
334
  }
274
335
  for (const start of starts) {
275
336
  const nextOpener = statement.slice(start).search(/[[{(]/);
@@ -282,11 +343,58 @@ function commandHeads(statement: string): string[][] {
282
343
  if (head === undefined) continue;
283
344
  const words = commandWords(head, tokens.slice(1));
284
345
  // A lone closer (`}` ending a multi-line block) runs nothing.
285
- if (words.length > 0) heads.push(words);
346
+ if (words.length > 0)
347
+ heads.push({
348
+ words,
349
+ spacedFileOutput: hasSpacedFileOutput(statement, start, words, closes),
350
+ });
286
351
  }
287
352
  return heads;
288
353
  }
289
354
 
355
+ /**
356
+ * RouterOS also accepts `file value` / `file-name value` without `=`. These
357
+ * belong to print/export/serialize, not a loop variable or a pure function's
358
+ * value. Read the command's own tokens, skipping nested expressions and
359
+ * blocks: `:serialize value=[:tostr file] file-n "out"` has one file argument
360
+ * after its nested read. Quoted text is already masked by the statement split.
361
+ * Grounded on CHR 7.26beta1, with export also checked on long-term.
362
+ */
363
+ function hasSpacedFileOutput(
364
+ statement: string,
365
+ start: number,
366
+ words: readonly string[],
367
+ closes: ReadonlyMap<number, number>,
368
+ ): boolean {
369
+ let commandWords: number;
370
+ if (words[0] === ":serialize") {
371
+ commandWords = 1;
372
+ } else {
373
+ const depth = menuDepth(words);
374
+ if (words[depth] !== "print" && words[depth] !== "export") return false;
375
+ commandWords = depth + 1;
376
+ }
377
+ let own = "";
378
+ for (let index = start; index < statement.length; index += 1) {
379
+ const char = statement[index] ?? "";
380
+ if ("[{(".includes(char)) {
381
+ own += " ";
382
+ index = closes.get(index) ?? statement.length;
383
+ } else if ("]})".includes(char)) {
384
+ break;
385
+ } else own += char;
386
+ }
387
+ let consumed = 0;
388
+ for (const token of tokenizeRouterOsCli(own)) {
389
+ if (consumed < commandWords) {
390
+ consumed += token.split("/").filter(Boolean).length;
391
+ continue;
392
+ }
393
+ if (!token.includes("=") && isFileOutputAttribute(token)) return true;
394
+ }
395
+ return false;
396
+ }
397
+
290
398
  /**
291
399
  * The path segments and verb of one command, from its head token and the bare
292
400
  * words after it. The space-separated spelling puts menu segments and the verb
@@ -299,6 +407,19 @@ function commandWords(head: string, following: readonly string[]): string[] {
299
407
  const words: string[] = [];
300
408
  const headWord = trimGroupingPunctuation(head);
301
409
  if (headWord.startsWith(":")) return [headWord];
410
+ // `/serialize …` is the root command `:serialize`: at statement start the
411
+ // sigil is optional and `/` binds the root vocabulary (CHR 7.23.2, see
412
+ // `src/explain/symbols.ts`). Only the absolute one-segment form is read
413
+ // this way. A bare `delay` may be a relative menu command after earlier
414
+ // navigation, so it keeps the menu-verb rule and fails closed.
415
+ const root = /^\/([A-Za-z0-9-]+)$/.exec(headWord)?.[1]?.toLowerCase();
416
+ if (
417
+ root !== undefined &&
418
+ READ_ONLY_SCRIPT_DIRECTIVES.has(`:${root}`) &&
419
+ !isKnownMenuPath([root])
420
+ ) {
421
+ return [`:${root}`];
422
+ }
302
423
  words.push(...headWord.split("/").filter(Boolean));
303
424
  if (headWord !== head) return words;
304
425
  for (const token of following) {
@@ -325,12 +446,16 @@ function commandWords(head: string, following: readonly string[]): string[] {
325
446
  function isReadOnlyCommand(words: readonly string[]): boolean {
326
447
  const head = words[0] ?? "";
327
448
  if (head.startsWith(":")) return READ_ONLY_SCRIPT_DIRECTIVES.has(head);
449
+ const verb = words[menuDepth(words)];
450
+ return verb !== undefined && READ_ONLY_EXECUTE_VERBS.has(verb.toLowerCase());
451
+ }
452
+
453
+ function menuDepth(words: readonly string[]): number {
328
454
  let depth = 0;
329
455
  while (depth < words.length && isKnownMenuPath(words.slice(0, depth + 1))) {
330
456
  depth += 1;
331
457
  }
332
- const verb = words[depth];
333
- return verb !== undefined && READ_ONLY_EXECUTE_VERBS.has(verb.toLowerCase());
458
+ return depth;
334
459
  }
335
460
 
336
461
  function trimGroupingPunctuation(value: string): string {
@@ -20,7 +20,9 @@ import type {
20
20
  CommonSettingsMeta,
21
21
  EnvelopeTargetMeta,
22
22
  FanoutData,
23
+ FanoutMemberOperation,
23
24
  FanoutSummary,
25
+ SettingSource,
24
26
  ValidationTrace,
25
27
  Warning,
26
28
  } from "./envelope.ts";
@@ -310,29 +312,151 @@ export function commonVia(
310
312
  return via ?? null;
311
313
  }
312
314
 
315
+ /** The part of an outer fan-out `operation` its targets may share. */
316
+ interface SharedOperation {
317
+ request?: unknown;
318
+ auth?: unknown;
319
+ }
320
+
321
+ /**
322
+ * Write shared meta once (#461): the outer envelope keeps the run's settings,
323
+ * request and auth, and each target keeps only what differs from them. A
324
+ * settings key the outer envelope lacks, and the `auth`, move out when every
325
+ * target that reached an operation agrees on them. A target's `settings`
326
+ * keeps the keys whose source differs from the outer key's; its
327
+ * `request`/`auth` are dropped when equal to the outer ones.
328
+ */
329
+ export function compactFanoutTargets<Data, Op, OuterOp extends object>(
330
+ settings: CommonSettingsMeta,
331
+ operation: OuterOp,
332
+ targets: readonly CentrsEnvelope<Data, Op>[],
333
+ ): {
334
+ settings: CommonSettingsMeta;
335
+ operation: OuterOp;
336
+ targets: CentrsEnvelope<Data, FanoutMemberOperation<Op>>[];
337
+ } {
338
+ // A target that failed before it had an operation (a resolve failure)
339
+ // has no settings or auth to agree or disagree with.
340
+ const reached = targets.flatMap((target) => {
341
+ const inner = target.meta.operation as SharedOperation | undefined;
342
+ return inner ? [{ settings: target.meta.settings, auth: inner.auth }] : [];
343
+ });
344
+ const agreed = (value: (member: (typeof reached)[number]) => unknown) => {
345
+ const first = reached[0] && value(reached[0]);
346
+ return first !== undefined &&
347
+ reached.every((member) => Bun.deepEquals(value(member), first))
348
+ ? first
349
+ : undefined;
350
+ };
351
+ const outerSettings: CommonSettingsMeta = { ...settings };
352
+ for (const key of Object.keys(reached[0]?.settings ?? {})) {
353
+ if (outerSettings[key] !== undefined) continue;
354
+ const source = agreed((member) => member.settings[key]);
355
+ if (source !== undefined) outerSettings[key] = source as SettingSource;
356
+ }
357
+ const outer = operation as SharedOperation;
358
+ const auth = outer.auth === undefined ? agreed((m) => m.auth) : undefined;
359
+ const shared: SharedOperation =
360
+ auth === undefined ? outer : { ...outer, auth };
361
+ return {
362
+ settings: outerSettings,
363
+ operation: shared as OuterOp,
364
+ targets: targets.map((target) => {
365
+ const own: CommonSettingsMeta = {};
366
+ // An `undefined` source is never written: JSON drops it, so a direct
367
+ // and a serialized reader would disagree on what the target inherits.
368
+ for (const [key, source] of Object.entries(target.meta.settings)) {
369
+ if (source !== undefined && !Bun.deepEquals(source, outerSettings[key]))
370
+ own[key] = source;
371
+ }
372
+ const meta = { ...target.meta, settings: own };
373
+ const inner = target.meta.operation as
374
+ | (Record<string, unknown> & SharedOperation)
375
+ | undefined;
376
+ if (inner) {
377
+ const { request, auth, ...rest } = inner;
378
+ // `auth` is shared only when every reached target has the same
379
+ // defined one, and every inner operation type requires `request`,
380
+ // so an omitted field always means "equal to the outer one".
381
+ const differs = (value: unknown, outer: unknown) =>
382
+ value !== undefined && !Bun.deepEquals(value, outer);
383
+ meta.operation = {
384
+ ...rest,
385
+ ...(differs(request, shared.request) ? { request } : {}),
386
+ ...(differs(auth, shared.auth) ? { auth } : {}),
387
+ } as Op;
388
+ }
389
+ return { ...target, meta } as CentrsEnvelope<
390
+ Data,
391
+ FanoutMemberOperation<Op>
392
+ >;
393
+ }),
394
+ };
395
+ }
396
+
397
+ /**
398
+ * The inverse of {@link compactFanoutTargets}: a target's full meta, read as
399
+ * `target ?? outer` (per key for settings). A target that failed before it
400
+ * had an operation gets no operation.
401
+ */
402
+ export function expandFanoutTarget<Data, Op>(
403
+ outer: { meta: { settings: CommonSettingsMeta; operation?: unknown } },
404
+ target: CentrsEnvelope<Data, FanoutMemberOperation<Op>>,
405
+ ): CentrsEnvelope<Data, Op> {
406
+ const shared = (outer.meta.operation ?? {}) as SharedOperation;
407
+ const inner = target.meta.operation as
408
+ | (Record<string, unknown> & SharedOperation)
409
+ | undefined;
410
+ const settings: CommonSettingsMeta = { ...outer.meta.settings };
411
+ for (const [key, source] of Object.entries(target.meta.settings)) {
412
+ if (source !== undefined) settings[key] = source;
413
+ }
414
+ const operation = inner && {
415
+ ...inner,
416
+ ...(inner.request === undefined && shared.request !== undefined
417
+ ? { request: shared.request }
418
+ : {}),
419
+ ...(inner.auth === undefined && shared.auth !== undefined
420
+ ? { auth: shared.auth }
421
+ : {}),
422
+ };
423
+ return {
424
+ ...target,
425
+ meta: { ...target.meta, settings, ...(operation ? { operation } : {}) },
426
+ } as CentrsEnvelope<Data, Op>;
427
+ }
428
+
313
429
  /**
314
430
  * Assemble the locked fan-out success envelope: `data = { summary, targets }`,
315
431
  * outer `ok: true` = orchestration succeeded. The command owns its outer
316
- * `operation` meta shape and passes it in.
432
+ * `operation` meta shape and passes it in; shared meta is hoisted out of the
433
+ * targets by {@link compactFanoutTargets}. `target` is the outer target, set
434
+ * only when the fan-out runs through one router (a CMR controller).
317
435
  */
318
- export function buildFanoutEnvelope<Data, Op, OuterOp>(input: {
436
+ export function buildFanoutEnvelope<Data, Op, OuterOp extends object>(input: {
319
437
  summary: FanoutSummary;
320
438
  targets: readonly CentrsEnvelope<Data, Op>[];
321
439
  warnings: readonly Warning[];
322
440
  settings: CommonSettingsMeta;
323
441
  via: RouterOsProtocol | null;
324
442
  operation: OuterOp;
443
+ target?: EnvelopeTargetMeta;
325
444
  }): CentrsSuccessEnvelope<FanoutData<Data, Op>, OuterOp> {
445
+ const { settings, operation, targets } = compactFanoutTargets(
446
+ input.settings,
447
+ input.operation,
448
+ input.targets,
449
+ );
326
450
  return {
327
451
  ok: true,
328
- data: { summary: input.summary, targets: input.targets },
452
+ data: { summary: input.summary, targets },
329
453
  warnings: input.warnings,
330
454
  tips: [],
331
455
  meta: {
332
- target: {},
456
+ target: input.target ?? {},
333
457
  via: input.via,
334
- settings: input.settings,
335
- operation: input.operation,
458
+ settings,
459
+ operation,
336
460
  },
337
461
  };
338
462
  }
package/src/errors.ts CHANGED
@@ -265,11 +265,12 @@ export function serializeUnknownError(error: unknown): unknown {
265
265
  return serializeCentrsError(error);
266
266
  }
267
267
 
268
+ // No `stack`: it is raw exception text with local install paths, and the
269
+ // constitution summarizes those rather than embedding them (#476).
268
270
  if (error instanceof Error) {
269
271
  return {
270
272
  name: error.name,
271
273
  message: error.message,
272
- stack: error.stack,
273
274
  code: extractErrorCode(error),
274
275
  };
275
276
  }