metergraph-cli 0.0.0-stage → 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,176 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ const packageJson = JSON.parse(
4
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
5
+ );
6
+
7
+ export const PACKAGE_NAME = packageJson.name;
8
+ export const VERSION = packageJson.version;
9
+
10
+ export const SCHEMA_VERSION = 1;
11
+ export const DEFAULT_ORIGIN = "https://app.metergraph.dev";
12
+
13
+ export const DEFAULT_TIMEOUT_MS = 5000;
14
+ export const MIN_TIMEOUT_MS = 100;
15
+ export const MAX_TIMEOUT_MS = 30000;
16
+ export const MAX_BODY_BYTES = 32 * 1024;
17
+
18
+ // The public guide a person follows to connect an application to Metergraph.
19
+ // It is the next action wherever the CLI itself cannot help, such as doctor
20
+ // results and sign in from a cloud runtime.
21
+ export const CONNECTION_GUIDE_URL =
22
+ "https://www.metergraph.dev/docs/guides/agent-access/";
23
+
24
+ // Deployment profiles recognized by the probe contract. Any other value,
25
+ // including a well-formed one, is reported as unsupported and never echoed.
26
+ export const SUPPORTED_PROFILES = Object.freeze([
27
+ "local",
28
+ "managed",
29
+ "byoc-core",
30
+ ]);
31
+
32
+ // Clients that load project skills from a native directory, from each
33
+ // client's own documentation. Every client has its own directory, so
34
+ // installing for one never touches another client's files.
35
+ export const SKILL_CLIENTS = Object.freeze({
36
+ codex: Object.freeze({ label: "Codex", dir: ".agents" }),
37
+ claude: Object.freeze({ label: "Claude Code", dir: ".claude" }),
38
+ cursor: Object.freeze({ label: "Cursor", dir: ".cursor" }),
39
+ });
40
+ export const SKILL_RUNTIMES = Object.freeze(["local", "cloud"]);
41
+
42
+ // Recognized values that cannot load project skill files. They get a pointer
43
+ // to the connection guide instead of a usage error, and nothing is written.
44
+ export const HANDOFF_SKILL_CLIENTS = Object.freeze({
45
+ "claude-desktop": "Claude Desktop",
46
+ chatgpt: "ChatGPT",
47
+ });
48
+ export const HANDOFF_SKILL_RUNTIMES = Object.freeze(["cloud-no-shell"]);
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
+
119
+ // One exit code per outcome. Documented in README.md; changing a value is a
120
+ // breaking change for scripts. New outcomes are appended.
121
+ export const EXIT_CODES = Object.freeze({
122
+ ok: 0,
123
+ internal_error: 1,
124
+ invalid_input: 2,
125
+ authentication_required: 3,
126
+ connection_failed: 4,
127
+ unhealthy: 5,
128
+ unsupported: 6,
129
+ redirect_rejected: 7,
130
+ conflict: 8,
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,
140
+ });
141
+
142
+ export const EXIT_CODE_MEANINGS = Object.freeze({
143
+ ok: "Command succeeded. Doctor does not return this in this preview.",
144
+ internal_error: "Unexpected internal failure in the CLI.",
145
+ invalid_input: "Unknown command or argument, or an invalid option value. No request was made.",
146
+ authentication_required:
147
+ "Service is reachable, healthy and supported, and requires authentication. No workspace is connected.",
148
+ connection_failed: "The origin could not be reached, the connection failed, or the probe timed out.",
149
+ unhealthy:
150
+ "The service answered but reported that it is not healthy, or answered with a server error.",
151
+ unsupported:
152
+ "The service answered with a response, deployment profile or status this CLI does not support, " +
153
+ "or the skill client or runtime cannot use project skill files, " +
154
+ "or sign in cannot run in this environment. Nothing was written.",
155
+ redirect_rejected: "The service answered with a redirect. Redirects are never followed.",
156
+ conflict:
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.",
159
+ filesystem_error:
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.",
176
+ });
@@ -0,0 +1,207 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ // Reads the separate Metadata agent credential for deployment verification
5
+ // from one explicit, absolute, private file. There is no argv, stdin or
6
+ // environment variable form. The file is read, never copied, written or
7
+ // persisted, and the token is returned only to the verifier that asked for
8
+ // it. Results are { ok: true, token } or { ok: false, outcome, reason } with
9
+ // fixed tokens: no path, file content or system error text is ever returned.
10
+ //
11
+ // The file must be a regular file owned by the current user with mode 0600
12
+ // and exactly one link, holding one bearer token and at most one trailing
13
+ // newline. No component of the path may be a symbolic link, so callers pass
14
+ // the canonical path (on macOS, /private/var/... rather than /var/...). The
15
+ // directory holding the file must be owned by the current user or root and
16
+ // must not be writable by group or others. Higher directories may be writable
17
+ // by others only when the sticky bit is set, which is how shared temporary
18
+ // directories such as /tmp are protected; the file's own directory gets no
19
+ // such allowance.
20
+ //
21
+ // Windows has no owner-only mode bits to check, and this CLI has no proof of
22
+ // a file's ACL, so the read fails closed there with a platform handoff.
23
+
24
+ const MAX_PATH_LENGTH = 4096;
25
+ // No token is shorter than this, so a fixed string that is shorter can never
26
+ // contain one.
27
+ export const CREDENTIAL_MIN_LENGTH = 16;
28
+ const TOKEN = new RegExp(`^[\\x21-\\x7e]{${CREDENTIAL_MIN_LENGTH},8192}$`);
29
+ // The longest token plus a CRLF line ending.
30
+ export const CREDENTIAL_MAX_BYTES = 8192 + 2;
31
+
32
+ const OPEN_FLAGS =
33
+ fs.constants.O_RDONLY |
34
+ (fs.constants.O_NOFOLLOW ?? 0) |
35
+ // A FIFO opened without O_NONBLOCK blocks until a writer appears.
36
+ (fs.constants.O_NONBLOCK ?? 0) |
37
+ (fs.constants.O_NOCTTY ?? 0);
38
+
39
+ const fail = (reason) => ({ ok: false, outcome: "filesystem_error", reason });
40
+
41
+ // options.afterOpen and options.afterRead are test seams only: afterOpen runs
42
+ // after the file is opened and before it is checked, afterRead after the
43
+ // content is read and before it is checked again, so a test can replace,
44
+ // rewrite or chmod the file in between.
45
+ export function readCredentialFile(file, { platform = process.platform, afterOpen = null, afterRead = null } = {}) {
46
+ if (platform === "win32") return { ok: false, outcome: "unsupported", reason: "platform_credential_handoff" };
47
+ if (!validPath(file)) return fail("credential_path_invalid");
48
+ const uid = process.getuid();
49
+
50
+ const parents = checkParents(file, uid);
51
+ if (!parents.ok) return parents;
52
+
53
+ let before;
54
+ try {
55
+ before = fs.lstatSync(file, { bigint: true });
56
+ } catch (error) {
57
+ return fail(error?.code === "ENOENT" ? "credential_missing" : "credential_unreadable");
58
+ }
59
+ if (before.isSymbolicLink()) return fail("credential_symlink");
60
+ const problem = credentialStatProblem(before, uid);
61
+ if (problem !== null) return fail(problem);
62
+
63
+ let fd;
64
+ try {
65
+ fd = fs.openSync(file, OPEN_FLAGS);
66
+ } catch (error) {
67
+ if (error?.code === "ELOOP") return fail("credential_symlink");
68
+ return fail(error?.code === "ENOENT" ? "credential_changed" : "credential_unreadable");
69
+ }
70
+ let buffer = null;
71
+ try {
72
+ if (afterOpen !== null) afterOpen();
73
+ const opened = fs.fstatSync(fd, { bigint: true });
74
+ // No links left means the name was moved away or replaced after the open.
75
+ if (opened.nlink === 0n) return fail("credential_changed");
76
+ const openedProblem = credentialStatProblem(opened, uid);
77
+ if (openedProblem !== null) return fail(openedProblem);
78
+ if (!sameInode(opened, before)) return fail("credential_changed");
79
+
80
+ buffer = Buffer.alloc(CREDENTIAL_MAX_BYTES + 1);
81
+ let length = 0;
82
+ while (length < buffer.length) {
83
+ const count = fs.readSync(fd, buffer, length, buffer.length - length, length);
84
+ if (count === 0) break;
85
+ length += count;
86
+ }
87
+ if (length > CREDENTIAL_MAX_BYTES) return fail("credential_too_large");
88
+ if (afterRead !== null) afterRead();
89
+
90
+ // After the read the file must still pass every check and be the same
91
+ // file with the same metadata. Rewriting it in place, even with content
92
+ // of the same length, or changing its mode, owner or links moves ctime or
93
+ // mtime, which are compared to the nanosecond.
94
+ const after = fs.fstatSync(fd, { bigint: true });
95
+ if (after.nlink === 0n) return fail("credential_changed");
96
+ const afterProblem = credentialStatProblem(after, uid);
97
+ if (afterProblem !== null) return fail(afterProblem);
98
+ if (!unchanged(after, opened) || after.size !== BigInt(length)) return fail("credential_changed");
99
+ let current;
100
+ try {
101
+ current = fs.lstatSync(file, { bigint: true });
102
+ } catch {
103
+ return fail("credential_changed");
104
+ }
105
+ if (!unchanged(current, opened)) return fail("credential_changed");
106
+ // Every directory above it must still be the ones checked.
107
+ const again = checkParents(file, uid);
108
+ if (!again.ok) return again;
109
+ if (again.inodes.some((inode, index) => !sameInode(inode, parents.inodes[index]))) {
110
+ return fail("credential_changed");
111
+ }
112
+
113
+ return parseToken(buffer.subarray(0, length));
114
+ } catch {
115
+ return fail("credential_unreadable");
116
+ } finally {
117
+ if (buffer !== null) buffer.fill(0);
118
+ fs.closeSync(fd);
119
+ }
120
+ }
121
+
122
+ // Returns a fixed reason when a stat of the credential file is not a private,
123
+ // owner-only, singly linked regular file of a usable size, otherwise null.
124
+ // Accepts number or bigint stats. Exported for tests that cannot create files
125
+ // owned by another user.
126
+ export function credentialStatProblem(stat, uid) {
127
+ if (!stat.isFile()) return "credential_not_regular";
128
+ if (Number(stat.uid) !== uid) return "credential_not_owner";
129
+ if ((Number(stat.mode) & 0o7777) !== 0o600) return "credential_permissions_unsafe";
130
+ if (Number(stat.nlink) !== 1) return "credential_hardlinked";
131
+ if (Number(stat.size) === 0) return "credential_empty";
132
+ if (Number(stat.size) > CREDENTIAL_MAX_BYTES) return "credential_too_large";
133
+ return null;
134
+ }
135
+
136
+ function validPath(file) {
137
+ return (
138
+ typeof file === "string" &&
139
+ file.length > 1 &&
140
+ file.length <= MAX_PATH_LENGTH &&
141
+ !file.includes("\0") &&
142
+ path.isAbsolute(file) &&
143
+ path.normalize(file) === file &&
144
+ !file.endsWith(path.sep)
145
+ );
146
+ }
147
+
148
+ // Checks every directory from the root down to the file's own directory.
149
+ // Returns { ok: true, inodes } so a second walk can prove nothing moved.
150
+ function checkParents(file, uid) {
151
+ const directories = [];
152
+ for (let dir = path.dirname(file); ; dir = path.dirname(dir)) {
153
+ directories.unshift(dir);
154
+ if (path.dirname(dir) === dir) break;
155
+ }
156
+ const inodes = [];
157
+ for (const [index, dir] of directories.entries()) {
158
+ let stat;
159
+ try {
160
+ stat = fs.lstatSync(dir);
161
+ } catch (error) {
162
+ return fail(error?.code === "ENOENT" ? "credential_missing" : "credential_unreadable");
163
+ }
164
+ if (stat.isSymbolicLink()) return fail("credential_path_symlink");
165
+ if (!stat.isDirectory()) return fail("credential_path_invalid");
166
+ if (stat.uid !== uid && stat.uid !== 0) return fail("credential_parent_unsafe");
167
+ const shared = (stat.mode & 0o022) !== 0;
168
+ const own = index === directories.length - 1;
169
+ if (shared && (own || (stat.mode & 0o1000) === 0)) return fail("credential_parent_unsafe");
170
+ inodes.push(stat);
171
+ }
172
+ return { ok: true, inodes };
173
+ }
174
+
175
+ function sameInode(a, b) {
176
+ return a.dev === b.dev && a.ino === b.ino;
177
+ }
178
+
179
+ // The same file with the same owner, mode, links, size and change times.
180
+ // Both are bigint stats, so times compare to the nanosecond.
181
+ function unchanged(a, b) {
182
+ return (
183
+ sameInode(a, b) &&
184
+ a.uid === b.uid &&
185
+ a.mode === b.mode &&
186
+ a.nlink === b.nlink &&
187
+ a.size === b.size &&
188
+ a.mtimeNs === b.mtimeNs &&
189
+ a.ctimeNs === b.ctimeNs
190
+ );
191
+ }
192
+
193
+ function parseToken(bytes) {
194
+ let text;
195
+ try {
196
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
197
+ } catch {
198
+ return fail("credential_malformed");
199
+ }
200
+ if (text.endsWith("\r\n")) text = text.slice(0, -2);
201
+ else if (text.endsWith("\n")) text = text.slice(0, -1);
202
+ if (text.length === 0) return fail("credential_empty");
203
+ // One token only: any whitespace means a second token, a label such as
204
+ // "Bearer" or a second line.
205
+ if (!TOKEN.test(text)) return fail("credential_malformed");
206
+ return { ok: true, token: text };
207
+ }