metergraph-cli 0.1.0 → 0.2.0-preview.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,397 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ import { systemRoot } from "./auth-browser.js";
8
+ import { AUTH_PATHS, METADATA_SCOPE, SUPPORTED_PROFILES } from "./constants.js";
9
+ import { parseOrigin } from "./origin.js";
10
+
11
+ // Per-user credential storage for the delegated Metadata OAuth grant. Only
12
+ // this module reads or writes token values. Layout, below the config
13
+ // directory chosen by --config-dir, METERGRAPH_CONFIG_DIR or the default:
14
+ // credentials/<slot>.json one grant, written atomically
15
+ // credentials/<slot>.lock held while a grant is refreshed or removed
16
+ // On POSIX systems the directories must be owner-only (0700) and owned by the
17
+ // current user, and files are created 0600 and checked the same way. On
18
+ // Windows, file modes do not protect anything, so the grant is encrypted with
19
+ // DPAPI for the current user before it is written. Existing paths with unsafe
20
+ // permissions are refused, never silently changed. Symbolic links are never
21
+ // followed below the config directory.
22
+
23
+ export class Stop extends Error {
24
+ constructor(outcome, reason) {
25
+ super(reason);
26
+ this.outcome = outcome;
27
+ this.reason = reason;
28
+ }
29
+ }
30
+
31
+ const IS_WINDOWS = process.platform === "win32";
32
+ const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
33
+ const MAX_FILE_BYTES = 64 * 1024;
34
+ const SLOT = /^[0-9a-f]{32}$/;
35
+ const RECORD_KEYS = [
36
+ "access_token",
37
+ "client_id",
38
+ "deployment_profile",
39
+ "expires_at",
40
+ "issuer",
41
+ "origin",
42
+ "refresh_pending",
43
+ "refresh_token",
44
+ "resource",
45
+ "schema_version",
46
+ "scope",
47
+ "workspace_id",
48
+ ].join();
49
+
50
+ export const PROTECTION = IS_WINDOWS ? "dpapi" : "owner_only_file";
51
+
52
+ // The config directory, as an absolute path. The default is derived from the
53
+ // operating system's home directory for this user. Other tools' variables
54
+ // such as CODEX_HOME are never used.
55
+ export function resolveConfigDir(flag, env = process.env) {
56
+ if (flag !== null && flag !== undefined) return path.resolve(flag);
57
+ const fromEnv = env.METERGRAPH_CONFIG_DIR;
58
+ if (typeof fromEnv === "string" && fromEnv !== "") {
59
+ if (!path.isAbsolute(fromEnv) || fromEnv.includes("\0")) throw new Stop("invalid_input", "invalid_config_dir");
60
+ return path.resolve(fromEnv);
61
+ }
62
+ const home = os.homedir();
63
+ if (typeof home !== "string" || !path.isAbsolute(home)) {
64
+ throw new Stop("filesystem_error", "credential_store_unavailable");
65
+ }
66
+ return IS_WINDOWS ? path.join(home, "AppData", "Roaming", "Metergraph") : path.join(home, ".config", "metergraph");
67
+ }
68
+
69
+ // Returns { dir, credentials } after checking both directories, creating
70
+ // missing ones with owner-only permissions when create is true. Returns null
71
+ // when the store does not exist and create is false.
72
+ export function openStore(dir, { create }) {
73
+ try {
74
+ checkParent(dir);
75
+ let stat = lstatOrNull(dir);
76
+ if (stat === null) {
77
+ if (!create) return null;
78
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
79
+ stat = fs.lstatSync(dir);
80
+ }
81
+ checkPrivate(stat, "directory");
82
+ const credentials = path.join(dir, "credentials");
83
+ let inner = lstatOrNull(credentials);
84
+ if (inner === null) {
85
+ if (!create) return null;
86
+ fs.mkdirSync(credentials, { mode: 0o700 });
87
+ inner = fs.lstatSync(credentials);
88
+ }
89
+ checkPrivate(inner, "directory");
90
+ return { dir, credentials };
91
+ } catch (error) {
92
+ if (error instanceof Stop) throw error;
93
+ throw new Stop("filesystem_error", "credential_store_unavailable");
94
+ }
95
+ }
96
+
97
+ export function newSlot() {
98
+ return randomBytes(16).toString("hex");
99
+ }
100
+
101
+ export function isSlot(value) {
102
+ return typeof value === "string" && SLOT.test(value);
103
+ }
104
+
105
+ // Returns the stored record, or null when the slot has no file. A file that
106
+ // cannot be decoded is reported as unreadable, never guessed at.
107
+ export function readCredential(store, slot) {
108
+ const file = slotPath(store, slot, "json");
109
+ let content;
110
+ try {
111
+ content = readPrivateFile(file);
112
+ } catch (error) {
113
+ if (error instanceof Stop) throw error;
114
+ throw new Stop("filesystem_error", "credential_store_unavailable");
115
+ }
116
+ if (content === null) return null;
117
+ const record = decode(content);
118
+ if (record === null) throw new Stop("login_required", "credential_unreadable");
119
+ return record;
120
+ }
121
+
122
+ // Atomically replaces the slot file with record.
123
+ export function writeCredential(store, slot, record) {
124
+ if (!validRecord(record)) throw new Error("credential record is not valid");
125
+ try {
126
+ const content = encode(record);
127
+ writePrivateFile(store.credentials, `${slot}.json`, content);
128
+ } catch (error) {
129
+ if (error instanceof Stop) throw error;
130
+ throw new Stop("filesystem_error", "credential_write_failed");
131
+ }
132
+ }
133
+
134
+ // Removes the slot file. Returns true when a file was removed. A symbolic
135
+ // link or other non-regular entry in its place is refused, not removed.
136
+ export function deleteCredential(store, slot) {
137
+ const file = slotPath(store, slot, "json");
138
+ try {
139
+ const stat = lstatOrNull(file);
140
+ if (stat === null) return false;
141
+ if (!stat.isFile()) throw new Stop("conflict", "credential_path_unsafe");
142
+ fs.unlinkSync(file);
143
+ return true;
144
+ } catch (error) {
145
+ if (error instanceof Stop) throw error;
146
+ throw new Stop("filesystem_error", "credential_write_failed");
147
+ }
148
+ }
149
+
150
+ // Runs work while holding the slot's exclusive lock file. Waits up to waitMs
151
+ // for another process to finish, then stops with a conflict. A lock left by
152
+ // a killed process stays until it is deleted, because taking over a lock
153
+ // that may still be held could let two refreshes use one refresh token.
154
+ // An optional signal (a deadline or Ctrl+C) stops the wait, and stops before
155
+ // work starts, with authorization_failed/cancelled. A lock this call never
156
+ // took is never touched.
157
+ export async function withSlotLock(store, slot, work, { waitMs, signal = null }) {
158
+ const lock = slotPath(store, slot, "lock");
159
+ const until = Date.now() + waitMs;
160
+ const cancelled = () => new Stop("authorization_failed", "cancelled");
161
+ for (;;) {
162
+ if (signal?.aborted) throw cancelled();
163
+ try {
164
+ const fd = fs.openSync(lock, "wx", 0o600);
165
+ fs.closeSync(fd);
166
+ break;
167
+ } catch (error) {
168
+ if (error.code !== "EEXIST") throw new Stop("filesystem_error", "credential_store_unavailable");
169
+ if (Date.now() >= until) throw new Stop("conflict", "credential_locked");
170
+ await pause(50, signal);
171
+ }
172
+ }
173
+ try {
174
+ if (signal?.aborted) throw cancelled();
175
+ return await work();
176
+ } finally {
177
+ try {
178
+ fs.unlinkSync(lock);
179
+ } catch {
180
+ // Reported by the next run as a held lock.
181
+ }
182
+ }
183
+ }
184
+
185
+ // Resolves after ms, or as soon as signal aborts.
186
+ function pause(ms, signal) {
187
+ return new Promise((resolve) => {
188
+ const done = () => {
189
+ clearTimeout(timer);
190
+ signal?.removeEventListener("abort", done);
191
+ resolve();
192
+ };
193
+ const timer = setTimeout(done, ms);
194
+ signal?.addEventListener("abort", done, { once: true });
195
+ });
196
+ }
197
+
198
+ export function validRecord(record) {
199
+ if (record === null || typeof record !== "object" || Array.isArray(record)) return false;
200
+ if (Object.keys(record).sort().join() !== RECORD_KEYS) return false;
201
+ const origin = parseOrigin(record.origin);
202
+ return (
203
+ record.schema_version === 1 &&
204
+ origin === record.origin &&
205
+ record.issuer === `${origin}${AUTH_PATHS.issuer}` &&
206
+ record.resource === `${origin}${AUTH_PATHS.resource}` &&
207
+ SUPPORTED_PROFILES.includes(record.deployment_profile) &&
208
+ typeof record.workspace_id === "string" &&
209
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(record.workspace_id) &&
210
+ record.scope === METADATA_SCOPE &&
211
+ printable(record.client_id, 256) &&
212
+ printable(record.access_token, 8192) &&
213
+ printable(record.refresh_token, 4096) &&
214
+ Number.isSafeInteger(record.expires_at) &&
215
+ typeof record.refresh_pending === "boolean"
216
+ );
217
+ }
218
+
219
+ function printable(value, max) {
220
+ return typeof value === "string" && value.length > 0 && value.length <= max && /^[\x21-\x7e]+$/.test(value);
221
+ }
222
+
223
+ function slotPath(store, slot, extension) {
224
+ if (!isSlot(slot)) throw new Error("invalid credential slot");
225
+ return path.join(store.credentials, `${slot}.${extension}`);
226
+ }
227
+
228
+ // File format: { schema_version, protection, credential } where credential
229
+ // is the record itself for owner_only_file, and base64 DPAPI output for dpapi.
230
+ function encode(record) {
231
+ const plain = JSON.stringify(record);
232
+ const credential = IS_WINDOWS ? dpapi("protect", Buffer.from(plain, "utf8")).toString("base64") : record;
233
+ return Buffer.from(`${JSON.stringify({ schema_version: 1, protection: PROTECTION, credential })}\n`);
234
+ }
235
+
236
+ function decode(content) {
237
+ let wrapper;
238
+ try {
239
+ wrapper = JSON.parse(content.toString("utf8"));
240
+ } catch {
241
+ return null;
242
+ }
243
+ if (wrapper === null || typeof wrapper !== "object" || wrapper.schema_version !== 1) return null;
244
+ if (wrapper.protection !== PROTECTION) return null;
245
+ let record = wrapper.credential;
246
+ if (IS_WINDOWS) {
247
+ if (typeof record !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(record)) return null;
248
+ try {
249
+ record = JSON.parse(dpapi("unprotect", Buffer.from(record, "base64")).toString("utf8"));
250
+ } catch (error) {
251
+ if (error instanceof Stop) throw error;
252
+ return null;
253
+ }
254
+ }
255
+ return validRecord(record) ? record : null;
256
+ }
257
+
258
+ // A fixed Windows PowerShell script, passed as an encoded constant. The data
259
+ // travels on stdin and stdout pipes only, never on the command line. The
260
+ // optional entropy ties the blob to this purpose; the scope is the current
261
+ // user, so another account on the machine cannot decrypt it.
262
+ const DPAPI_SCRIPT = [
263
+ "$ErrorActionPreference = 'Stop'",
264
+ "$ProgressPreference = 'SilentlyContinue'",
265
+ "Add-Type -AssemblyName System.Security",
266
+ "$mode = [Console]::In.ReadLine()",
267
+ "$data = [Convert]::FromBase64String([Console]::In.ReadLine())",
268
+ "$entropy = [Text.Encoding]::UTF8.GetBytes('metergraph-cli-credential-v1')",
269
+ "$scope = [Security.Cryptography.DataProtectionScope]::CurrentUser",
270
+ "if ($mode -eq 'protect') { $out = [Security.Cryptography.ProtectedData]::Protect($data, $entropy, $scope) }",
271
+ "elseif ($mode -eq 'unprotect') { $out = [Security.Cryptography.ProtectedData]::Unprotect($data, $entropy, $scope) }",
272
+ "else { exit 2 }",
273
+ "[Console]::Out.Write([Convert]::ToBase64String($out))",
274
+ ].join("\n");
275
+ const DPAPI_COMMAND = Buffer.from(DPAPI_SCRIPT, "utf16le").toString("base64");
276
+
277
+ export function dpapi(mode, data) {
278
+ const powershell = path.win32.join(systemRoot(), "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
279
+ const result = spawnSync(
280
+ powershell,
281
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", DPAPI_COMMAND],
282
+ {
283
+ input: `${mode}\n${data.toString("base64")}\n`,
284
+ encoding: "utf8",
285
+ shell: false,
286
+ windowsHide: true,
287
+ timeout: 30000,
288
+ maxBuffer: 1024 * 1024,
289
+ },
290
+ );
291
+ const out = typeof result.stdout === "string" ? result.stdout.trim() : "";
292
+ if (result.error || result.status !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(out)) {
293
+ throw new Stop("filesystem_error", "credential_protection_failed");
294
+ }
295
+ return Buffer.from(out, "base64");
296
+ }
297
+
298
+ function lstatOrNull(file) {
299
+ try {
300
+ return fs.lstatSync(file);
301
+ } catch (error) {
302
+ if (error.code === "ENOENT") return null;
303
+ throw error;
304
+ }
305
+ }
306
+
307
+ // A world-writable parent without the sticky bit would let another user
308
+ // replace the config directory itself.
309
+ function checkParent(dir) {
310
+ if (IS_WINDOWS) return;
311
+ let stat;
312
+ try {
313
+ stat = fs.statSync(path.dirname(dir));
314
+ } catch {
315
+ return;
316
+ }
317
+ if ((stat.mode & 0o002) !== 0 && (stat.mode & 0o1000) === 0) {
318
+ throw new Stop("conflict", "credential_permissions_unsafe");
319
+ }
320
+ }
321
+
322
+ function checkPrivate(stat, kind) {
323
+ const right = kind === "directory" ? stat.isDirectory() : stat.isFile();
324
+ if (stat.isSymbolicLink() || !right) throw new Stop("conflict", "credential_path_unsafe");
325
+ if (IS_WINDOWS) return;
326
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
327
+ throw new Stop("conflict", "credential_path_not_owned");
328
+ }
329
+ if ((stat.mode & 0o077) !== 0) throw new Stop("conflict", "credential_permissions_unsafe");
330
+ }
331
+
332
+ function readPrivateFile(file) {
333
+ const stat = lstatOrNull(file);
334
+ if (stat === null) return null;
335
+ checkPrivate(stat, "file");
336
+ let fd;
337
+ try {
338
+ fd = fs.openSync(file, fs.constants.O_RDONLY | NOFOLLOW);
339
+ } catch (error) {
340
+ if (error.code === "ELOOP") throw new Stop("conflict", "credential_path_unsafe");
341
+ if (error.code === "ENOENT") return null;
342
+ throw error;
343
+ }
344
+ try {
345
+ const opened = fs.fstatSync(fd);
346
+ checkPrivate(opened, "file");
347
+ if (opened.size > MAX_FILE_BYTES) throw new Stop("login_required", "credential_unreadable");
348
+ return fs.readFileSync(fd);
349
+ } finally {
350
+ fs.closeSync(fd);
351
+ }
352
+ }
353
+
354
+ // Writes an exclusive owner-only temporary file next to the target, flushes
355
+ // it and renames it over the target. A rename replaces a link itself rather
356
+ // than following it, but an unexpected entry at the target is refused first.
357
+ function writePrivateFile(dir, name, content) {
358
+ const dest = path.join(dir, name);
359
+ const existing = lstatOrNull(dest);
360
+ if (existing !== null && (existing.isSymbolicLink() || !existing.isFile())) {
361
+ throw new Stop("conflict", "credential_path_unsafe");
362
+ }
363
+ const temp = path.join(dir, `.${name}.${randomBytes(6).toString("hex")}.tmp`);
364
+ const fd = fs.openSync(temp, "wx", 0o600);
365
+ try {
366
+ try {
367
+ if (!IS_WINDOWS) fs.fchmodSync(fd, 0o600);
368
+ fs.writeFileSync(fd, content);
369
+ fs.fsyncSync(fd);
370
+ } finally {
371
+ fs.closeSync(fd);
372
+ }
373
+ fs.renameSync(temp, dest);
374
+ } catch (error) {
375
+ try {
376
+ fs.unlinkSync(temp);
377
+ } catch {
378
+ // Already renamed or never created.
379
+ }
380
+ throw error;
381
+ }
382
+ syncDir(dir);
383
+ }
384
+
385
+ function syncDir(dir) {
386
+ if (IS_WINDOWS) return;
387
+ try {
388
+ const fd = fs.openSync(dir, "r");
389
+ try {
390
+ fs.fsyncSync(fd);
391
+ } finally {
392
+ fs.closeSync(fd);
393
+ }
394
+ } catch {
395
+ // Not supported everywhere. The rename itself is still atomic.
396
+ }
397
+ }
package/src/cli.js CHANGED
@@ -1,6 +1,9 @@
1
- import { parseArgs } from "./args.js";
1
+ import { READ_COMMANDS, parseArgs } from "./args.js";
2
+ import { runLogin, runLogout } from "./auth-login.js";
2
3
  import { runDoctor } from "./doctor.js";
