@tikoci/centrs 0.1.8 → 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.
- package/CHANGELOG.md +216 -31
- package/GLOSSARY.txt +8 -0
- package/README.md +16 -8
- package/SECURITY.md +11 -3
- package/commands/api/README.md +44 -15
- package/commands/api/examples.md +44 -6
- package/commands/execute/README.md +218 -7
- package/commands/execute/examples.md +278 -1
- package/commands/explain/README.md +2 -1
- package/commands/mcp/README.md +11 -0
- package/commands/mcp/examples.md +6 -0
- package/commands/retrieve/README.md +32 -14
- package/commands/retrieve/examples.md +10 -6
- package/commands/transfer/examples.md +9 -0
- package/docs/CLI.md +10 -8
- package/docs/CONSTITUTION.md +54 -3
- package/docs/MATRIX.md +20 -3
- package/docs/errors/cdb/empty-group.md +19 -3
- package/docs/errors/cdb/empty-selection.md +9 -3
- package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
- package/docs/errors/cmr/cancelled.md +15 -0
- package/docs/errors/cmr/client-disconnected.md +19 -0
- package/docs/errors/cmr/client-script-not-validated.md +22 -0
- package/docs/errors/cmr/console-only.md +29 -0
- package/docs/errors/cmr/empty-selection.md +33 -0
- package/docs/errors/cmr/membership-changed.md +20 -0
- package/docs/errors/cmr/not-a-controller.md +26 -0
- package/docs/errors/cmr/outcome-unknown.md +29 -0
- package/docs/errors/cmr/output-truncated.md +16 -0
- package/docs/errors/cmr/policy-denied.md +31 -0
- package/docs/errors/cmr/rule-selects-every-device.md +32 -0
- package/docs/errors/cmr/selection-includes-controller.md +27 -0
- package/docs/errors/cmr/selection-incomplete.md +18 -0
- package/docs/errors/cmr/validation-incomplete.md +23 -0
- package/docs/errors/routeros/version-unsupported.md +4 -0
- package/docs/errors/transport/write-outcome-unknown.md +24 -0
- package/docs/errors/usage/flag-after-separator.md +34 -0
- package/docs/errors/usage/missing-labels.md +19 -0
- package/package.json +7 -5
- package/src/api-fanout.ts +18 -2
- package/src/api.ts +230 -71
- package/src/btest.ts +7 -2
- package/src/cli/api.ts +30 -16
- package/src/cli/common.ts +180 -1
- package/src/cli/devices.ts +0 -2
- package/src/cli/discover.ts +0 -1
- package/src/cli/execute.ts +176 -21
- package/src/cli/explain.ts +2 -0
- package/src/cli/mcp.ts +0 -1
- package/src/cli/retrieve.ts +40 -18
- package/src/cli/settings.ts +0 -3
- package/src/cli/terminal.ts +0 -2
- package/src/cli/transfer.ts +10 -8
- package/src/cli/version.ts +87 -0
- package/src/cli.ts +5 -3
- package/src/core/cmr-fleet.ts +486 -0
- package/src/core/cmr.ts +182 -0
- package/src/core/envelope.ts +46 -1
- package/src/core/error-catalog.ts +95 -2
- package/src/core/execute-command.ts +144 -19
- package/src/core/fanout.ts +207 -14
- package/src/errors.ts +3 -1
- package/src/execute-cmr.ts +1442 -0
- package/src/execute-fanout.ts +15 -1
- package/src/execute.ts +173 -40
- package/src/index.ts +35 -1
- package/src/mcp/safety.ts +11 -0
- package/src/mcp/tools.ts +4 -1
- package/src/protocols/adapter.ts +54 -12
- package/src/resolver/cdb.ts +13 -1
- package/src/resolver/index.ts +4 -0
- package/src/resolver/selection.ts +37 -1
- package/src/resolver/target.ts +96 -8
- package/src/retrieve-fanout.ts +4 -1
- package/src/retrieve-follow.ts +29 -33
- package/src/retrieve-sample.ts +14 -8
- package/src/retrieve-stream.ts +38 -6
- package/src/retrieve.ts +73 -59
- package/src/transfer-fanout.ts +8 -1
- package/src/transfer.ts +15 -19
package/src/execute-fanout.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
buildFanoutResolveFailure,
|
|
34
34
|
commonVia,
|
|
35
35
|
defaultFanoutSleep,
|
|
36
|
+
mayRetryUnsentWrite,
|
|
36
37
|
resolveFanoutConcurrency,
|
|
37
38
|
runFanout,
|
|
38
39
|
summarizeFanout,
|
|
@@ -59,9 +60,11 @@ import {
|
|
|
59
60
|
} from "./execute.ts";
|
|
60
61
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
61
62
|
import {
|
|
63
|
+
emptyWriteSelectionError,
|
|
62
64
|
expandSelection,
|
|
63
65
|
isDefaultRecordTarget,
|
|
64
66
|
loadEnvFileDefaults,
|
|
67
|
+
redactTargetInput,
|
|
65
68
|
type SelectionExpansion,
|
|
66
69
|
type SelectionMember,
|
|
67
70
|
type SelectionResolveInput,
|
|
@@ -84,6 +87,8 @@ export interface ExecuteFanoutOperationMeta {
|
|
|
84
87
|
concurrency: number;
|
|
85
88
|
summary: FanoutSummary;
|
|
86
89
|
request: ExecuteRequestSummary;
|
|
90
|
+
/** Hoisted from the targets when every target used the same login (#461). */
|
|
91
|
+
auth?: { username?: string; passwordProvided: boolean };
|
|
87
92
|
selection: ExecuteSelectionSummary;
|
|
88
93
|
}
|
|
89
94
|
|
|
@@ -105,6 +110,7 @@ export interface ExecuteFanoutInternals {
|
|
|
105
110
|
) => Promise<SelectionExpansion>;
|
|
106
111
|
execute?: (
|
|
107
112
|
resolved: ResolvedExecuteRequest,
|
|
113
|
+
trace: ValidationTrace,
|
|
108
114
|
) => Promise<ExecuteSuccessEnvelope>;
|
|
109
115
|
sleep?: (ms: number) => Promise<void>;
|
|
110
116
|
}
|
|
@@ -123,7 +129,7 @@ function summarizeSelection(
|
|
|
123
129
|
where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
|
|
124
130
|
all: selection.all,
|
|
125
131
|
default: selection.default,
|
|
126
|
-
positionals: selection.positionals,
|
|
132
|
+
positionals: selection.positionals.map((value) => redactTargetInput(value)),
|
|
127
133
|
quickchr: selection.quickchr,
|
|
128
134
|
};
|
|
129
135
|
}
|
|
@@ -223,6 +229,12 @@ export async function executeFanout(
|
|
|
223
229
|
const concurrency = resolveFanoutConcurrency(options.concurrency, via);
|
|
224
230
|
|
|
225
231
|
if (expansion.empty) {
|
|
232
|
+
if (global.summary.write) {
|
|
233
|
+
throw emptyWriteSelectionError(
|
|
234
|
+
expansion,
|
|
235
|
+
`\`${global.summary.command}\``,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
226
238
|
return executeFanoutEnvelope({
|
|
227
239
|
selection: selectionSummary,
|
|
228
240
|
concurrency,
|
|
@@ -308,6 +320,8 @@ export async function executeFanout(
|
|
|
308
320
|
),
|
|
309
321
|
member,
|
|
310
322
|
),
|
|
323
|
+
mayRetry: (resolved) =>
|
|
324
|
+
mayRetryUnsentWrite(global.summary.write, traces.get(resolved)),
|
|
311
325
|
sleep,
|
|
312
326
|
});
|
|
313
327
|
|
package/src/execute.ts
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertNotCmrDashboardIl,
|
|
3
|
+
assertNotCmrDashboardPath,
|
|
4
|
+
cmrRunScriptWarnings,
|
|
5
|
+
cmrScriptWarnings,
|
|
6
|
+
} from "./core/cmr.ts";
|
|
7
|
+
import {
|
|
8
|
+
type CmrFleetRead,
|
|
9
|
+
cmrFleetCommand,
|
|
10
|
+
cmrFleetGate,
|
|
11
|
+
cmrFleetReadable,
|
|
12
|
+
readCmrFleet,
|
|
13
|
+
} from "./core/cmr-fleet.ts";
|
|
1
14
|
import type {
|
|
2
15
|
CentrsEnvelope,
|
|
3
16
|
CentrsErrorEnvelope,
|
|
@@ -16,6 +29,7 @@ import {
|
|
|
16
29
|
canonicalizeExecuteCommand,
|
|
17
30
|
isWriteShaped,
|
|
18
31
|
} from "./core/execute-command.ts";
|
|
32
|
+
import { sentWriteWarnings } from "./core/fanout.ts";
|
|
19
33
|
import { inspectArgumentNames, pathTokens } from "./core/inspect.ts";
|
|
20
34
|
import { mapRouterOsResultError } from "./core/routeros-errors.ts";
|
|
21
35
|
import { routerOsStringLiteral } from "./core/routeros-string.ts";
|
|
@@ -37,6 +51,7 @@ import {
|
|
|
37
51
|
getProtocolPlan,
|
|
38
52
|
type ProtocolAdapter,
|
|
39
53
|
type ProtocolExecuteResult,
|
|
54
|
+
type ProtocolSendOptions,
|
|
40
55
|
plannedProtocols,
|
|
41
56
|
type RouterOsProtocol,
|
|
42
57
|
} from "./protocols/index.ts";
|
|
@@ -57,6 +72,7 @@ import {
|
|
|
57
72
|
type ResolvedAuth,
|
|
58
73
|
type ResolvedSetting,
|
|
59
74
|
type ResolvedTarget,
|
|
75
|
+
redactTargetInput,
|
|
60
76
|
resolveAuth,
|
|
61
77
|
resolveBooleanSetting,
|
|
62
78
|
resolveCdb,
|
|
@@ -163,6 +179,8 @@ export interface ResolvedExecuteRequest {
|
|
|
163
179
|
yes: boolean;
|
|
164
180
|
verbose: boolean;
|
|
165
181
|
warnings: readonly Warning[];
|
|
182
|
+
/** A CMR fleet command's reach, read before the write gate (#453). */
|
|
183
|
+
cmrFleet?: CmrFleetRead;
|
|
166
184
|
}
|
|
167
185
|
|
|
168
186
|
export async function execute(
|
|
@@ -194,6 +212,7 @@ export async function executeEnvelope(
|
|
|
194
212
|
const trace: ValidationTrace = {};
|
|
195
213
|
try {
|
|
196
214
|
resolved = await resolveExecuteRequest(request, env);
|
|
215
|
+
resolved = await withCmrFleetScope(resolved);
|
|
197
216
|
await assertWriteConfirmed(request, resolved);
|
|
198
217
|
return await runResolvedExecute(resolved, trace);
|
|
199
218
|
} catch (error) {
|
|
@@ -288,7 +307,11 @@ export async function runResolvedExecute(
|
|
|
288
307
|
// reports the gate that actually passed rather than a guess.
|
|
289
308
|
trace.validation = validation;
|
|
290
309
|
|
|
291
|
-
const result = await runCommand(resolved, backend
|
|
310
|
+
const result = await runCommand(resolved, backend, {
|
|
311
|
+
onSent: () => {
|
|
312
|
+
trace.dispatched = true;
|
|
313
|
+
},
|
|
314
|
+
});
|
|
292
315
|
const routerOsFailure = routerOsFailureFromResult(
|
|
293
316
|
result,
|
|
294
317
|
resolved.via.value,
|
|
@@ -319,6 +342,7 @@ export async function resolveExecuteRequest(
|
|
|
319
342
|
): Promise<ResolvedExecuteRequest> {
|
|
320
343
|
validateExecuteRequestShape(request);
|
|
321
344
|
const canonical = canonicalizeExecuteCommand(request.command);
|
|
345
|
+
assertNotStructuredDashboard(canonical);
|
|
322
346
|
const config = options.config ?? (await loadEnvFileDefaults(env));
|
|
323
347
|
// A quickchr target is a named-live-provider: it supplies host/port/auth from
|
|
324
348
|
// the live descriptor and bypasses CDB/env resolution for those fields
|
|
@@ -450,9 +474,12 @@ export async function resolveExecuteRequest(
|
|
|
450
474
|
maxResultsBytes,
|
|
451
475
|
yes: request.yes ?? false,
|
|
452
476
|
verbose: request.verbose ?? false,
|
|
453
|
-
warnings:
|
|
454
|
-
?
|
|
455
|
-
|
|
477
|
+
warnings: [
|
|
478
|
+
...(connection ? connection.warnings : (cdbResolution?.warnings ?? [])),
|
|
479
|
+
...(canonical.mode === "structured"
|
|
480
|
+
? cmrRunScriptWarnings(`${canonical.path}/${canonical.verb}`)
|
|
481
|
+
: cmrScriptWarnings(canonical.input)),
|
|
482
|
+
],
|
|
456
483
|
};
|
|
457
484
|
}
|
|
458
485
|
|
|
@@ -477,6 +504,7 @@ export function resolveExecuteGlobalContext(
|
|
|
477
504
|
): ExecuteGlobalContext {
|
|
478
505
|
validateExecuteRequestShape(request);
|
|
479
506
|
const canonical = canonicalizeExecuteCommand(request.command);
|
|
507
|
+
assertNotStructuredDashboard(canonical);
|
|
480
508
|
const via = resolveExecuteProtocol(request, env, undefined, config);
|
|
481
509
|
const format = resolveFormat(request, env, config);
|
|
482
510
|
const validate = resolveBooleanSetting(
|
|
@@ -556,7 +584,7 @@ export function buildExecuteErrorEnvelope(
|
|
|
556
584
|
warnings: [],
|
|
557
585
|
tips: [],
|
|
558
586
|
meta: {
|
|
559
|
-
target: { input: request.targetInput },
|
|
587
|
+
target: { input: redactTargetInput(request.targetInput) },
|
|
560
588
|
via: requestedVia,
|
|
561
589
|
settings: {},
|
|
562
590
|
operation: {
|
|
@@ -599,7 +627,14 @@ export function buildExecuteErrorEnvelopeFromResolved(
|
|
|
599
627
|
return {
|
|
600
628
|
ok: false,
|
|
601
629
|
error: serializeCentrsError(centrsError),
|
|
602
|
-
warnings: [
|
|
630
|
+
warnings: [
|
|
631
|
+
...resolved.warnings,
|
|
632
|
+
...sentWriteWarnings(
|
|
633
|
+
isWriteShaped(resolved.canonical),
|
|
634
|
+
centrsError,
|
|
635
|
+
trace,
|
|
636
|
+
),
|
|
637
|
+
],
|
|
603
638
|
tips: resolvedExecuteTips(resolved),
|
|
604
639
|
meta: metaFromResolved(
|
|
605
640
|
resolved,
|
|
@@ -729,7 +764,7 @@ function deviceStageSource(resolved: ResolvedExecuteRequest): string {
|
|
|
729
764
|
* judge. An absent verdict means the caller did not thread a trace, so say
|
|
730
765
|
* nothing extra rather than invent the reason.
|
|
731
766
|
*/
|
|
732
|
-
function offlineStagePassed(
|
|
767
|
+
export function offlineStagePassed(
|
|
733
768
|
verdict: ValidationTrace["offlineVerdict"],
|
|
734
769
|
): EnvelopeValidationStage {
|
|
735
770
|
return {
|
|
@@ -748,7 +783,7 @@ function offlineStagePassed(
|
|
|
748
783
|
* a consumer reading `stages` never has to special-case the disabled shape.
|
|
749
784
|
* `reason` is left off — `enabled: false` already says why.
|
|
750
785
|
*/
|
|
751
|
-
function disabledValidationMeta(): EnvelopeValidationMeta {
|
|
786
|
+
export function disabledValidationMeta(): EnvelopeValidationMeta {
|
|
752
787
|
return {
|
|
753
788
|
enabled: false,
|
|
754
789
|
source: "disabled",
|
|
@@ -774,7 +809,7 @@ export function renderExecuteEnvelope(
|
|
|
774
809
|
return toYaml(envelope);
|
|
775
810
|
case "text":
|
|
776
811
|
return envelope.ok
|
|
777
|
-
? renderExecuteSuccessText(envelope
|
|
812
|
+
? renderExecuteSuccessText(envelope)
|
|
778
813
|
: formatCentrsErrorText(envelope.error, options);
|
|
779
814
|
default:
|
|
780
815
|
return exhaustiveOutputFormat(format);
|
|
@@ -888,6 +923,7 @@ async function validateConsoleParseCommand(
|
|
|
888
923
|
});
|
|
889
924
|
// Throws validation/syntax or validation/unknown-attribute on a bad parse.
|
|
890
925
|
classifyParseResult(result.ret ?? "", resolved.command, resolved.via.value);
|
|
926
|
+
assertNotCmrDashboardIl(result.ret ?? "", resolved.command);
|
|
891
927
|
return {
|
|
892
928
|
enabled: true,
|
|
893
929
|
source: `:put [:parse ...] over ${resolved.via.value}`,
|
|
@@ -908,7 +944,7 @@ async function validateConsoleParseCommand(
|
|
|
908
944
|
* `warn` is an abstention, and saying so is the difference between "the analyzer
|
|
909
945
|
* read all of this" and "it declined to judge part of it".
|
|
910
946
|
*/
|
|
911
|
-
function passedStages(
|
|
947
|
+
export function passedStages(
|
|
912
948
|
offline: OfflineGateResult,
|
|
913
949
|
deviceSource: string,
|
|
914
950
|
): EnvelopeValidationStage[] {
|
|
@@ -939,6 +975,7 @@ export async function validateRouterOsScript(
|
|
|
939
975
|
via: RouterOsProtocol,
|
|
940
976
|
): Promise<void> {
|
|
941
977
|
const script = `:put [:parse ${routerOsStringLiteral(command)}]`;
|
|
978
|
+
let il: string;
|
|
942
979
|
try {
|
|
943
980
|
// GH#230: `:parse` never throws (CHR 7.23.3). The diagnostic rides the
|
|
944
981
|
// success value's text — over REST/native the `ret` field (HTTP 200 /
|
|
@@ -947,7 +984,8 @@ export async function validateRouterOsScript(
|
|
|
947
984
|
// Read the return and classify it: one `:parse` covers syntax and the
|
|
948
985
|
// unknown-attribute (name-level) gate.
|
|
949
986
|
const result = await backend.execute({ path: "", command: "", script });
|
|
950
|
-
|
|
987
|
+
il = result.ret ?? "";
|
|
988
|
+
classifyParseResult(il, command, via);
|
|
951
989
|
} catch (error) {
|
|
952
990
|
// The preflight is a syntax gate, but `backend.execute` also surfaces
|
|
953
991
|
// connection and authentication failures (login happens lazily here).
|
|
@@ -984,6 +1022,20 @@ export async function validateRouterOsScript(
|
|
|
984
1022
|
causeData: syntaxCause(error),
|
|
985
1023
|
});
|
|
986
1024
|
}
|
|
1025
|
+
assertNotCmrDashboardIl(il, command);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* The plain spellings of the dashboard are refused before anything is dialed.
|
|
1030
|
+
* Every other spelling (a relative menu change, a block, a substitution) is
|
|
1031
|
+
* found in the `:parse` return, so it is refused by validation.
|
|
1032
|
+
*/
|
|
1033
|
+
function assertNotStructuredDashboard(
|
|
1034
|
+
canonical: CanonicalExecuteCommand,
|
|
1035
|
+
): void {
|
|
1036
|
+
if (canonical.mode === "structured") {
|
|
1037
|
+
assertNotCmrDashboardPath(`${canonical.path}/${canonical.verb}`);
|
|
1038
|
+
}
|
|
987
1039
|
}
|
|
988
1040
|
|
|
989
1041
|
/**
|
|
@@ -998,22 +1050,32 @@ function isPreflightTransportError(error: CentrsError): boolean {
|
|
|
998
1050
|
async function runCommand(
|
|
999
1051
|
resolved: ResolvedExecuteRequest,
|
|
1000
1052
|
backend: ProtocolAdapter,
|
|
1053
|
+
options: ProtocolSendOptions,
|
|
1001
1054
|
): Promise<ProtocolExecuteResult> {
|
|
1002
1055
|
if (resolved.via.value === "mac-telnet" || resolved.via.value === "ssh") {
|
|
1003
1056
|
// Console transports: run the raw CLI line and capture the console output
|
|
1004
1057
|
// (structured path-POST / tagged talk do not apply). ssh runs it as a
|
|
1005
1058
|
// single `ssh host "<command>"`; mac-telnet drives the L2 console.
|
|
1006
|
-
return backend.execute(
|
|
1059
|
+
return backend.execute(
|
|
1060
|
+
{ path: "", command: "", script: resolved.command },
|
|
1061
|
+
options,
|
|
1062
|
+
);
|
|
1007
1063
|
}
|
|
1008
1064
|
if (resolved.canonical.mode === "script") {
|
|
1009
|
-
return backend.execute(
|
|
1065
|
+
return backend.execute(
|
|
1066
|
+
{ path: "", command: "", script: resolved.command },
|
|
1067
|
+
options,
|
|
1068
|
+
);
|
|
1010
1069
|
}
|
|
1011
|
-
return backend.execute(
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1070
|
+
return backend.execute(
|
|
1071
|
+
{
|
|
1072
|
+
path: resolved.canonical.path,
|
|
1073
|
+
command: resolved.canonical.verb,
|
|
1074
|
+
attributes: resolved.canonical.attributes,
|
|
1075
|
+
queries: resolved.canonical.queries,
|
|
1076
|
+
},
|
|
1077
|
+
options,
|
|
1078
|
+
);
|
|
1017
1079
|
}
|
|
1018
1080
|
|
|
1019
1081
|
async function assertWriteConfirmed(
|
|
@@ -1023,9 +1085,13 @@ async function assertWriteConfirmed(
|
|
|
1023
1085
|
if (!isWriteShaped(resolved.canonical) || resolved.yes) {
|
|
1024
1086
|
return;
|
|
1025
1087
|
}
|
|
1088
|
+
const fleet = resolved.cmrFleet
|
|
1089
|
+
? cmrFleetGate(resolved.cmrFleet, targetName(resolved))
|
|
1090
|
+
: undefined;
|
|
1026
1091
|
if (request.stdinIsTty ?? process.stdin.isTTY) {
|
|
1027
1092
|
const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
|
|
1028
|
-
|
|
1093
|
+
fleet?.prompt ??
|
|
1094
|
+
`Run write-shaped RouterOS command over ${resolved.via.value}? Type yes to continue: `,
|
|
1029
1095
|
);
|
|
1030
1096
|
if (confirmed) {
|
|
1031
1097
|
return;
|
|
@@ -1034,6 +1100,7 @@ async function assertWriteConfirmed(
|
|
|
1034
1100
|
throw new CentrsError({
|
|
1035
1101
|
code: "usage/confirmation-required",
|
|
1036
1102
|
summary:
|
|
1103
|
+
fleet?.summary ??
|
|
1037
1104
|
"Write-shaped RouterOS execute commands require explicit confirmation.",
|
|
1038
1105
|
remediation:
|
|
1039
1106
|
"Pass `--yes` in non-interactive automation, or answer `yes` at the TTY prompt after reviewing the command.",
|
|
@@ -1042,10 +1109,75 @@ async function assertWriteConfirmed(
|
|
|
1042
1109
|
path: resolved.canonical.path,
|
|
1043
1110
|
verb: resolved.canonical.verb,
|
|
1044
1111
|
via: resolved.via.value,
|
|
1112
|
+
...fleet?.context,
|
|
1045
1113
|
},
|
|
1046
1114
|
});
|
|
1047
1115
|
}
|
|
1048
1116
|
|
|
1117
|
+
function targetName(resolved: ResolvedExecuteRequest): string {
|
|
1118
|
+
return (
|
|
1119
|
+
resolved.target.identity ?? resolved.target.input ?? resolved.target.host
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* #453: a write-shaped CMR fleet command (`/cmr/device/reboot labels=all`) is
|
|
1125
|
+
* one request to the controller that acts on every device it selects. Read
|
|
1126
|
+
* the controller's inventory before the gate so the gate names that reach,
|
|
1127
|
+
* and add the fleet warnings, with or without `--yes`. Structured input only:
|
|
1128
|
+
* a script's selectors (`[find …]`, positionals) are not resolved.
|
|
1129
|
+
*/
|
|
1130
|
+
async function withCmrFleetScope(
|
|
1131
|
+
resolved: ResolvedExecuteRequest,
|
|
1132
|
+
): Promise<ResolvedExecuteRequest> {
|
|
1133
|
+
const { canonical } = resolved;
|
|
1134
|
+
if (canonical.mode !== "structured" || !isWriteShaped(canonical)) {
|
|
1135
|
+
return resolved;
|
|
1136
|
+
}
|
|
1137
|
+
const command = cmrFleetCommand(
|
|
1138
|
+
`${canonical.path}/${canonical.verb}`,
|
|
1139
|
+
canonical.attributes,
|
|
1140
|
+
);
|
|
1141
|
+
if (!command) return resolved;
|
|
1142
|
+
if (!cmrFleetReadable(resolved.via.value)) {
|
|
1143
|
+
return {
|
|
1144
|
+
...resolved,
|
|
1145
|
+
cmrFleet: {
|
|
1146
|
+
scope: {
|
|
1147
|
+
command: command.command,
|
|
1148
|
+
unresolved: `${resolved.via.value} has no structured read`,
|
|
1149
|
+
},
|
|
1150
|
+
warnings: [],
|
|
1151
|
+
},
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
const adapter = createProtocolAdapter({
|
|
1155
|
+
protocol: resolved.via.value,
|
|
1156
|
+
host: resolved.target.host,
|
|
1157
|
+
port: resolved.target.port,
|
|
1158
|
+
tls: resolved.target.tls,
|
|
1159
|
+
baseUrl: resolved.target.baseUrl,
|
|
1160
|
+
username: resolved.auth.username,
|
|
1161
|
+
password: resolved.auth.password,
|
|
1162
|
+
timeoutMs: resolved.timeoutMs.value,
|
|
1163
|
+
insecure: resolved.insecure.value,
|
|
1164
|
+
});
|
|
1165
|
+
try {
|
|
1166
|
+
const cmrFleet = await readCmrFleet(
|
|
1167
|
+
command,
|
|
1168
|
+
adapter,
|
|
1169
|
+
resolved.auth.username,
|
|
1170
|
+
);
|
|
1171
|
+
return {
|
|
1172
|
+
...resolved,
|
|
1173
|
+
cmrFleet,
|
|
1174
|
+
warnings: [...resolved.warnings, ...cmrFleet.warnings],
|
|
1175
|
+
};
|
|
1176
|
+
} finally {
|
|
1177
|
+
await adapter.close();
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1049
1181
|
export async function promptForWriteConfirmation(
|
|
1050
1182
|
prompt: string,
|
|
1051
1183
|
): Promise<boolean> {
|
|
@@ -1399,27 +1531,28 @@ function applyMaxResultsBudget(
|
|
|
1399
1531
|
return envelope;
|
|
1400
1532
|
}
|
|
1401
1533
|
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1534
|
+
/** Text mode renders only the data; the CLI writes the rest to stderr (#470). */
|
|
1535
|
+
function renderExecuteSuccessText(envelope: ExecuteSuccessEnvelope): string {
|
|
1536
|
+
return typeof envelope.data === "string"
|
|
1537
|
+
? envelope.data
|
|
1538
|
+
: JSON.stringify(envelope.data, null, 2);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* The `--verbose` header of a text-mode execute result (stderr, #470). A
|
|
1543
|
+
* failure after resolution still names its target; one before has no
|
|
1544
|
+
* transport (`via: null`) and no header.
|
|
1545
|
+
*/
|
|
1546
|
+
export function executeVerboseLines(envelope: ExecuteEnvelope): string[] {
|
|
1547
|
+
if (envelope.meta.via === null) return [];
|
|
1548
|
+
const lines = [
|
|
1549
|
+
`target: ${envelope.meta.target.input ?? envelope.meta.target.host}`,
|
|
1550
|
+
`via: ${envelope.meta.via}`,
|
|
1551
|
+
];
|
|
1552
|
+
if (envelope.meta.validation?.source) {
|
|
1553
|
+
lines.push(`validation: ${envelope.meta.validation.source}`);
|
|
1421
1554
|
}
|
|
1422
|
-
return lines
|
|
1555
|
+
return lines;
|
|
1423
1556
|
}
|
|
1424
1557
|
|
|
1425
1558
|
function syntaxCause(error: unknown): unknown {
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,8 @@ export {
|
|
|
6
6
|
type ApiOutputFormat,
|
|
7
7
|
type ApiRequest,
|
|
8
8
|
type ApiRequestSummary,
|
|
9
|
+
type ApiStreamFrameEnvelope,
|
|
10
|
+
type ApiStreamLine,
|
|
9
11
|
type ApiStreamMeta,
|
|
10
12
|
type ApiStreamStopReason,
|
|
11
13
|
type ApiStreamSummary,
|
|
@@ -16,6 +18,7 @@ export {
|
|
|
16
18
|
apiOutputFormats,
|
|
17
19
|
apiRequestSummaryFromRequest,
|
|
18
20
|
apiStream,
|
|
21
|
+
apiVerboseLines,
|
|
19
22
|
buildApiBody,
|
|
20
23
|
buildApiErrorEnvelope,
|
|
21
24
|
buildApiErrorEnvelopeFromResolved,
|
|
@@ -81,10 +84,12 @@ export {
|
|
|
81
84
|
type EnvelopeValidationMeta,
|
|
82
85
|
type EnvelopeValidationStage,
|
|
83
86
|
type FanoutData,
|
|
87
|
+
type FanoutMemberOperation,
|
|
84
88
|
type FanoutSummary,
|
|
85
89
|
normalizeWarnings,
|
|
86
90
|
type SettingSource,
|
|
87
91
|
type SettingSourceKind,
|
|
92
|
+
type StreamLineEnvelope,
|
|
88
93
|
type ValidationTrace,
|
|
89
94
|
type Warning,
|
|
90
95
|
} from "./core/envelope.ts";
|
|
@@ -92,6 +97,8 @@ export {
|
|
|
92
97
|
buildFanoutEnvelope,
|
|
93
98
|
buildFanoutResolveFailure,
|
|
94
99
|
commonVia,
|
|
100
|
+
compactFanoutTargets,
|
|
101
|
+
expandFanoutTarget,
|
|
95
102
|
FANOUT_BACKOFF_BASE_MS,
|
|
96
103
|
FANOUT_CONCURRENCY_DEFAULTS,
|
|
97
104
|
FANOUT_MAX_RETRIES,
|
|
@@ -279,6 +286,7 @@ export {
|
|
|
279
286
|
execute,
|
|
280
287
|
executeEnvelope,
|
|
281
288
|
executeOutputFormats,
|
|
289
|
+
executeVerboseLines,
|
|
282
290
|
isWriteShaped,
|
|
283
291
|
renderExecuteEnvelope,
|
|
284
292
|
resolveExecuteGlobalContext,
|
|
@@ -286,6 +294,23 @@ export {
|
|
|
286
294
|
runResolvedExecute,
|
|
287
295
|
validateExecuteRequestShape,
|
|
288
296
|
} from "./execute.ts";
|
|
297
|
+
export {
|
|
298
|
+
buildExecuteCmrErrorEnvelope,
|
|
299
|
+
CMR_VALIDATION_SOURCE,
|
|
300
|
+
type CmrController,
|
|
301
|
+
type CmrDevice,
|
|
302
|
+
type CmrScriptCall,
|
|
303
|
+
type CmrScriptOutcome,
|
|
304
|
+
type CmrScriptRow,
|
|
305
|
+
type ExecuteCmrData,
|
|
306
|
+
type ExecuteCmrEnvelope,
|
|
307
|
+
type ExecuteCmrErrorEnvelope,
|
|
308
|
+
type ExecuteCmrInternals,
|
|
309
|
+
type ExecuteCmrOperationMeta,
|
|
310
|
+
type ExecuteCmrRequest,
|
|
311
|
+
executeCmr,
|
|
312
|
+
renderExecuteCmrEnvelope,
|
|
313
|
+
} from "./execute-cmr.ts";
|
|
289
314
|
export {
|
|
290
315
|
buildExecuteFanoutErrorEnvelope,
|
|
291
316
|
type ExecuteFanoutData,
|
|
@@ -587,6 +612,7 @@ export {
|
|
|
587
612
|
type RetrieveEnvelope,
|
|
588
613
|
type RetrieveErrorEnvelope,
|
|
589
614
|
type RetrieveFollowCounts,
|
|
615
|
+
type RetrieveFollowLine,
|
|
590
616
|
type RetrieveFollowSettings,
|
|
591
617
|
type RetrieveFollowStopReason,
|
|
592
618
|
type RetrieveFollowSummary,
|
|
@@ -599,15 +625,19 @@ export {
|
|
|
599
625
|
type RetrieveSampleSettings,
|
|
600
626
|
type RetrieveSampleStopReason,
|
|
601
627
|
type RetrieveSampleSummary,
|
|
628
|
+
type RetrieveStreamLine,
|
|
629
|
+
type RetrieveStreamLineEnvelope,
|
|
602
630
|
type RetrieveStreamMeta,
|
|
603
631
|
type RetrieveSuccessEnvelope,
|
|
604
632
|
type RetrieveWarning,
|
|
605
633
|
renderRetrieveEnvelope,
|
|
606
634
|
resolveRetrieveGlobalContext,
|
|
635
|
+
resolveRetrieveOutputFormat,
|
|
607
636
|
resolveRetrieveRequest,
|
|
608
637
|
retrieve,
|
|
609
638
|
retrieveEnvelope,
|
|
610
639
|
retrieveOutputFormats,
|
|
640
|
+
retrieveVerboseLines,
|
|
611
641
|
runResolvedRetrieve,
|
|
612
642
|
validateRetrieveRequestShape,
|
|
613
643
|
} from "./retrieve.ts";
|
|
@@ -633,7 +663,10 @@ export {
|
|
|
633
663
|
type RetrieveSampleOptions,
|
|
634
664
|
retrieveSample,
|
|
635
665
|
} from "./retrieve-sample.ts";
|
|
636
|
-
export {
|
|
666
|
+
export {
|
|
667
|
+
isStreamDataLine,
|
|
668
|
+
renderRetrieveStreamLine,
|
|
669
|
+
} from "./retrieve-stream.ts";
|
|
637
670
|
export { type RetrieveWaitOptions, retrieveWait } from "./retrieve-wait.ts";
|
|
638
671
|
export {
|
|
639
672
|
buildSettingsErrorEnvelope,
|
|
@@ -701,6 +734,7 @@ export {
|
|
|
701
734
|
transferEnvelope,
|
|
702
735
|
transferMethods,
|
|
703
736
|
transferOutputFormats,
|
|
737
|
+
transferVerboseLines,
|
|
704
738
|
transferVerbs,
|
|
705
739
|
validateTransferRequestShape,
|
|
706
740
|
} from "./transfer.ts";
|
package/src/mcp/safety.ts
CHANGED
|
@@ -19,6 +19,11 @@ import {
|
|
|
19
19
|
import { CentrsError } from "../errors.ts";
|
|
20
20
|
import type { McpWritePolicy } from "../mcp.ts";
|
|
21
21
|
import { parseCommentKv } from "../resolver/comment-kv.ts";
|
|
22
|
+
import {
|
|
23
|
+
assertNoTargetCredentials,
|
|
24
|
+
assertNoTargetPassword,
|
|
25
|
+
redactTargetInput,
|
|
26
|
+
} from "../resolver/target.ts";
|
|
22
27
|
import type { CentrsMcpConfig } from "./config.ts";
|
|
23
28
|
|
|
24
29
|
export interface RegisteredTarget {
|
|
@@ -68,10 +73,16 @@ export function resolveRegisteredTarget(
|
|
|
68
73
|
cdb: LoadedCdb,
|
|
69
74
|
target: string,
|
|
70
75
|
): RegisteredTarget {
|
|
76
|
+
// Before the lookup, whose refusal echoes the target (#478).
|
|
77
|
+
assertNoTargetPassword(target);
|
|
71
78
|
let entry: DevicesShowItem;
|
|
72
79
|
try {
|
|
73
80
|
entry = showDevice({ cdb, target }).data.entry;
|
|
74
81
|
} catch (error) {
|
|
82
|
+
// Preserve registered identities; mask credential-shaped lookup failures.
|
|
83
|
+
if (redactTargetInput(target) !== target) {
|
|
84
|
+
assertNoTargetCredentials(target);
|
|
85
|
+
}
|
|
75
86
|
if (error instanceof CentrsError && error.code === "cdb/not-found-target") {
|
|
76
87
|
throw new CentrsError({
|
|
77
88
|
code: "cdb/target-not-registered",
|
package/src/mcp/tools.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
import type { CommentKvUpdate } from "../resolver/comment-kv.ts";
|
|
37
37
|
import { parseBbox, parseNear } from "../resolver/geo.ts";
|
|
38
38
|
import { parseDuration } from "../resolver/settings.ts";
|
|
39
|
+
import { redactTargetInput } from "../resolver/target.ts";
|
|
39
40
|
import { retrieveEnvelope } from "../retrieve.ts";
|
|
40
41
|
import {
|
|
41
42
|
buildRetrieveFanoutErrorEnvelope,
|
|
@@ -89,7 +90,9 @@ function mcpErrorEnvelope(
|
|
|
89
90
|
warnings: [],
|
|
90
91
|
tips: [],
|
|
91
92
|
meta: {
|
|
92
|
-
target: context.target
|
|
93
|
+
target: context.target
|
|
94
|
+
? { input: redactTargetInput(context.target) }
|
|
95
|
+
: {},
|
|
93
96
|
via: null,
|
|
94
97
|
settings: {},
|
|
95
98
|
operation: { kind: "mcp", tool },
|