metergraph-cli 0.2.0 → 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/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([...READ_BASE, "--days", "--limit"]),
45
- routes: new Set([...READ_BASE, "--limit"]),
46
- traces: new Set([...READ_BASE, "--days", "--limit", "--route", "--status", "--cursor"]),
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 not supported: the service's agent access contract has no environment selector, " +
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 not supported. Use --days N (1 to 90) for a window relative to now. No request was made.",
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
- return {
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) {
@@ -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
- // Variables whose presence means the browser a person uses is not on this
55
- // machine, or no person is present. Only presence is checked; values are
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
- for (const [reason, names] of REMOTE_MARKERS) {
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
  }
@@ -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": "default-src 'none'",
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)) return { status: 400 };
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")) return { status: 400, result: { kind: "error", reason: "callback_invalid" } };
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) {