3
4
  import {
5
+ authMessage,
6
+ authText,
4
7
  doctorMessage,
5
8
  doctorText,
6
9
  envelope,
@@ -10,8 +13,14 @@ import {
10
13
  toJsonLine,
11
14
  versionData,
12
15
  } from "./output.js";
16
+ import { runRead } from "./read.js";
17
+ import { readMessage, readText } from "./read-output.js";
13
18
  import { runSkill } from "./skill.js";
19
+ import { runSetup } from "./setup.js";
20
+ import { containsKnownCredential, preflightNonHostedSetup } from "./setup-deployment.js";
14
21
  import { VERSION } from "./constants.js";
22
+ import { runVerify } from "./verify.js";
23
+ import { verifyMessage, verifyText } from "./verify-output.js";
15
24
 
16
25
  // Runs one command and resolves with the exit code. With --json, stdout gets
17
26
  // exactly one JSON line and stderr stays empty. Without it, results go to
@@ -40,7 +49,7 @@ async function run(argv, { stdout, stderr }) {
40
49
  if (!parsed.ok) {
41
50
  const result = envelope({
42
51
  command: parsed.command,
43
- outcome: "invalid_input",
52
+ outcome: parsed.outcome,
44
53
  reason: parsed.code,
45
54
  message: parsed.message,
46
55
  });
@@ -61,6 +70,73 @@ async function run(argv, { stdout, stderr }) {
61
70
  return result;
62
71
  }
63
72
 
73
+ if (parsed.command === "login" || parsed.command === "logout") {
74
+ // Human progress goes to stderr, never with --json, so stdout carries
75
+ // only the final result.
76
+ const progress = parsed.json ? () => {} : (text) => stderr.write(`${text}\n`);
77
+ const { outcome, reason, data } =
78
+ parsed.command === "login" ? await runLogin(parsed, progress) : await runLogout(parsed);
79
+ const message = authMessage(outcome, reason);
80
+ const result = envelope({
81
+ command: parsed.command,
82
+ outcome,
83
+ reason,
84
+ data,
85
+ message: outcome === "ok" ? null : message,
86
+ });
87
+ stdout.write(parsed.json ? toJsonLine(result) : authText(result, message));
88
+ return result;
89
+ }
90
+
91
+ if (parsed.command === "setup") {
92
+ const routed = parsed.deployment === "managed"
93
+ ? { proceed: true, profile: "managed" }
94
+ : await preflightNonHostedSetup(parsed);
95
+ const progress = parsed.json ? () => {} : (line) =>
96
+ stderr.write(containsKnownCredential(line, routed) ? "Setup progress.\n" : `${line}\n`);
97
+ const response = routed.proceed
98
+ ? await runSetup({ ...parsed, expectedProfile: routed.profile ?? null }, progress)
99
+ : routed;
100
+ const { outcome, reason, data } = response;
101
+ let result = envelope({ command: "setup", outcome, reason, data,
102
+ message: outcome === "ok" ? null : "Setup did not complete. Review the reason and retry safely." });
103
+ if (containsKnownCredential(result, routed)) {
104
+ result = envelope({ command: "setup", outcome: "unsupported", reason: "credential_echo" });
105
+ }
106
+ const receipt = result.data?.receipt;
107
+ const summary = receipt ? `Workspace: ${receipt.workspace_id}\nDeployment: ${receipt.deployment_profile} at ${receipt.origin}\nCompleted: ${receipt.completed_steps.join(", ")}\nPending: ${receipt.pending_steps.join(", ")}\n` : "";
108
+ const human = `Metergraph setup: ${result.data?.status ?? "not_ready"}\n${summary}Application traffic verified: no\n${result.ok ? "Next: instrument your application and verify an exact trace.\n" : `Result: ${result.outcome} (${result.error.reason})\n`}`;
109
+ if (containsKnownCredential(human, routed)) {
110
+ result = envelope({ command: "setup", outcome: "unsupported", reason: "credential_echo" });
111
+ stdout.write(parsed.json ? toJsonLine(result) : "Setup stopped.\n");
112
+ } else {
113
+ stdout.write(parsed.json ? toJsonLine(result) : human);
114
+ }
115
+ return result;
116
+ }
117
+
118
+ if (READ_COMMANDS.includes(parsed.command)) {
119
+ const { outcome, reason, data } = await runRead(parsed);
120
+ const message = readMessage(outcome, reason);
121
+ const result = envelope({
122
+ command: parsed.command,
123
+ outcome,
124
+ reason,
125
+ data,
126
+ message: outcome === "ok" ? null : message,
127
+ });
128
+ stdout.write(parsed.json ? toJsonLine(result) : readText(result, message));
129
+ return result;
130
+ }
131
+
132
+ if (parsed.command === "verify") {
133
+ const { outcome, reason, data } = await runVerify(parsed);
134
+ const message = verifyMessage(outcome, reason);
135
+ const result = envelope({ command: "verify", outcome, reason, data, message: outcome === "ok" ? null : message });
136
+ stdout.write(parsed.json ? toJsonLine(result) : verifyText(result));
137
+ return result;
138
+ }
139
+
64
140
  if (parsed.command === "skill") {
65
141
  const { outcome, reason, message, data } = runSkill(parsed);
66
142
  const result = envelope({
package/src/constants.js CHANGED
@@ -16,7 +16,8 @@ export const MAX_TIMEOUT_MS = 30000;
16
16
  export const MAX_BODY_BYTES = 32 * 1024;
17
17
 
18
18
  // The public guide a person follows to connect an application to Metergraph.
19
- // The CLI cannot sign in yet, so this is the only next action it offers.
19
+ // It is the next action wherever the CLI itself cannot help, such as doctor
20
+ // results and sign in from a cloud runtime.
20
21
  export const CONNECTION_GUIDE_URL =
21
22
  "https://www.metergraph.dev/docs/guides/agent-access/";
22
23
 
@@ -46,6 +47,75 @@ export const HANDOFF_SKILL_CLIENTS = Object.freeze({
46
47
  });
47
48
  export const HANDOFF_SKILL_RUNTIMES = Object.freeze(["cloud-no-shell"]);
48
49
 
50
+ // Sign in needs a browser on the same machine as the CLI, because the browser
51
+ // hands the authorization back to a loopback listener. Other runtimes are
52
+ // recognized so they get a handoff instead of a usage error.
53
+ export const LOGIN_RUNTIMES = Object.freeze(["local"]);
54
+ export const HANDOFF_LOGIN_RUNTIMES = Object.freeze(["cloud", "cloud-no-shell"]);
55
+
56
+ // How long login waits for the browser to finish, and the fixed deadline for
57
+ // each group of HTTP requests around it.
58
+ export const LOGIN_DEFAULT_TIMEOUT_MS = 300000;
59
+ export const LOGIN_MIN_TIMEOUT_MS = 1000;
60
+ export const LOGIN_MAX_TIMEOUT_MS = 900000;
61
+ export const AUTH_HTTP_TIMEOUT_MS = 15000;
62
+
63
+ // The only scope this CLI requests. It never asks for agent:read (Debug) or
64
+ // agent:replay, and never falls back to them.
65
+ export const METADATA_SCOPE = "agent:metadata";
66
+
67
+ // The service's agent access contract version, as sent in schema_version by
68
+ // GET /v1/agent/workspace and GET /v1/agent/capabilities. It is the
69
+ // service's own string and is unrelated to SCHEMA_VERSION, the version of
70
+ // this CLI's JSON output.
71
+ export const AGENT_CONTRACT_VERSION = "metergraph.agent-access/v1";
72
+
73
+ // Exact paths on the chosen origin. Discovered metadata must name exactly
74
+ // these, so a tampered document cannot send the CLI anywhere else.
75
+ export const AUTH_PATHS = Object.freeze({
76
+ resource: "/v1/agent/mcp",
77
+ issuer: "/v1/oauth",
78
+ authorization: "/v1/oauth/authorize",
79
+ token: "/v1/oauth/token",
80
+ registration: "/v1/oauth/register",
81
+ revocation: "/v1/oauth/revoke",
82
+ signup: "/v1/auth/signup",
83
+ workspace: "/v1/agent/workspace",
84
+ capabilities: "/v1/agent/capabilities",
85
+ });
86
+
87
+ // Bearer-protected Metadata read endpoints. They are fixed paths on the bound
88
+ // origin; the transport accepts a query string only on the paths listed in
89
+ // READ_QUERY_KEYS, and only with those keys. GET /v1/agent/routes takes no
90
+ // query at all, so its rows are bounded by the response size limit and cut to
91
+ // --limit locally.
92
+ export const READ_PATHS = Object.freeze({
93
+ usage: "/v1/agent/usage",
94
+ routes: "/v1/agent/routes",
95
+ traces: "/v1/agent/traces",
96
+ });
97
+ export const READ_QUERY_KEYS = Object.freeze({
98
+ "/v1/agent/usage": Object.freeze(["days", "limit"]),
99
+ "/v1/agent/traces": Object.freeze(["days", "limit", "route", "status", "cursor"]),
100
+ "/v1/cli/setup/credential": Object.freeze(["family_id"]),
101
+ });
102
+
103
+ // Read commands share one total deadline for every request they make,
104
+ // including a token refresh. It is never reset per request or page.
105
+ export const READ_DEFAULT_TIMEOUT_MS = 15000;
106
+ export const READ_MIN_TIMEOUT_MS = 1000;
107
+ export const READ_MAX_TIMEOUT_MS = 60000;
108
+ // A client bound below the service's own max_response_bytes. A larger body is
109
+ // discarded and reported as response_too_large, never read further.
110
+ export const READ_MAX_BYTES = 1024 * 1024;
111
+ export const READ_MAX_DAYS = 90;
112
+ export const READ_MAX_LIMIT = 200;
113
+ export const READ_DEFAULT_DAYS = 7;
114
+ export const READ_DEFAULT_LIMIT = 50;
115
+ export const TRACES_DEFAULT_LIMIT = 20;
116
+ export const MAX_CURSOR_LENGTH = 512;
117
+ export const MAX_FILTER_LENGTH = 256;
118
+
49
119
  // One exit code per outcome. Documented in README.md; changing a value is a
50
120
  // breaking change for scripts. New outcomes are appended.
51
121
  export const EXIT_CODES = Object.freeze({
@@ -59,6 +129,14 @@ export const EXIT_CODES = Object.freeze({
59
129
  redirect_rejected: 7,
60
130
  conflict: 8,
61
131
  filesystem_error: 9,
132
+ authorization_failed: 10,
133
+ verification_failed: 11,
134
+ login_required: 12,
135
+ revocation_unconfirmed: 13,
136
+ capability_unavailable: 14,
137
+ permission_denied: 15,
138
+ rate_limited: 16,
139
+ cancelled: 17,
62
140
  });
63
141
 
64
142
  export const EXIT_CODE_MEANINGS = Object.freeze({
@@ -72,10 +150,27 @@ export const EXIT_CODE_MEANINGS = Object.freeze({
72
150
  "The service answered but reported that it is not healthy, or answered with a server error.",
73
151
  unsupported:
74
152
  "The service answered with a response, deployment profile or status this CLI does not support, " +
75
- "or the skill client or runtime cannot use project skill files. Nothing was written.",
153
+ "or the skill client or runtime cannot use project skill files, " +
154
+ "or sign in cannot run in this environment. Nothing was written.",
76
155
  redirect_rejected: "The service answered with a redirect. Redirects are never followed.",
77
156
  conflict:
78
- "The skill target is not owned by this CLI, was modified, is unsafe, is locked or needs an explicit update. Nothing was changed.",
157
+ "The skill target is not owned by this CLI, was modified, is unsafe, is locked or needs an explicit update, " +
158
+ "or the project is bound to a different origin or workspace. Nothing was changed.",
79
159
  filesystem_error:
80
- "Project files could not be read or written. Partial changes were rolled back unless the message says otherwise.",
160
+ "Project or credential files could not be read or written. Partial changes were rolled back unless the message says otherwise.",
161
+ authorization_failed:
162
+ "Browser authorization did not finish: it was denied, cancelled, timed out or returned an invalid callback. Nothing was saved.",
163
+ verification_failed:
164
+ "The service issued a grant that does not match the requested origin, workspace, client, resource or Metadata scope. Nothing was saved.",
165
+ login_required:
166
+ "No usable sign in for this project: none was saved, it expired, was revoked, lost access or could not be refreshed safely. Run login again.",
167
+ revocation_unconfirmed:
168
+ "Local credentials were removed, but the service did not confirm that the grant was revoked.",
169
+ capability_unavailable:
170
+ "The service does not make this read available to the project's Metadata grant. No data was read.",
171
+ permission_denied:
172
+ "The service refused this read for the signed in grant, for example for a missing scope or permission.",
173
+ rate_limited: "The service asked the CLI to slow down. Nothing was retried. Try again later.",
174
+ cancelled:
175
+ "A read command was interrupted before it finished. Read commands never change workspace configuration or telemetry.",
81
176
  });