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.
- package/LICENSE +201 -0
- package/README.md +876 -2
- package/assets/skill/SKILL.md +73 -0
- package/assets/skill/manifest.json +9 -0
- package/bin/metergraph.js +11 -0
- package/package.json +44 -4
- package/src/args.js +486 -0
- package/src/auth-binding.js +202 -0
- package/src/auth-browser.js +74 -0
- package/src/auth-callback.js +177 -0
- package/src/auth-login.js +410 -0
- package/src/auth-oauth.js +462 -0
- package/src/auth-session.js +174 -0
- package/src/auth-store.js +397 -0
- package/src/cli.js +166 -0
- package/src/constants.js +176 -0
- package/src/deployment-credential.js +207 -0
- package/src/deployment-route.js +449 -0
- package/src/doctor.js +208 -0
- package/src/http.js +129 -0
- package/src/origin.js +37 -0
- package/src/output.js +726 -0
- package/src/read-contract.js +601 -0
- package/src/read-output.js +216 -0
- package/src/read.js +325 -0
- package/src/setup-deployment.js +178 -0
- package/src/setup-env-acl.js +99 -0
- package/src/setup-env-git.js +103 -0
- package/src/setup-env-parse.js +169 -0
- package/src/setup-env.js +685 -0
- package/src/setup-state.js +141 -0
- package/src/setup.js +317 -0
- package/src/skill-bundle.js +53 -0
- package/src/skill.js +449 -0
- package/src/trace-contract.js +129 -0
- package/src/trace-open.js +36 -0
- package/src/transport.js +171 -0
- package/src/verify-output.js +36 -0
- package/src/verify.js +120 -0
package/src/transport.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
|
|
4
|
+
import { PACKAGE_NAME, READ_QUERY_KEYS, VERSION } from "./constants.js";
|
|
5
|
+
import { classifyError } from "./http.js";
|
|
6
|
+
|
|
7
|
+
const USER_AGENT = `${PACKAGE_NAME}/${VERSION}`;
|
|
8
|
+
const MAX_QUERY_LENGTH = 2048;
|
|
9
|
+
|
|
10
|
+
// Builds the request URL. Without a query the path must be a fixed path on
|
|
11
|
+
// the origin with no search part. A query is accepted only as a
|
|
12
|
+
// URLSearchParams the caller built, only on a path in READ_QUERY_KEYS, only
|
|
13
|
+
// with that path's keys, each at most once. Anything else is a programming
|
|
14
|
+
// error, so no input can choose another path, origin or header.
|
|
15
|
+
function requestUrl(origin, pathname, query) {
|
|
16
|
+
const url = new URL(pathname, origin);
|
|
17
|
+
if (url.origin !== origin || url.pathname !== pathname || url.search !== "") {
|
|
18
|
+
// Paths are constants from this package, so this is a programming error.
|
|
19
|
+
throw new Error("request path is not a fixed path on the origin");
|
|
20
|
+
}
|
|
21
|
+
if (query === null) return url;
|
|
22
|
+
const allowed = Object.hasOwn(READ_QUERY_KEYS, pathname) ? READ_QUERY_KEYS[pathname] : null;
|
|
23
|
+
if (!(query instanceof URLSearchParams) || allowed === null) {
|
|
24
|
+
throw new Error("a query is not allowed on this path");
|
|
25
|
+
}
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
for (const key of query.keys()) {
|
|
28
|
+
if (!allowed.includes(key) || seen.has(key)) throw new Error("query key is not allowed");
|
|
29
|
+
seen.add(key);
|
|
30
|
+
}
|
|
31
|
+
const search = query.toString();
|
|
32
|
+
if (search.length > MAX_QUERY_LENGTH) throw new Error("query is too long");
|
|
33
|
+
url.search = search;
|
|
34
|
+
if (url.origin !== origin || url.pathname !== pathname) {
|
|
35
|
+
throw new Error("request path is not a fixed path on the origin");
|
|
36
|
+
}
|
|
37
|
+
return url;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Sends one request to a fixed path on an already validated origin and
|
|
41
|
+
// resolves (never rejects) with either
|
|
42
|
+
// { kind: "response", status, headers, body, tooLarge }
|
|
43
|
+
// { kind: "error", reason, status, sent }
|
|
44
|
+
// reason is a fixed token from classifyError. sent is true when the whole
|
|
45
|
+
// request had been handed to the network before the failure, so the server
|
|
46
|
+
// may have acted on it (for example consumed a refresh token).
|
|
47
|
+
// Redirects are returned with a null body and never followed. Bodies of every
|
|
48
|
+
// other status are read up to maxBytes; a larger body sets tooLarge and is
|
|
49
|
+
// discarded. The only credential ever sent is the bearer argument, and only
|
|
50
|
+
// to this origin. Nothing is taken from the environment, and no cookies are
|
|
51
|
+
// sent or kept. query is null or a URLSearchParams accepted by requestUrl.
|
|
52
|
+
export function send(
|
|
53
|
+
origin,
|
|
54
|
+
pathname,
|
|
55
|
+
{ method = "GET", signal, maxBytes, bearer = null, form = null, json = null, query = null },
|
|
56
|
+
) {
|
|
57
|
+
const url = requestUrl(origin, pathname, query);
|
|
58
|
+
|
|
59
|
+
const headers = {
|
|
60
|
+
accept: "application/json",
|
|
61
|
+
"accept-encoding": "identity",
|
|
62
|
+
"user-agent": USER_AGENT,
|
|
63
|
+
};
|
|
64
|
+
let payload = null;
|
|
65
|
+
if (form !== null) {
|
|
66
|
+
payload = Buffer.from(new URLSearchParams(form).toString());
|
|
67
|
+
headers["content-type"] = "application/x-www-form-urlencoded";
|
|
68
|
+
} else if (json !== null) {
|
|
69
|
+
payload = Buffer.from(JSON.stringify(json));
|
|
70
|
+
headers["content-type"] = "application/json";
|
|
71
|
+
}
|
|
72
|
+
if (payload !== null) headers["content-length"] = String(payload.length);
|
|
73
|
+
if (bearer !== null) headers.authorization = `Bearer ${bearer}`;
|
|
74
|
+
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
let settled = false;
|
|
77
|
+
let status = null;
|
|
78
|
+
let sent = false;
|
|
79
|
+
const finish = (value) => {
|
|
80
|
+
if (!settled) {
|
|
81
|
+
settled = true;
|
|
82
|
+
resolve(value);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const fail = (error) => finish({ kind: "error", reason: classifyError(error, signal), status, sent });
|
|
86
|
+
|
|
87
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
88
|
+
let request;
|
|
89
|
+
try {
|
|
90
|
+
request = transport.request(url, { method, agent: false, signal, headers });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
fail(error);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
request.on("finish", () => {
|
|
97
|
+
sent = true;
|
|
98
|
+
});
|
|
99
|
+
request.on("error", fail);
|
|
100
|
+
request.on("response", (response) => {
|
|
101
|
+
response.on("error", fail);
|
|
102
|
+
status = response.statusCode;
|
|
103
|
+
const respond = (body, tooLarge) =>
|
|
104
|
+
finish({ kind: "response", status, headers: response.headers, body, tooLarge });
|
|
105
|
+
|
|
106
|
+
if (status >= 300 && status < 400) {
|
|
107
|
+
respond(null, false);
|
|
108
|
+
response.destroy();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const declared = response.headers["content-length"];
|
|
112
|
+
if (declared !== undefined && Number(declared) > maxBytes) {
|
|
113
|
+
respond(null, true);
|
|
114
|
+
response.destroy();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const chunks = [];
|
|
118
|
+
let size = 0;
|
|
119
|
+
response.on("data", (chunk) => {
|
|
120
|
+
size += chunk.length;
|
|
121
|
+
if (size > maxBytes) {
|
|
122
|
+
respond(null, true);
|
|
123
|
+
response.destroy();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
chunks.push(chunk);
|
|
127
|
+
});
|
|
128
|
+
response.on("end", () => respond(Buffer.concat(chunks), false));
|
|
129
|
+
// A close without end means the body was cut off.
|
|
130
|
+
response.on("close", () => fail(null));
|
|
131
|
+
});
|
|
132
|
+
request.end(payload ?? undefined);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Maps a transport result that cannot be used to { outcome, reason }, or
|
|
137
|
+
// returns null when the response has a status the caller should inspect.
|
|
138
|
+
export function failureOf(response) {
|
|
139
|
+
if (response.kind === "error") return { outcome: "connection_failed", reason: response.reason };
|
|
140
|
+
const { status } = response;
|
|
141
|
+
if (status >= 300 && status < 400) return { outcome: "redirect_rejected", reason: "redirect" };
|
|
142
|
+
if (status === 503) return { outcome: "unhealthy", reason: "service_unavailable" };
|
|
143
|
+
if (status >= 500) return { outcome: "unhealthy", reason: "server_error" };
|
|
144
|
+
if (response.tooLarge) return { outcome: "unsupported", reason: "response_too_large" };
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A deadline for one group of requests, optionally tied to a cancel signal.
|
|
149
|
+
// timedOut tells a deadline apart from a cancellation.
|
|
150
|
+
export function deadline(ms, cancel = null) {
|
|
151
|
+
const timer = AbortSignal.timeout(ms);
|
|
152
|
+
const signal = cancel === null ? timer : AbortSignal.any([timer, cancel]);
|
|
153
|
+
return { signal, timedOut: () => timer.aborted };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Turns SIGINT, SIGTERM and SIGHUP into a cancel signal for the duration of
|
|
157
|
+
// a command, so every listener, socket and lock is cleaned up and the
|
|
158
|
+
// outcome is reported truthfully instead of the process dying mid-step.
|
|
159
|
+
// release() must be called on every outcome.
|
|
160
|
+
export function trapSignals() {
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
const names = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
163
|
+
const onSignal = () => controller.abort();
|
|
164
|
+
for (const name of names) process.on(name, onSignal);
|
|
165
|
+
return {
|
|
166
|
+
signal: controller.signal,
|
|
167
|
+
release() {
|
|
168
|
+
for (const name of names) process.removeListener(name, onSignal);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const MESSAGES = Object.freeze({
|
|
2
|
+
invalid_trace_identity: "Provide exactly one exact trace or request ID from your application run.",
|
|
3
|
+
invalid_invocation_window: "Provide the start and end timestamps of that application invocation.",
|
|
4
|
+
invocation_window_outside_bounds: "The invocation is outside the requested Metadata window.",
|
|
5
|
+
verification_timeout: "The exact trace was not verified before the deadline.",
|
|
6
|
+
trace_not_found_within_bounds: "The exact trace is not visible in the bounded window yet.",
|
|
7
|
+
ambiguous_trace_identity: "More than one trace matches this request ID. Supply an exact trace ID.",
|
|
8
|
+
trace_outside_invocation_window: "The trace exists outside the stated invocation window.",
|
|
9
|
+
workspace_context_mismatch: "The trace response does not belong to the signed in workspace.",
|
|
10
|
+
link_workspace_binding_unavailable: "The trace is verified, but this dashboard link cannot select its workspace automatically. Open it manually in the correct workspace.",
|
|
11
|
+
server_link_unavailable: "The trace is verified, but the service did not provide a dashboard link.",
|
|
12
|
+
identity_query_unavailable: "The service cannot query an exact trace identity through Metadata access.",
|
|
13
|
+
capability_unavailable: "Trace Metadata access is unavailable for this workspace.",
|
|
14
|
+
login_required: "Sign in to this project with a Metadata-only grant before verifying a trace.",
|
|
15
|
+
forbidden: "This grant cannot read trace Metadata for the bound workspace.",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export function verifyMessage(outcome, reason) {
|
|
19
|
+
return MESSAGES[reason] ?? `Trace verification stopped (${outcome}). No application traffic claim was made.`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function verifyText(result) {
|
|
23
|
+
const data = result.data;
|
|
24
|
+
const lines = ["Metergraph exact trace verification", `Outcome: ${result.outcome} (exit ${result.exit_code})`];
|
|
25
|
+
if (data) {
|
|
26
|
+
lines.push(`Workspace: ${data.workspace.id}`, `Deployment: ${data.deployment_profile}`,
|
|
27
|
+
`Invocation source: ${data.invocation.source}`, `Attempts: ${data.attempts}`,
|
|
28
|
+
`Processed: ${data.readiness.processed}`, `Metadata available: ${data.readiness.metadata_available}`,
|
|
29
|
+
`Application traffic verified: ${data.application_traffic_verified}`);
|
|
30
|
+
if (data.trace) lines.push(`Trace ID: ${data.trace.trace_id}`);
|
|
31
|
+
if (data.app_url) lines.push(`Dashboard link${data.link_workspace_bound ? " (workspace bound)" : " (select the correct workspace)"}: ${data.app_url}`);
|
|
32
|
+
if (data.browser) lines.push(`Browser: ${data.browser}`);
|
|
33
|
+
}
|
|
34
|
+
if (result.error) lines.push(result.error.message);
|
|
35
|
+
return `${lines.join("\n")}\n`;
|
|
36
|
+
}
|
package/src/verify.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { setTimeout as wait } from "node:timers/promises";
|
|
2
|
+
import { verifiedSession } from "./auth-session.js";
|
|
3
|
+
import { Stop } from "./auth-store.js";
|
|
4
|
+
import { AUTH_PATHS, READ_MAX_BYTES } from "./constants.js";
|
|
5
|
+
import { parseJsonObject } from "./http.js";
|
|
6
|
+
import { capabilitySummary } from "./read-contract.js";
|
|
7
|
+
import { deadline, failureOf, send, trapSignals } from "./transport.js";
|
|
8
|
+
import { traceReceipt, traceSelection } from "./trace-contract.js";
|
|
9
|
+
import { openTrace } from "./trace-open.js";
|
|
10
|
+
|
|
11
|
+
// One total deadline includes session refresh/lock waits and every poll.
|
|
12
|
+
// The fixed MCP Metadata query reuses the service's identity resolver. No
|
|
13
|
+
// retained content, ingestion, classification, provider or analysis call is
|
|
14
|
+
// ever made here. Dependency hooks let deterministic tests own their service.
|
|
15
|
+
export async function runVerify(options, dependencies = {}) {
|
|
16
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
17
|
+
const pollIntervalMs = options.pollIntervalMs ?? 1000;
|
|
18
|
+
const maxAttempts = options.maxAttempts ?? 30;
|
|
19
|
+
const selected = traceSelection(options);
|
|
20
|
+
if (!selected.ok) return { outcome: selected.outcome, reason: selected.reason, data: null };
|
|
21
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 60000 ||
|
|
22
|
+
!Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 100 || pollIntervalMs > 10000 ||
|
|
23
|
+
!Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 60) {
|
|
24
|
+
return { outcome: "invalid_input", reason: "invalid_poll_bounds", data: null };
|
|
25
|
+
}
|
|
26
|
+
const trap = trapSignals();
|
|
27
|
+
const cancel = options.cancel ? AbortSignal.any([trap.signal, options.cancel]) : trap.signal;
|
|
28
|
+
const limit = deadline(timeoutMs, cancel);
|
|
29
|
+
let known = [];
|
|
30
|
+
let receipt = null;
|
|
31
|
+
let attempts = 0;
|
|
32
|
+
const end = (outcome, reason, data = receipt) => {
|
|
33
|
+
if (holdsKnown(data, known)) return { outcome: "verification_failed", reason: "credential_in_metadata_response", data: null };
|
|
34
|
+
return { outcome, reason, data: data === null ? null : { ...data, attempts } };
|
|
35
|
+
};
|
|
36
|
+
const interrupted = () => limit.timedOut() ? end("connection_failed", "verification_timeout") : end("cancelled", "cancelled");
|
|
37
|
+
try {
|
|
38
|
+
const sessionResult = await (dependencies.session ?? verifiedSession)({ project: options.project, configDir: options.configDir, cancel: limit.signal });
|
|
39
|
+
if (limit.signal.aborted) return interrupted();
|
|
40
|
+
if (!sessionResult.ok) return end(sessionResult.outcome, sessionResult.reason);
|
|
41
|
+
known = sessionResult.knownCredentials;
|
|
42
|
+
const session = sessionResult.session;
|
|
43
|
+
const ctx = { origin: session.origin, workspaceId: session.workspaceId, profile: session.profile };
|
|
44
|
+
const capabilities = capabilitySummary(sessionResult.documents.capabilities, ctx, new Set());
|
|
45
|
+
if (!capabilities.ok) return end(capabilities.outcome, capabilities.reason);
|
|
46
|
+
if (capabilities.value.agent.trace_metadata?.available !== true) return end("capability_unavailable", "capability_unavailable");
|
|
47
|
+
if (selected.value.days > capabilities.value.bounds.max_days || capabilities.value.bounds.max_rows < 2) return end("unsupported", "exceeds_service_bounds");
|
|
48
|
+
for (attempts = 1; attempts <= maxAttempts; attempts++) {
|
|
49
|
+
if (limit.signal.aborted) return interrupted();
|
|
50
|
+
const response = await queryTrace(session, selected.value, attempts, limit.signal, dependencies.send ?? send);
|
|
51
|
+
if (limit.signal.aborted) return interrupted();
|
|
52
|
+
if (!response.ok) return end(response.outcome, response.reason);
|
|
53
|
+
const checked = traceReceipt(response.body, ctx, selected.value);
|
|
54
|
+
if (!checked.ok) return end(checked.outcome, checked.reason);
|
|
55
|
+
receipt = checked.value;
|
|
56
|
+
if (holdsKnown(receipt, known)) return end("verification_failed", "credential_in_metadata_response", null);
|
|
57
|
+
if (checked.found) {
|
|
58
|
+
if (options.open) {
|
|
59
|
+
const opened = await openTrace(receipt, { ...options, signal: limit.signal }, dependencies.launch);
|
|
60
|
+
if (limit.signal.aborted) return interrupted();
|
|
61
|
+
return end(opened.outcome, opened.reason, opened.data);
|
|
62
|
+
}
|
|
63
|
+
return end("ok", null);
|
|
64
|
+
}
|
|
65
|
+
if (attempts === maxAttempts) return end("verification_failed", "trace_not_found_within_bounds");
|
|
66
|
+
try { await wait(pollIntervalMs, null, { signal: limit.signal }); } catch { return interrupted(); }
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (limit.signal.aborted) return interrupted();
|
|
70
|
+
if (error instanceof Stop) return end(error.outcome, error.reason);
|
|
71
|
+
throw error;
|
|
72
|
+
} finally { trap.release(); }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function queryTrace(session, selection, id, signal, request) {
|
|
76
|
+
const args = { days: selection.days, limit: 2, [selection.traceId === null ? "request_id" : "trace_id"]: selection.traceId ?? selection.requestId };
|
|
77
|
+
const response = await request(session.origin, AUTH_PATHS.resource, {
|
|
78
|
+
method: "POST", signal, maxBytes: READ_MAX_BYTES, bearer: session.accessToken,
|
|
79
|
+
json: { jsonrpc: "2.0", id, method: "tools/call", params: { name: "metergraph_query_traces", arguments: args } },
|
|
80
|
+
});
|
|
81
|
+
if (response.kind === "response") {
|
|
82
|
+
if (response.status === 401) return failure("login_required", "access_revoked");
|
|
83
|
+
if (response.status === 403) return failure("permission_denied", "forbidden");
|
|
84
|
+
if (response.status === 429) return failure("rate_limited", "rate_limited");
|
|
85
|
+
if (response.status === 404) return failure("unsupported", "endpoint_unavailable");
|
|
86
|
+
}
|
|
87
|
+
const fault = failureOf(response);
|
|
88
|
+
if (fault !== null) return { ok: false, ...fault };
|
|
89
|
+
if (response.status !== 200) return failure("unsupported", "unexpected_status");
|
|
90
|
+
const message = parseJsonObject(response);
|
|
91
|
+
const result = message?.result;
|
|
92
|
+
if (message?.jsonrpc === "2.0" && message.id === id && [-32601, -32602].includes(message.error?.code)) {
|
|
93
|
+
return failure("unsupported", "identity_query_unavailable");
|
|
94
|
+
}
|
|
95
|
+
if (message?.jsonrpc !== "2.0" || message.id !== id || message.error !== undefined ||
|
|
96
|
+
result === null || typeof result !== "object" || Array.isArray(result) || typeof result.isError !== "boolean") return failure("verification_failed", "trace_response_invalid");
|
|
97
|
+
if (result.isError) {
|
|
98
|
+
const code = result.structuredContent?.error?.code;
|
|
99
|
+
const errors = { forbidden: ["permission_denied", "forbidden"], unsupported_capability: ["capability_unavailable", "capability_unavailable"], not_found: ["unsupported", "identity_query_unavailable"], rate_limited: ["rate_limited", "rate_limited"], response_too_large: ["unsupported", "response_too_large"] };
|
|
100
|
+
return Object.hasOwn(errors, code) ? failure(...errors[code]) : failure("unsupported", "identity_query_unavailable");
|
|
101
|
+
}
|
|
102
|
+
return { ok: true, body: result.structuredContent };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const failure = (outcome, reason) => ({ ok: false, outcome, reason });
|
|
106
|
+
function holdsKnown(value, known) {
|
|
107
|
+
if (typeof value === "string") {
|
|
108
|
+
// A server link can URI-encode a credential into an otherwise allowed
|
|
109
|
+
// query value. Check the decoded URL as well as its printed spelling.
|
|
110
|
+
const variants = [value];
|
|
111
|
+
try { variants.push(decodeURIComponent(value)); } catch { /* Plain metadata need not be URI encoded. */ }
|
|
112
|
+
if (value.includes("#traces?")) {
|
|
113
|
+
const query = new URLSearchParams(value.slice(value.indexOf("#traces?") + 8));
|
|
114
|
+
for (const [key, entry] of query) variants.push(key, entry);
|
|
115
|
+
}
|
|
116
|
+
return known.some((secret) => typeof secret === "string" && secret.length > 0 && variants.some((variant) => variant.includes(secret)));
|
|
117
|
+
}
|
|
118
|
+
if (value === null || typeof value !== "object") return false;
|
|
119
|
+
return Object.entries(value).some(([key, entry]) => holdsKnown(key, known) || holdsKnown(entry, known));
|
|
120
|
+
}
|