metergraph-cli 0.2.1 → 0.2.2
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/README.md +12 -7
- package/assets/skill/SKILL.md +52 -3
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +13 -13
- package/assets/skills/metergraph-analyze/SKILL.md +62 -4
- package/assets/skills/metergraph-investigate/SKILL.md +222 -35
- package/assets/skills/metergraph-onboarding/SKILL.md +7 -0
- package/assets/skills/metergraph-report/SKILL.md +19 -2
- package/package.json +1 -1
- package/src/agent-key.js +157 -0
- package/src/approval-handoff.js +5 -0
- package/src/args.js +373 -14
- package/src/auth-browser.js +5 -19
- package/src/auth-callback.js +24 -15
- package/src/auth-login.js +55 -12
- package/src/auth-oauth.js +20 -3
- package/src/callback-page.js +58 -0
- package/src/cli.js +94 -37
- package/src/constants.js +32 -3
- package/src/environment.js +169 -0
- package/src/origin.js +6 -0
- package/src/output.js +325 -130
- package/src/read-contract.js +953 -13
- package/src/read-output.js +449 -30
- package/src/read.js +297 -59
- package/src/setup-deployment.js +4 -3
- package/src/setup-refusal.js +172 -0
- package/src/setup-state.js +4 -2
- package/src/setup.js +227 -53
- package/src/skill-bundle.js +2 -2
- package/src/style.js +101 -0
- package/src/transport.js +8 -4
- package/src/usage-aggregates.js +197 -0
- package/src/verify-output.js +21 -11
package/src/args.js
CHANGED
|
@@ -21,9 +21,18 @@ import {
|
|
|
21
21
|
SKILL_CLIENTS,
|
|
22
22
|
SKILL_RUNTIMES,
|
|
23
23
|
TRACES_DEFAULT_LIMIT,
|
|
24
|
+
MAX_METADATA_FILTERS,
|
|
25
|
+
MAX_METADATA_KEY_LENGTH,
|
|
26
|
+
MAX_QUERY_LENGTH,
|
|
27
|
+
MAX_METADATA_VALUE_LENGTH,
|
|
28
|
+
USAGE_COMPARISONS,
|
|
29
|
+
USAGE_DEFAULT_GROUP_BY,
|
|
30
|
+
USAGE_DIMENSIONS,
|
|
31
|
+
USAGE_MAX_FILTER_VALUES,
|
|
32
|
+
USAGE_SORTS,
|
|
24
33
|
} from "./constants.js";
|
|
25
34
|
import { parseOrigin } from "./origin.js";
|
|
26
|
-
import { isCursor, isSafeFilter } from "./read-contract.js";
|
|
35
|
+
import { groupedRequest, hasUnsafeText, isCursor, isSafeFilter, readQuery } from "./read-contract.js";
|
|
27
36
|
|
|
28
37
|
export const READ_COMMANDS = Object.freeze(["status", "context", "capabilities", "usage", "routes", "traces"]);
|
|
29
38
|
const COMMANDS = new Set(["doctor", "help", "skill", "skills", "login", "logout", "setup", "verify", ...READ_COMMANDS]);
|
|
@@ -31,6 +40,10 @@ const HELP_TOPICS = new Set(["doctor", "skill", "skills", "login", "logout", "se
|
|
|
31
40
|
const SKILL_ACTIONS = new Set(["install", "update"]);
|
|
32
41
|
const SKILLS_ACTIONS = new Set(["install", "update", "list"]);
|
|
33
42
|
const READ_BASE = ["--project", "--config-dir", "--timeout-ms"];
|
|
43
|
+
// The read commands may name where an agent key is sent and which workspace
|
|
44
|
+
// it must be for, and a private file holding the key. The key itself is
|
|
45
|
+
// never an argument.
|
|
46
|
+
const READ_KEY = ["--url", "--workspace", "--agent-token-file"];
|
|
34
47
|
const OPTIONS = {
|
|
35
48
|
doctor: new Set(["--url", "--timeout-ms"]),
|
|
36
49
|
skill: new Set(["--client", "--runtime", "--project"]),
|
|
@@ -38,12 +51,17 @@ const OPTIONS = {
|
|
|
38
51
|
login: new Set(["--runtime", "--url", "--workspace", "--project", "--config-dir", "--timeout-ms"]),
|
|
39
52
|
setup: new Set(["--runtime", "--url", "--workspace", "--project", "--config-dir", "--env-file", "--client", "--timeout-ms", "--deployment", "--agent-token-file", "--repository"]),
|
|
40
53
|
logout: new Set(["--project", "--config-dir"]),
|
|
41
|
-
status: new Set(READ_BASE),
|
|
42
|
-
context: new Set(READ_BASE),
|
|
43
|
-
capabilities: new Set(READ_BASE),
|
|
44
|
-
usage: new Set([
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
status: new Set([...READ_BASE, ...READ_KEY]),
|
|
55
|
+
context: new Set([...READ_BASE, ...READ_KEY]),
|
|
56
|
+
capabilities: new Set([...READ_BASE, ...READ_KEY]),
|
|
57
|
+
usage: new Set([
|
|
58
|
+
...READ_BASE, ...READ_KEY, "--days", "--limit", "--metadata", "--metadata-json", "--group-by-metadata",
|
|
59
|
+
"--group-by", "--route", "--provider", "--model", "--environment", "--since", "--until", "--sort", "--compare",
|
|
60
|
+
]),
|
|
61
|
+
routes: new Set([...READ_BASE, ...READ_KEY, "--limit"]),
|
|
62
|
+
traces: new Set([
|
|
63
|
+
...READ_BASE, ...READ_KEY, "--days", "--limit", "--route", "--status", "--cursor", "--metadata", "--metadata-json",
|
|
64
|
+
]),
|
|
47
65
|
verify: new Set([...READ_BASE, "--trace-id", "--request-id", "--since", "--until", "--source", "--days", "--poll-ms", "--max-attempts"]),
|
|
48
66
|
};
|
|
49
67
|
// Requests the read commands recognize and refuse, so a script gets an
|
|
@@ -63,23 +81,35 @@ const REFUSED = {
|
|
|
63
81
|
};
|
|
64
82
|
const REFUSED_MESSAGES = {
|
|
65
83
|
environment_selector_unsupported:
|
|
66
|
-
"--environment is
|
|
67
|
-
"so the CLI never sends one. Results cover the bound workspace. No request was made.",
|
|
84
|
+
"--environment is supported only by usage, which sends it to the grouped usage read. This command has no " +
|
|
85
|
+
"environment selector, so the CLI never sends one. Results cover the bound workspace. No request was made.",
|
|
68
86
|
workload_filter_unsupported:
|
|
69
87
|
"--workload is not supported by this CLI version: it cannot verify from the returned traces that a " +
|
|
70
88
|
"workload filter was applied, so it never sends one. No request was made.",
|
|
71
89
|
time_range_unsupported:
|
|
72
|
-
"--since and --until are
|
|
90
|
+
"--since and --until are supported only by usage. Use --days N (1 to 90) for a window relative to now. " +
|
|
91
|
+
"No request was made.",
|
|
73
92
|
query_unsupported:
|
|
74
93
|
"Free-form queries are not supported. Use the fixed options of this command. No request was made.",
|
|
75
94
|
content_access_unsupported:
|
|
76
95
|
"Read commands return Metadata only. They never read retained content, debug data or replays. No request was made.",
|
|
77
96
|
};
|
|
97
|
+
// Options that may be given more than once; their values are kept in order.
|
|
98
|
+
const REPEATABLE = new Set(["--metadata"]);
|
|
99
|
+
// usage takes several values for each of its exact-match filters.
|
|
100
|
+
const USAGE_REPEATABLE = new Set(["--route", "--provider", "--model", "--environment"]);
|
|
101
|
+
// usage reads a time range and environments itself, through grouped usage.
|
|
102
|
+
const USAGE_ACCEPTS = new Set(["--environment", "--since", "--until"]);
|
|
103
|
+
// Options that select grouped usage (GET /v1/agent/usage/aggregates).
|
|
104
|
+
const USAGE_GROUPED = [
|
|
105
|
+
"--group-by", "--group-by-metadata", "--route", "--provider", "--model", "--environment", "--since", "--until", "--sort", "--compare",
|
|
106
|
+
];
|
|
78
107
|
// Options that take no value.
|
|
79
108
|
const FLAGS = {
|
|
80
109
|
login: new Set(["--signup", "--no-browser", "--reconnect"]),
|
|
81
110
|
setup: new Set(["--no-browser", "--repair", "--signup", "--reconnect", "--skip-skill", "--confirm-prerequisites", "--no-repository"]),
|
|
82
111
|
verify: new Set(["--open", "--no-browser"]),
|
|
112
|
+
usage: new Set(["--today"]),
|
|
83
113
|
};
|
|
84
114
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
85
115
|
|
|
@@ -156,7 +186,7 @@ export function parseArgs(argv) {
|
|
|
156
186
|
if (READ_COMMANDS.includes(command)) {
|
|
157
187
|
const equals = arg.indexOf("=");
|
|
158
188
|
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
159
|
-
if (Object.hasOwn(REFUSED, name)) {
|
|
189
|
+
if (Object.hasOwn(REFUSED, name) && !(command === "usage" && USAGE_ACCEPTS.has(name))) {
|
|
160
190
|
const { reason } = REFUSED[name];
|
|
161
191
|
return fail(reason, REFUSED_MESSAGES[reason], "unsupported");
|
|
162
192
|
}
|
|
@@ -182,6 +212,10 @@ export function parseArgs(argv) {
|
|
|
182
212
|
if (value === undefined) {
|
|
183
213
|
return fail("missing_value", `${name} requires a value.`);
|
|
184
214
|
}
|
|
215
|
+
if (REPEATABLE.has(name) || (command === "usage" && USAGE_REPEATABLE.has(name))) {
|
|
216
|
+
values[name] = [...(values[name] ?? []), value];
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
185
219
|
if (values[name] !== undefined) {
|
|
186
220
|
return fail("duplicate_option", `${name} may be given only once.`);
|
|
187
221
|
}
|
|
@@ -223,7 +257,7 @@ export function parseArgs(argv) {
|
|
|
223
257
|
if (command === "login") return parseLogin(values, flags, json, fail);
|
|
224
258
|
if (command === "setup") return parseSetup(values, flags, json, fail);
|
|
225
259
|
if (command === "verify") return parseVerify(values, flags, json, fail);
|
|
226
|
-
if (READ_COMMANDS.includes(command)) return parseRead(command, values, json, fail);
|
|
260
|
+
if (READ_COMMANDS.includes(command)) return parseRead(command, values, flags, json, fail);
|
|
227
261
|
if (command === "logout") {
|
|
228
262
|
const paths = parsePaths(values, fail);
|
|
229
263
|
if (!paths.ok) return paths;
|
|
@@ -452,7 +486,7 @@ function parseSetup(values, flags, json, fail) {
|
|
|
452
486
|
// Read commands take the project and config directory, one total timeout and,
|
|
453
487
|
// where the endpoint supports them, bounded days, limit and trace filters.
|
|
454
488
|
// Out of range values fail here; nothing is clamped.
|
|
455
|
-
function parseRead(command, values, json, fail) {
|
|
489
|
+
function parseRead(command, values, flags, json, fail) {
|
|
456
490
|
const paths = parsePaths(values, fail);
|
|
457
491
|
if (!paths.ok) return paths;
|
|
458
492
|
const rawTimeout = values["--timeout-ms"];
|
|
@@ -479,6 +513,29 @@ function parseRead(command, values, json, fail) {
|
|
|
479
513
|
limit = values["--limit"] === undefined ? fallback : parseBounded(values["--limit"], 1, READ_MAX_LIMIT);
|
|
480
514
|
if (limit === null) return fail("invalid_limit", `--limit must be a whole number from 1 to ${READ_MAX_LIMIT}.`);
|
|
481
515
|
}
|
|
516
|
+
// Where to read and with which key: the same for every read, grouped usage
|
|
517
|
+
// included.
|
|
518
|
+
let origin = null;
|
|
519
|
+
if (values["--url"] !== undefined) {
|
|
520
|
+
origin = parseOrigin(values["--url"]);
|
|
521
|
+
if (origin === null) return fail("invalid_url", INVALID_URL);
|
|
522
|
+
}
|
|
523
|
+
const rawWorkspace = values["--workspace"];
|
|
524
|
+
if (rawWorkspace !== undefined && !UUID.test(rawWorkspace)) {
|
|
525
|
+
return fail("invalid_workspace", "--workspace must be a workspace ID in UUID form.");
|
|
526
|
+
}
|
|
527
|
+
const agentTokenFile = values["--agent-token-file"] ?? null;
|
|
528
|
+
if (agentTokenFile !== null && (agentTokenFile === "" || agentTokenFile.includes("\0"))) {
|
|
529
|
+
return fail("invalid_agent_token_file", "--agent-token-file must name a private absolute file.");
|
|
530
|
+
}
|
|
531
|
+
const target = {
|
|
532
|
+
origin,
|
|
533
|
+
workspace: rawWorkspace === undefined ? null : rawWorkspace.toLowerCase(),
|
|
534
|
+
agentTokenFile,
|
|
535
|
+
};
|
|
536
|
+
if (command === "usage" && (USAGE_GROUPED.some((name) => values[name] !== undefined) || flags.has("--today"))) {
|
|
537
|
+
return parseGroupedUsage(values, flags, json, fail, { paths, timeoutMs, limit, target });
|
|
538
|
+
}
|
|
482
539
|
const route = values["--route"] ?? null;
|
|
483
540
|
if (route !== null && !isSafeFilter(route)) {
|
|
484
541
|
return fail("invalid_route", "--route must be 1 to 256 characters without control characters.");
|
|
@@ -494,9 +551,12 @@ function parseRead(command, values, json, fail) {
|
|
|
494
551
|
"--cursor must be the next_cursor value from an earlier traces result, at most 512 printable characters.",
|
|
495
552
|
);
|
|
496
553
|
}
|
|
497
|
-
|
|
554
|
+
const metadata = parseMetadata(values, fail);
|
|
555
|
+
if (!metadata.ok) return metadata;
|
|
556
|
+
const parsed = {
|
|
498
557
|
ok: true,
|
|
499
558
|
command,
|
|
559
|
+
...target,
|
|
500
560
|
project: paths.project,
|
|
501
561
|
configDir: paths.configDir,
|
|
502
562
|
timeoutMs,
|
|
@@ -505,8 +565,307 @@ function parseRead(command, values, json, fail) {
|
|
|
505
565
|
route,
|
|
506
566
|
status,
|
|
507
567
|
cursor,
|
|
568
|
+
metadata: metadata.filters,
|
|
569
|
+
groupByMetadata: null,
|
|
570
|
+
json,
|
|
571
|
+
};
|
|
572
|
+
// Measure the exact query the command will send (URLSearchParams encoding,
|
|
573
|
+
// every parameter), so an over-long one fails here and not after sign in.
|
|
574
|
+
const query = readQuery(parsed);
|
|
575
|
+
if (query !== null && query.toString().length > MAX_QUERY_LENGTH) {
|
|
576
|
+
return fail(
|
|
577
|
+
"query_too_long",
|
|
578
|
+
"The filters are too long to send in one request. Use fewer or shorter metadata values, or a shorter route or cursor.",
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
return parsed;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Grouped usage: server-side groups by provider, model, route and UTC day,
|
|
585
|
+
// with exact filters and a UTC window. The window is either a preset of whole
|
|
586
|
+
// UTC days (--days N or --today: the current UTC day so far and the N - 1
|
|
587
|
+
// whole UTC days before it) or an explicit --since/--until, never both.
|
|
588
|
+
// Ranking other than by cost and the prior-period comparison are computed
|
|
589
|
+
// from the returned groups and say when those groups are not all of them.
|
|
590
|
+
function parseGroupedUsage(values, flags, json, fail, { paths, timeoutMs, limit, target }) {
|
|
591
|
+
// A "metadata.<key>" entry takes the rest of the value as the key, so a key
|
|
592
|
+
// may contain commas and dots; it must come last. --group-by-metadata KEY
|
|
593
|
+
// is the same dimension, appended last.
|
|
594
|
+
let groupBy = values["--group-by"] === undefined && values["--group-by-metadata"] !== undefined
|
|
595
|
+
? []
|
|
596
|
+
: [...USAGE_DEFAULT_GROUP_BY];
|
|
597
|
+
if (values["--group-by"] !== undefined) {
|
|
598
|
+
const raw = values["--group-by"];
|
|
599
|
+
const at = raw.startsWith(METADATA_PREFIX) ? 0 : raw.indexOf(`,${METADATA_PREFIX}`);
|
|
600
|
+
const plain = at === -1 ? raw : raw.slice(0, Math.max(0, at));
|
|
601
|
+
const metadataPart = at === -1 ? null : raw.slice(at === 0 ? 0 : at + 1);
|
|
602
|
+
const parts = plain === "" ? [] : plain.split(",").map((part) => part.trim());
|
|
603
|
+
if (
|
|
604
|
+
(parts.length === 0 && metadataPart === null) ||
|
|
605
|
+
!parts.every((part) => USAGE_DIMENSIONS.includes(part)) ||
|
|
606
|
+
new Set(parts).size !== parts.length
|
|
607
|
+
) {
|
|
608
|
+
return fail(
|
|
609
|
+
"invalid_group_by",
|
|
610
|
+
`--group-by must list distinct dimensions from ${USAGE_DIMENSIONS.join(", ")}, comma separated, ` +
|
|
611
|
+
"optionally ending with metadata.KEY.",
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
groupBy = metadataPart === null ? parts : [...parts, metadataPart];
|
|
615
|
+
}
|
|
616
|
+
if (values["--group-by-metadata"] !== undefined) {
|
|
617
|
+
if (groupBy.some((dimension) => dimension.startsWith(METADATA_PREFIX))) {
|
|
618
|
+
return fail("too_many_metadata_dimensions", "Group by at most one metadata key.");
|
|
619
|
+
}
|
|
620
|
+
groupBy = [...groupBy, `${METADATA_PREFIX}${values["--group-by-metadata"]}`];
|
|
621
|
+
}
|
|
622
|
+
const metadataDimension = groupBy.find((dimension) => dimension.startsWith(METADATA_PREFIX)) ?? null;
|
|
623
|
+
if (metadataDimension !== null) {
|
|
624
|
+
const problem = metadataKeyProblem(metadataDimension.slice(METADATA_PREFIX.length));
|
|
625
|
+
if (problem !== null) return fail(problem.code, `A metadata grouping key ${problem.message}`);
|
|
626
|
+
}
|
|
627
|
+
const filters = {};
|
|
628
|
+
for (const [option, key] of [["--route", "route"], ["--provider", "provider"], ["--model", "model"], ["--environment", "environment"]]) {
|
|
629
|
+
const list = values[option] ?? [];
|
|
630
|
+
if (list.length > USAGE_MAX_FILTER_VALUES) {
|
|
631
|
+
return fail("too_many_filter_values", `${option} may be given at most ${USAGE_MAX_FILTER_VALUES} times.`);
|
|
632
|
+
}
|
|
633
|
+
if (!list.every((value) => isSafeFilter(value) && value.trim() !== "")) {
|
|
634
|
+
return fail(`invalid_${key}`, `${option} must be 1 to 256 characters without control characters.`);
|
|
635
|
+
}
|
|
636
|
+
filters[key] = list.length === 0 ? null : [...new Set(list)];
|
|
637
|
+
}
|
|
638
|
+
const today = flags.has("--today");
|
|
639
|
+
const rawSince = values["--since"];
|
|
640
|
+
const rawUntil = values["--until"];
|
|
641
|
+
const explicit = rawSince !== undefined || rawUntil !== undefined;
|
|
642
|
+
if ([today, explicit, values["--days"] !== undefined].filter(Boolean).length > 1) {
|
|
643
|
+
return fail("window_conflict", "Use one window: --days N, --today, or --since with an optional --until.");
|
|
644
|
+
}
|
|
645
|
+
let days = null;
|
|
646
|
+
let since = null;
|
|
647
|
+
let until = null;
|
|
648
|
+
if (explicit) {
|
|
649
|
+
if (rawSince === undefined) return fail("invalid_window", "--until needs --since.");
|
|
650
|
+
since = utcInstant(rawSince);
|
|
651
|
+
until = rawUntil === undefined ? null : utcInstant(rawUntil);
|
|
652
|
+
if (since === null || (rawUntil !== undefined && until === null)) {
|
|
653
|
+
return fail(
|
|
654
|
+
"invalid_window",
|
|
655
|
+
"--since and --until must be a UTC date (YYYY-MM-DD, meaning 00:00 UTC) or an ISO 8601 timestamp with Z or an offset.",
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
const end = until === null ? Date.now() : Date.parse(until);
|
|
659
|
+
if (Date.parse(since) >= end) return fail("invalid_window", "--since must be before --until (or before now).");
|
|
660
|
+
if (end - Date.parse(since) > READ_MAX_DAYS * DAY_MS) {
|
|
661
|
+
return fail("invalid_window", `The window may span at most ${READ_MAX_DAYS} days.`);
|
|
662
|
+
}
|
|
663
|
+
} else {
|
|
664
|
+
days = today ? 1 : values["--days"] === undefined ? READ_DEFAULT_DAYS : parseBounded(values["--days"], 1, READ_MAX_DAYS);
|
|
665
|
+
if (days === null) return fail("invalid_days", `--days must be a whole number from 1 to ${READ_MAX_DAYS}.`);
|
|
666
|
+
}
|
|
667
|
+
const sort = values["--sort"] ?? (groupBy.length === 1 && groupBy[0] === "day" ? "day" : "cost");
|
|
668
|
+
if (!USAGE_SORTS.includes(sort)) return fail("invalid_sort", `--sort must be one of ${USAGE_SORTS.join(", ")}.`);
|
|
669
|
+
if (sort === "day" && !groupBy.includes("day")) {
|
|
670
|
+
return fail("invalid_sort", "--sort day needs day in --group-by.");
|
|
671
|
+
}
|
|
672
|
+
const compare = values["--compare"] ?? null;
|
|
673
|
+
if (compare !== null && !USAGE_COMPARISONS.includes(compare)) {
|
|
674
|
+
return fail("invalid_compare", `--compare must be ${USAGE_COMPARISONS.join(" or ")}.`);
|
|
675
|
+
}
|
|
676
|
+
if (compare !== null && groupBy.includes("day")) {
|
|
677
|
+
return fail(
|
|
678
|
+
"compare_with_day_grouping",
|
|
679
|
+
"--compare matches groups across two windows, and a UTC day is never in both. " +
|
|
680
|
+
"For a trend, use --group-by day without --compare.",
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
const metadata = parseMetadata(values, fail);
|
|
684
|
+
if (!metadata.ok) return metadata;
|
|
685
|
+
const parsed = {
|
|
686
|
+
ok: true,
|
|
687
|
+
command: "usage",
|
|
688
|
+
...target,
|
|
689
|
+
project: paths.project,
|
|
690
|
+
configDir: paths.configDir,
|
|
691
|
+
timeoutMs,
|
|
692
|
+
days,
|
|
693
|
+
limit,
|
|
694
|
+
route: null,
|
|
695
|
+
status: null,
|
|
696
|
+
cursor: null,
|
|
697
|
+
metadata: metadata.filters,
|
|
698
|
+
groupByMetadata: metadataDimension === null ? null : metadataDimension.slice(METADATA_PREFIX.length),
|
|
699
|
+
grouped: { groupBy, filters, since, until, today, sort, compare },
|
|
508
700
|
json,
|
|
509
701
|
};
|
|
702
|
+
// Measure the exact query the read sends, and for --compare the prior
|
|
703
|
+
// window's query, which carries two timestamps.
|
|
704
|
+
const longest = [readQuery(parsed)];
|
|
705
|
+
if (compare !== null) {
|
|
706
|
+
const stamp = "0000-00-00T00:00:00.000Z";
|
|
707
|
+
longest.push(readQuery(parsed, { ...groupedRequest(parsed), days: null, since: stamp, until: stamp }));
|
|
708
|
+
}
|
|
709
|
+
if (longest.some((query) => query.toString().length > MAX_QUERY_LENGTH)) {
|
|
710
|
+
return fail(
|
|
711
|
+
"query_too_long",
|
|
712
|
+
"The filters are too long to send in one request. Use fewer or shorter --route, --provider, --model, " +
|
|
713
|
+
"--environment or --metadata values.",
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
return parsed;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
720
|
+
const METADATA_PREFIX = "metadata.";
|
|
721
|
+
const UTC_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
722
|
+
const ZONED_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
723
|
+
|
|
724
|
+
// A window bound as a UTC ISO 8601 string, or null. A date alone is 00:00
|
|
725
|
+
// UTC; a timestamp must carry Z or an offset, so a local time is never read
|
|
726
|
+
// as UTC by accident.
|
|
727
|
+
function utcInstant(raw) {
|
|
728
|
+
if (typeof raw !== "string" || raw.length > 40) return null;
|
|
729
|
+
const text = UTC_DATE.test(raw) ? `${raw}T00:00:00Z` : ZONED_TIME.test(raw) ? raw : null;
|
|
730
|
+
if (text === null) return null;
|
|
731
|
+
const parsed = Date.parse(text);
|
|
732
|
+
if (!Number.isFinite(parsed)) return null;
|
|
733
|
+
// Reject calendar values the parser would roll over, such as 2026-02-30 or
|
|
734
|
+
// 2026-09-31T12:00:00Z, rather than read a different instant.
|
|
735
|
+
const [year, month, day] = text.slice(0, 10).split("-").map(Number);
|
|
736
|
+
const calendar = new Date(Date.UTC(year, month - 1, day));
|
|
737
|
+
if (calendar.getUTCFullYear() !== year || calendar.getUTCMonth() !== month - 1 || calendar.getUTCDate() !== day) {
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
const time = /T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(text);
|
|
741
|
+
if (time && (Number(time[1]) > 23 || Number(time[2]) > 59 || Number(time[3] ?? 0) > 59)) return null;
|
|
742
|
+
const offset = /([+-])(\d{2}):(\d{2})$/.exec(text);
|
|
743
|
+
if (offset && (Number(offset[2]) > 23 || Number(offset[3]) > 59)) return null;
|
|
744
|
+
return new Date(parsed).toISOString();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Exact customer-metadata filters. --metadata KEY=VALUE splits at the first
|
|
748
|
+
// "=", so a value may contain "=" and a key may not; --metadata-json takes one
|
|
749
|
+
// JSON object for keys with "=" or values that are awkward to quote. Values
|
|
750
|
+
// are opaque strings: nothing is trimmed, case folded or split on a prefix.
|
|
751
|
+
// Returns filters sorted by key, or null when none were given.
|
|
752
|
+
function parseMetadata(values, fail) {
|
|
753
|
+
const pairs = values["--metadata"] ?? [];
|
|
754
|
+
const raw = values["--metadata-json"];
|
|
755
|
+
if (pairs.length > 0 && raw !== undefined) {
|
|
756
|
+
return fail("metadata_conflict", "Use --metadata or --metadata-json, not both.");
|
|
757
|
+
}
|
|
758
|
+
const entries = [];
|
|
759
|
+
if (raw !== undefined) {
|
|
760
|
+
let parsed;
|
|
761
|
+
try {
|
|
762
|
+
parsed = JSON.parse(raw);
|
|
763
|
+
} catch {
|
|
764
|
+
return fail("invalid_metadata", '--metadata-json must be a JSON object such as {"prompt_id":"p-1"}.');
|
|
765
|
+
}
|
|
766
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
767
|
+
return fail("invalid_metadata", '--metadata-json must be a JSON object such as {"prompt_id":"p-1"}.');
|
|
768
|
+
}
|
|
769
|
+
if (duplicateJsonKey(raw)) {
|
|
770
|
+
return fail("duplicate_metadata_key", "A metadata key may be given only once.");
|
|
771
|
+
}
|
|
772
|
+
entries.push(...Object.entries(parsed));
|
|
773
|
+
} else {
|
|
774
|
+
for (const pair of pairs) {
|
|
775
|
+
const equals = pair.indexOf("=");
|
|
776
|
+
if (equals <= 0) {
|
|
777
|
+
return fail(
|
|
778
|
+
"invalid_metadata",
|
|
779
|
+
"--metadata must be KEY=VALUE with a non-empty key. Use --metadata-json for a key that contains \"=\".",
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
entries.push([pair.slice(0, equals), pair.slice(equals + 1)]);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (entries.length === 0) return { ok: true, filters: null };
|
|
786
|
+
if (entries.length > MAX_METADATA_FILTERS) {
|
|
787
|
+
return fail("too_many_metadata_filters", `At most ${MAX_METADATA_FILTERS} metadata keys may be given.`);
|
|
788
|
+
}
|
|
789
|
+
const seen = new Set();
|
|
790
|
+
for (const [key, value] of entries) {
|
|
791
|
+
const problem = metadataKeyProblem(key);
|
|
792
|
+
if (problem !== null) return fail(problem.code, `A metadata key ${problem.message}`);
|
|
793
|
+
if (seen.has(key)) return fail("duplicate_metadata_key", "A metadata key may be given only once.");
|
|
794
|
+
seen.add(key);
|
|
795
|
+
if (typeof value !== "string") {
|
|
796
|
+
return fail("invalid_metadata", "Metadata values must be strings; they are matched as exact opaque text.");
|
|
797
|
+
}
|
|
798
|
+
if (value.length > MAX_METADATA_VALUE_LENGTH || value.includes("\0")) {
|
|
799
|
+
return fail(
|
|
800
|
+
"invalid_metadata",
|
|
801
|
+
`A metadata value must be at most ${MAX_METADATA_VALUE_LENGTH} characters without NUL.`,
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const filters = entries
|
|
806
|
+
.map(([key, value]) => ({ key, value }))
|
|
807
|
+
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
808
|
+
return { ok: true, filters };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// JSON.parse keeps the last of two equal keys; the service refuses them, so
|
|
812
|
+
// the CLI refuses them first instead of silently sending one. Scans the
|
|
813
|
+
// already valid JSON text for the top-level object's keys.
|
|
814
|
+
function duplicateJsonKey(raw) {
|
|
815
|
+
const keys = new Set();
|
|
816
|
+
let depth = 0;
|
|
817
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
818
|
+
const char = raw[index];
|
|
819
|
+
if (char === "{" || char === "[") depth += 1;
|
|
820
|
+
else if (char === "}" || char === "]") depth -= 1;
|
|
821
|
+
else if (char === '"') {
|
|
822
|
+
let end = index + 1;
|
|
823
|
+
while (raw[end] !== '"') end += raw[end] === "\\" ? 2 : 1;
|
|
824
|
+
const token = raw.slice(index, end + 1);
|
|
825
|
+
let next = end + 1;
|
|
826
|
+
while (/\s/.test(raw[next] ?? "")) next += 1;
|
|
827
|
+
if (depth === 1 && raw[next] === ":") {
|
|
828
|
+
const key = JSON.parse(token);
|
|
829
|
+
if (keys.has(key)) return true;
|
|
830
|
+
keys.add(key);
|
|
831
|
+
}
|
|
832
|
+
index = end;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Matches the service's rule: lowercased, everything but letters and digits
|
|
839
|
+
// removed, the key must not end in one of these.
|
|
840
|
+
const SENSITIVE_KEY_SUFFIXES = Object.freeze([
|
|
841
|
+
"accesskey", "apikey", "auth", "authorization", "bearer", "clientsecret", "cookie",
|
|
842
|
+
"credential", "credentials", "encryptionkey", "jwt", "passwd", "password", "privatekey",
|
|
843
|
+
"secret", "secretkey", "sessionkey", "signingkey", "token",
|
|
844
|
+
]);
|
|
845
|
+
|
|
846
|
+
function metadataKeyProblem(key) {
|
|
847
|
+
// Keys are printed back (filter and grouping lines), so control and
|
|
848
|
+
// formatting characters that could rewrite terminal or log output are
|
|
849
|
+
// refused, as they are for every other printed name.
|
|
850
|
+
if (
|
|
851
|
+
typeof key !== "string" ||
|
|
852
|
+
key.length === 0 ||
|
|
853
|
+
key.length > MAX_METADATA_KEY_LENGTH ||
|
|
854
|
+
hasUnsafeText(key)
|
|
855
|
+
) {
|
|
856
|
+
return {
|
|
857
|
+
code: "invalid_metadata_key",
|
|
858
|
+
message: `must be 1 to ${MAX_METADATA_KEY_LENGTH} characters without control or formatting characters.`,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
862
|
+
if (SENSITIVE_KEY_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) {
|
|
863
|
+
return {
|
|
864
|
+
code: "metadata_key_ineligible",
|
|
865
|
+
message: "looks like a credential name and cannot be used for filtering or grouping.",
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
return null;
|
|
510
869
|
}
|
|
511
870
|
|
|
512
871
|
function parseBounded(raw, min, max) {
|
package/src/auth-browser.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import childProcess from "node:child_process";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
+
import { classifyEnvironment } from "./environment.js";
|
|
5
|
+
|
|
4
6
|
// Opens a URL with the operating system's own launcher: a fixed executable
|
|
5
7
|
// and an argument array, never a shell. The URL is the authorization request
|
|
6
8
|
// (public client ID, state and PKCE challenge); no token, code or verifier is
|
|
@@ -51,24 +53,8 @@ export function systemRoot() {
|
|
|
51
53
|
return "C:\\Windows";
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// never read into output.
|
|
57
|
-
const REMOTE_MARKERS = [
|
|
58
|
-
["ssh_session", ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]],
|
|
59
|
-
["cloud_workspace", ["CODESPACES", "GITPOD_WORKSPACE_ID", "CLOUD_SHELL"]],
|
|
60
|
-
["ci_environment", ["CI"]],
|
|
61
|
-
];
|
|
62
|
-
|
|
63
|
-
// Returns null for an ordinary local session, or a fixed reason token.
|
|
56
|
+
// Returns null for an ordinary local session (including a local container),
|
|
57
|
+
// or the fixed reason token of a remote or CI session. See environment.js.
|
|
64
58
|
export function detectRemoteSession(env = process.env) {
|
|
65
|
-
|
|
66
|
-
for (const name of names) {
|
|
67
|
-
const value = env[name];
|
|
68
|
-
if (typeof value === "string" && value !== "" && value !== "0" && value.toLowerCase() !== "false") {
|
|
69
|
-
return reason;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return null;
|
|
59
|
+
return classifyEnvironment(env, { fileExists: () => false }).reason;
|
|
74
60
|
}
|
package/src/auth-callback.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { timingSafeEqual } from "node:crypto";
|
|
2
2
|
import http from "node:http";
|
|
3
3
|
|
|
4
|
+
import { PAGE_CSP, PAGES } from "./callback-page.js";
|
|
5
|
+
|
|
4
6
|
// Loopback listener for the OAuth redirect (RFC 8252 section 7.3). It binds
|
|
5
7
|
// 127.0.0.1 on an ephemeral port and accepts exactly one callback: a GET on
|
|
6
8
|
// the fixed path, with the exact Host, one state that matches, and either one
|
|
@@ -14,23 +16,19 @@ const HOST = "127.0.0.1";
|
|
|
14
16
|
const MAX_URL_LENGTH = 4096;
|
|
15
17
|
const MAX_CODE_LENGTH = 2048;
|
|
16
18
|
const ALLOWED_PARAMS = new Set(["code", "state", "error", "error_description", "error_uri", "iss"]);
|
|
19
|
+
// The setup receipt a combined sign-in approval returns next to the code.
|
|
20
|
+
// Accepted only by a wait that asked for it.
|
|
21
|
+
export const SETUP_CODE_PARAM = "ingest_setup_code";
|
|
17
22
|
|
|
18
23
|
const PAGE_HEADERS = {
|
|
19
24
|
"content-type": "text/html; charset=utf-8",
|
|
20
25
|
"cache-control": "no-store",
|
|
21
|
-
"content-security-policy":
|
|
26
|
+
"content-security-policy": PAGE_CSP,
|
|
22
27
|
"referrer-policy": "no-referrer",
|
|
23
28
|
"x-content-type-options": "nosniff",
|
|
24
29
|
"x-frame-options": "DENY",
|
|
25
30
|
connection: "close",
|
|
26
31
|
};
|
|
27
|
-
const page = (text) =>
|
|
28
|
-
`<!doctype html><meta charset="utf-8"><title>Metergraph CLI</title><p>${text}</p>\n`;
|
|
29
|
-
const PAGES = {
|
|
30
|
-
done: page("Metergraph CLI: authorization received. You can close this tab and return to the terminal."),
|
|
31
|
-
failed: page("Metergraph CLI: authorization did not complete. Return to the terminal for details."),
|
|
32
|
-
invalid: page("Metergraph CLI: this request was not accepted."),
|
|
33
|
-
};
|
|
34
32
|
|
|
35
33
|
// Starts listening. Resolves with { redirectUri, wait, close }.
|
|
36
34
|
// wait({ state, issuer, requireIss, timeoutMs, cancel }) arms the listener
|
|
@@ -38,7 +36,9 @@ const PAGES = {
|
|
|
38
36
|
// accepted and the timeout is running. Call it before the authorization
|
|
39
37
|
// URL is opened or printed, so a fast browser can never reach an unarmed
|
|
40
38
|
// listener. It may be called once. The promise resolves with
|
|
41
|
-
// { kind: "code", code }
|
|
39
|
+
// { kind: "code", code[, setupCode] } setupCode, the ingest_setup_code
|
|
40
|
+
// value, only when wait() was given
|
|
41
|
+
// acceptSetupCode and it was sent
|
|
42
42
|
// { kind: "denied" } the user or server declined
|
|
43
43
|
// { kind: "error", reason } authorization_error,
|
|
44
44
|
// callback_issuer_mismatch or
|
|
@@ -85,7 +85,7 @@ export async function startCallbackListener() {
|
|
|
85
85
|
await done;
|
|
86
86
|
};
|
|
87
87
|
|
|
88
|
-
const wait = ({ state, issuer, requireIss, timeoutMs, cancel }) => {
|
|
88
|
+
const wait = ({ state, issuer, requireIss, timeoutMs, cancel, acceptSetupCode = false }) => {
|
|
89
89
|
if (armed) throw new Error("callback listener is already armed");
|
|
90
90
|
armed = true;
|
|
91
91
|
// The executor runs synchronously, so the handler and timer are in
|
|
@@ -105,7 +105,7 @@ export async function startCallbackListener() {
|
|
|
105
105
|
|
|
106
106
|
handler = (request, response) => {
|
|
107
107
|
if (settled) return respond(response, 410, PAGES.invalid);
|
|
108
|
-
const verdict = inspect(request, { host, state, issuer, requireIss });
|
|
108
|
+
const verdict = inspect(request, { host, state, issuer, requireIss, acceptSetupCode });
|
|
109
109
|
if (verdict.result === undefined) return respond(response, verdict.status, PAGES.invalid);
|
|
110
110
|
const result = verdict.result;
|
|
111
111
|
respond(response, verdict.status, result.kind === "code" ? PAGES.done : PAGES.failed);
|
|
@@ -131,7 +131,7 @@ function respond(response, status, body) {
|
|
|
131
131
|
|
|
132
132
|
// Returns { status } for a request that is ignored, or { status, result }
|
|
133
133
|
// for the one request that ends the flow.
|
|
134
|
-
function inspect(request, { host, state, issuer, requireIss }) {
|
|
134
|
+
function inspect(request, { host, state, issuer, requireIss, acceptSetupCode }) {
|
|
135
135
|
const url = request.url ?? "";
|
|
136
136
|
if (url.length > MAX_URL_LENGTH) return { status: 414 };
|
|
137
137
|
if (request.method !== "GET") return { status: 405 };
|
|
@@ -148,7 +148,9 @@ function inspect(request, { host, state, issuer, requireIss }) {
|
|
|
148
148
|
}
|
|
149
149
|
const seen = new Set();
|
|
150
150
|
for (const key of params.keys()) {
|
|
151
|
-
if (!ALLOWED_PARAMS.has(key) || seen.has(key))
|
|
151
|
+
if (!(ALLOWED_PARAMS.has(key) || (acceptSetupCode && key === SETUP_CODE_PARAM)) || seen.has(key)) {
|
|
152
|
+
return { status: 400 };
|
|
153
|
+
}
|
|
152
154
|
seen.add(key);
|
|
153
155
|
}
|
|
154
156
|
// Only a request that carries this flow's exact state can end it.
|
|
@@ -158,7 +160,9 @@ function inspect(request, { host, state, issuer, requireIss }) {
|
|
|
158
160
|
return { status: 400, result: { kind: "error", reason: "callback_issuer_mismatch" } };
|
|
159
161
|
}
|
|
160
162
|
if (seen.has("error")) {
|
|
161
|
-
if (seen.has("code")
|
|
163
|
+
if (seen.has("code") || seen.has(SETUP_CODE_PARAM)) {
|
|
164
|
+
return { status: 400, result: { kind: "error", reason: "callback_invalid" } };
|
|
165
|
+
}
|
|
162
166
|
return params.get("error") === "access_denied"
|
|
163
167
|
? { status: 200, result: { kind: "denied" } }
|
|
164
168
|
: { status: 200, result: { kind: "error", reason: "authorization_error" } };
|
|
@@ -167,7 +171,12 @@ function inspect(request, { host, state, issuer, requireIss }) {
|
|
|
167
171
|
if (code === null || code.length === 0 || code.length > MAX_CODE_LENGTH || /[^\x21-\x7e]/.test(code)) {
|
|
168
172
|
return { status: 400, result: { kind: "error", reason: "callback_invalid" } };
|
|
169
173
|
}
|
|
170
|
-
return { status: 200, result: { kind: "code", code } };
|
|
174
|
+
if (!seen.has(SETUP_CODE_PARAM)) return { status: 200, result: { kind: "code", code } };
|
|
175
|
+
const setupCode = params.get(SETUP_CODE_PARAM);
|
|
176
|
+
if (setupCode.length === 0 || setupCode.length > MAX_CODE_LENGTH || /[^\x21-\x7e]/.test(setupCode)) {
|
|
177
|
+
return { status: 400, result: { kind: "error", reason: "callback_invalid" } };
|
|
178
|
+
}
|
|
179
|
+
return { status: 200, result: { kind: "code", code, setupCode } };
|
|
171
180
|
}
|
|
172
181
|
|
|
173
182
|
function sameText(actual, expected) {
|