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
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { discover, normalizeUuid, verifyContext } from "./auth-oauth.js";
|
|
4
|
+
import { AGENT_CONTRACT_VERSION, CONNECTION_GUIDE_URL, MAX_BODY_BYTES, METADATA_SCOPE } from "./constants.js";
|
|
5
|
+
import { CREDENTIAL_MIN_LENGTH, readCredentialFile } from "./deployment-credential.js";
|
|
6
|
+
import { checkDeployment } from "./doctor.js";
|
|
7
|
+
import { parseJsonObject } from "./http.js";
|
|
8
|
+
import { parseOrigin } from "./origin.js";
|
|
9
|
+
import { capabilitySummary } from "./read-contract.js";
|
|
10
|
+
import { deadline, send } from "./transport.js";
|
|
11
|
+
|
|
12
|
+
// Internal deployment routing for customer-operated Metergraph deployments.
|
|
13
|
+
// planDeploymentRoute is pure: it checks enumerated inputs and reports which
|
|
14
|
+
// prerequisites stand between the caller and a Metadata-only agent
|
|
15
|
+
// connection. verifyDeploymentRoute then proves the route afresh against the
|
|
16
|
+
// service. Neither installs, provisions, registers or writes anything, and
|
|
17
|
+
// results hold only fixed tokens, the normalized origin and workspace id, and
|
|
18
|
+
// statuses: no raw input, response text or credential.
|
|
19
|
+
//
|
|
20
|
+
// Every result is { ok, outcome, reason, plan, verification, receipt }, and
|
|
21
|
+
// every outcome is one of the CLI's EXIT_CODES outcomes. A result produced
|
|
22
|
+
// after the credential file was read also carries a non-enumerable
|
|
23
|
+
// knownCredentials array, so a caller that prints it can apply the same
|
|
24
|
+
// known-credential suppression as other commands. It is never serialized.
|
|
25
|
+
|
|
26
|
+
const PREREQUISITES = Object.freeze({
|
|
27
|
+
// A customer machine running the released commercial bundle.
|
|
28
|
+
"customer-local": Object.freeze([
|
|
29
|
+
"released_signed_bundle",
|
|
30
|
+
"registry_invitation",
|
|
31
|
+
"bundle_started_verified",
|
|
32
|
+
"local_admin_configured",
|
|
33
|
+
"metadata_agent_credential",
|
|
34
|
+
]),
|
|
35
|
+
// A deployment an operator provisions in the customer's own cloud.
|
|
36
|
+
byoc: Object.freeze([
|
|
37
|
+
"operator_provisioning",
|
|
38
|
+
"private_network_reachability",
|
|
39
|
+
"identity_membership_configured",
|
|
40
|
+
"metadata_agent_credential",
|
|
41
|
+
]),
|
|
42
|
+
// The self-hosted open source server. It has no hosted sign up, keys page
|
|
43
|
+
// or registry. Installing a server distribution, the server serving
|
|
44
|
+
// deployment discovery and advertising the Metadata scope, ingestion tokens
|
|
45
|
+
// (MG_TOKENS) and agent read tokens (MG_AGENT_TOKENS) are each separate
|
|
46
|
+
// operator steps. A server without deployment discovery and exact Metadata
|
|
47
|
+
// support requires an operator upgrade or configuration handoff.
|
|
48
|
+
oss: Object.freeze([
|
|
49
|
+
"server_distribution_installed",
|
|
50
|
+
"deployment_discovery_supported",
|
|
51
|
+
"metadata_scope_supported",
|
|
52
|
+
"ingestion_tokens_configured",
|
|
53
|
+
"agent_read_tokens_configured",
|
|
54
|
+
"metadata_agent_credential",
|
|
55
|
+
]),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Prerequisites only the server operator can meet, by upgrading or
|
|
59
|
+
// configuring the server. They get an operator handoff.
|
|
60
|
+
const OPERATOR_PREREQUISITES = Object.freeze(["deployment_discovery_supported", "metadata_scope_supported"]);
|
|
61
|
+
|
|
62
|
+
// Each model expects exactly one profile. oss is never treated as local or
|
|
63
|
+
// any other profile.
|
|
64
|
+
const PROFILES = Object.freeze({ "customer-local": "local", byoc: "byoc-core", oss: "oss" });
|
|
65
|
+
|
|
66
|
+
// Only a shell on the customer's own machine can reach a local deployment and
|
|
67
|
+
// read its private credential file. Hosted agent runtimes and remote shells
|
|
68
|
+
// get a handoff; nothing is tunnelled, forwarded or relaxed for them.
|
|
69
|
+
const RUNTIMES = Object.freeze(["local"]);
|
|
70
|
+
const HANDOFF_RUNTIMES = Object.freeze(["cloud", "cloud-no-shell", "remote-ssh"]);
|
|
71
|
+
const STATUSES = Object.freeze(["ready", "required", "unknown"]);
|
|
72
|
+
|
|
73
|
+
// The only next action kinds a result can carry.
|
|
74
|
+
const ACTION_KINDS = Object.freeze([
|
|
75
|
+
"verify_route",
|
|
76
|
+
"complete_prerequisite",
|
|
77
|
+
"oss_operator_handoff",
|
|
78
|
+
"run_on_customer_machine",
|
|
79
|
+
"use_https_origin",
|
|
80
|
+
"connection_guide",
|
|
81
|
+
"check_private_network",
|
|
82
|
+
"platform_credential_handoff",
|
|
83
|
+
"fix_credential_file",
|
|
84
|
+
"use_metadata_only_credential",
|
|
85
|
+
"retry",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
// Facts agent verification says nothing about. They are reported as not
|
|
89
|
+
// checked on every verification and never inferred from it.
|
|
90
|
+
const NOT_CHECKED = Object.freeze(["ingest", "provider", "sdk", "registration", "bundle"]);
|
|
91
|
+
|
|
92
|
+
// Receipts record how far a previous run got. They are context only: a
|
|
93
|
+
// receipt never authorizes anything or skips a check.
|
|
94
|
+
// prerequisites: every prerequisite was reported ready
|
|
95
|
+
// preflight: the service reported the expected deployment profile and
|
|
96
|
+
// advertised the Metadata scope on its fixed OAuth metadata paths
|
|
97
|
+
// verified: the Metadata credential was verified for the workspace
|
|
98
|
+
const RECEIPT_VERSION = 1;
|
|
99
|
+
const PHASES = Object.freeze(["prerequisites", "preflight", "verified"]);
|
|
100
|
+
const RECEIPT_KEYS = Object.freeze([
|
|
101
|
+
"version",
|
|
102
|
+
"model",
|
|
103
|
+
"runtime",
|
|
104
|
+
"origin",
|
|
105
|
+
"workspace_id",
|
|
106
|
+
"deployment_profile",
|
|
107
|
+
"phase",
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
// Returned instead of any result that would contain the credential. The
|
|
111
|
+
// fallback, used when the credential happens to contain the first refusal's
|
|
112
|
+
// own text, uses only strings shorter than any credential, including its keys.
|
|
113
|
+
const ECHO_REFUSAL = Object.freeze(["verification_failed", "credential_in_metadata_response"]);
|
|
114
|
+
const ECHO_FALLBACK = Object.freeze(["unsupported", "credential_echo"]);
|
|
115
|
+
|
|
116
|
+
const DEPLOYMENT_PATH = "/v1/deployment";
|
|
117
|
+
const MIN_TIMEOUT_MS = 100;
|
|
118
|
+
const MAX_TIMEOUT_MS = 60000;
|
|
119
|
+
|
|
120
|
+
function action(kind, prerequisite = null) {
|
|
121
|
+
// Kinds are constants from this module, so this is a programming error.
|
|
122
|
+
if (!ACTION_KINDS.includes(kind)) throw new Error("unknown next action kind");
|
|
123
|
+
return { kind, prerequisite, url: CONNECTION_GUIDE_URL };
|
|
124
|
+
}
|
|
125
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
126
|
+
const result = (ok, outcome, reason, { plan = null, verification = null, receipt = null } = {}) => ({
|
|
127
|
+
ok,
|
|
128
|
+
outcome,
|
|
129
|
+
reason,
|
|
130
|
+
plan,
|
|
131
|
+
verification,
|
|
132
|
+
receipt,
|
|
133
|
+
});
|
|
134
|
+
const invalid = (reason) => result(false, "invalid_input", reason);
|
|
135
|
+
|
|
136
|
+
export function planDeploymentRoute(input) {
|
|
137
|
+
const context = routeContext(input);
|
|
138
|
+
if (!context.ok) return invalid(context.reason);
|
|
139
|
+
const { model, runtime, origin, workspaceId, profile, statuses } = context;
|
|
140
|
+
|
|
141
|
+
const plan = {
|
|
142
|
+
model,
|
|
143
|
+
runtime,
|
|
144
|
+
origin,
|
|
145
|
+
workspace_id: workspaceId,
|
|
146
|
+
deployment_profile: profile,
|
|
147
|
+
prerequisites: PREREQUISITES[model].map((name) => ({ name, status: statuses[name] })),
|
|
148
|
+
next_action: null,
|
|
149
|
+
unsupported_reasons: [],
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if (HANDOFF_RUNTIMES.includes(runtime)) plan.unsupported_reasons.push("runtime_not_customer_machine");
|
|
153
|
+
if (model === "byoc" && !origin.startsWith("https:")) plan.unsupported_reasons.push("byoc_requires_https");
|
|
154
|
+
if (plan.unsupported_reasons.length > 0) {
|
|
155
|
+
plan.next_action = action(
|
|
156
|
+
HANDOFF_RUNTIMES.includes(runtime) ? "run_on_customer_machine" : "use_https_origin",
|
|
157
|
+
);
|
|
158
|
+
return result(false, "unsupported", plan.unsupported_reasons[0], { plan });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const pending = plan.prerequisites.find((entry) => entry.status !== "ready");
|
|
162
|
+
if (pending !== undefined) {
|
|
163
|
+
// A handoff only: the person or operator completes it. Nothing is
|
|
164
|
+
// installed, provisioned or requested on their behalf.
|
|
165
|
+
const kind = OPERATOR_PREREQUISITES.includes(pending.name) ? "oss_operator_handoff" : "complete_prerequisite";
|
|
166
|
+
plan.next_action = action(kind, pending.name);
|
|
167
|
+
const reason = pending.status === "required" ? "prerequisite_required" : "prerequisite_unknown";
|
|
168
|
+
plan.unsupported_reasons.push(reason);
|
|
169
|
+
return result(false, "unsupported", reason, { plan });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
plan.next_action = action("verify_route");
|
|
173
|
+
return result(true, "ok", "prerequisites_ready", { plan, receipt: receiptFor(plan, "prerequisites") });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Proves the route with fresh requests, whatever a previous receipt says:
|
|
177
|
+
// 1. GET /v1/deployment on the origin with no credential. A redirect,
|
|
178
|
+
// missing endpoint, unexpected or mismatched profile, or an unreachable
|
|
179
|
+
// or unhealthy service stops here.
|
|
180
|
+
// 2. OAuth discovery with no credential, through auth-oauth discover: the
|
|
181
|
+
// protected resource and authorization server metadata must name the
|
|
182
|
+
// fixed resource, issuer and endpoints on this origin, with no redirect,
|
|
183
|
+
// and advertise the Metadata scope. Nothing is registered. There is no
|
|
184
|
+
// weaker fallback.
|
|
185
|
+
// 3. Only then reads the Metadata credential from credentialFile.
|
|
186
|
+
// 4. GET /v1/agent/workspace and /v1/agent/capabilities with it, through
|
|
187
|
+
// verifyContext, then the full capability summary and a check that no
|
|
188
|
+
// capability that reads content, replays, calls a provider or mutates is
|
|
189
|
+
// available, including ones this CLI does not know.
|
|
190
|
+
// One deadline of timeoutMs and the optional cancel signal span every step,
|
|
191
|
+
// and are checked after every request and immediately before success. No
|
|
192
|
+
// ingest, evaluation, provider or trace request is ever made, and the
|
|
193
|
+
// credential is never returned. A broader key fails closed: there is no
|
|
194
|
+
// fallback to Debug or Replay access.
|
|
195
|
+
export async function verifyDeploymentRoute(input) {
|
|
196
|
+
const planned = planDeploymentRoute(input);
|
|
197
|
+
if (!planned.ok) return planned;
|
|
198
|
+
const { plan } = planned;
|
|
199
|
+
const { credentialFile, timeoutMs = 15000, cancel = null } = input;
|
|
200
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < MIN_TIMEOUT_MS || timeoutMs > MAX_TIMEOUT_MS) {
|
|
201
|
+
return invalid("timeout_invalid");
|
|
202
|
+
}
|
|
203
|
+
if (cancel !== null && !(cancel instanceof AbortSignal)) return invalid("cancel_invalid");
|
|
204
|
+
if (typeof credentialFile !== "string" || !path.isAbsolute(credentialFile)) {
|
|
205
|
+
return invalid("credential_file_invalid");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const verification = {
|
|
209
|
+
deployment_discovery: "not_run",
|
|
210
|
+
metadata_discovery: "not_run",
|
|
211
|
+
credential_file: "not_run",
|
|
212
|
+
agent_metadata_access: "not_run",
|
|
213
|
+
contract: null,
|
|
214
|
+
access_scope: null,
|
|
215
|
+
content_included: null,
|
|
216
|
+
capabilities: null,
|
|
217
|
+
not_checked: [...NOT_CHECKED],
|
|
218
|
+
next_action: null,
|
|
219
|
+
};
|
|
220
|
+
let phase = "prerequisites";
|
|
221
|
+
// The step in progress, marked interrupted if the run stops during it.
|
|
222
|
+
let step = "deployment_discovery";
|
|
223
|
+
let token = null;
|
|
224
|
+
const { signal, timedOut } = deadline(timeoutMs, cancel);
|
|
225
|
+
const stop = (outcome, reason, kind = "connection_guide") => {
|
|
226
|
+
verification.next_action = action(kind);
|
|
227
|
+
plan.next_action = verification.next_action;
|
|
228
|
+
if (outcome === "unsupported") plan.unsupported_reasons.push(reason);
|
|
229
|
+
const stopped = result(false, outcome, reason, { plan, verification, receipt: receiptFor(plan, phase) });
|
|
230
|
+
return token === null ? stopped : withoutCredential(stopped, token);
|
|
231
|
+
};
|
|
232
|
+
const fail = (outcome, reason, kind) => {
|
|
233
|
+
verification[step] = "failed";
|
|
234
|
+
return stop(outcome, reason, kind);
|
|
235
|
+
};
|
|
236
|
+
const interrupted = () => {
|
|
237
|
+
verification[step] = "interrupted";
|
|
238
|
+
if (timedOut()) return stop("connection_failed", "timeout");
|
|
239
|
+
return stop("cancelled", "cancelled", "retry");
|
|
240
|
+
};
|
|
241
|
+
// Handoff for a discovery step the service does not support.
|
|
242
|
+
const unsupportedKind = (outcome) => {
|
|
243
|
+
if (plan.model === "oss" && outcome === "unsupported") return "oss_operator_handoff";
|
|
244
|
+
if (plan.model === "byoc" && outcome === "connection_failed") return "check_private_network";
|
|
245
|
+
return "connection_guide";
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
if (signal.aborted) return interrupted();
|
|
249
|
+
const response = await send(plan.origin, DEPLOYMENT_PATH, { signal, maxBytes: MAX_BODY_BYTES });
|
|
250
|
+
if (signal.aborted) return interrupted();
|
|
251
|
+
const discovered = discoveredProfile(response, plan.model);
|
|
252
|
+
if (discovered.outcome !== null) {
|
|
253
|
+
return fail(discovered.outcome, discovered.reason, unsupportedKind(discovered.outcome));
|
|
254
|
+
}
|
|
255
|
+
if (discovered.profile !== plan.deployment_profile) {
|
|
256
|
+
return fail("unsupported", "deployment_profile_mismatch", unsupportedKind("unsupported"));
|
|
257
|
+
}
|
|
258
|
+
verification.deployment_discovery = "passed";
|
|
259
|
+
|
|
260
|
+
step = "metadata_discovery";
|
|
261
|
+
const oauth = await discover(plan.origin, signal);
|
|
262
|
+
if (signal.aborted) return interrupted();
|
|
263
|
+
if (!oauth.ok) return fail(oauth.outcome, oauth.reason, unsupportedKind(oauth.outcome));
|
|
264
|
+
verification.metadata_discovery = "passed";
|
|
265
|
+
phase = "preflight";
|
|
266
|
+
|
|
267
|
+
step = "credential_file";
|
|
268
|
+
const credential = readCredentialFile(credentialFile);
|
|
269
|
+
if (!credential.ok) {
|
|
270
|
+
return fail(
|
|
271
|
+
credential.outcome,
|
|
272
|
+
credential.reason,
|
|
273
|
+
credential.outcome === "unsupported" ? "platform_credential_handoff" : "fix_credential_file",
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
// From here on every result passes through the known-credential guard.
|
|
277
|
+
token = credential.token;
|
|
278
|
+
verification.credential_file = "passed";
|
|
279
|
+
if (signal.aborted) return interrupted();
|
|
280
|
+
|
|
281
|
+
step = "agent_metadata_access";
|
|
282
|
+
const ctx = { profile: plan.deployment_profile, workspaceId: plan.workspace_id };
|
|
283
|
+
const verified = await verifyContext(plan.origin, token, ctx, signal);
|
|
284
|
+
if (signal.aborted) return interrupted();
|
|
285
|
+
if (!verified.ok) return fail(verified.outcome, verified.reason, "use_metadata_only_credential");
|
|
286
|
+
const summary = capabilitySummary(verified.documents.capabilities, ctx, new Set());
|
|
287
|
+
const refusal = summary.ok ? capabilityRefusal(summary.value, verified.documents.capabilities.agent) : summary;
|
|
288
|
+
if (refusal !== null) return fail(refusal.outcome, refusal.reason, "use_metadata_only_credential");
|
|
289
|
+
|
|
290
|
+
// Nothing is reported as verified after a cancellation or the deadline.
|
|
291
|
+
if (signal.aborted) return interrupted();
|
|
292
|
+
verification.agent_metadata_access = "passed";
|
|
293
|
+
// checkWorkspace already required exactly these values.
|
|
294
|
+
verification.contract = AGENT_CONTRACT_VERSION;
|
|
295
|
+
verification.access_scope = METADATA_SCOPE;
|
|
296
|
+
verification.content_included = false;
|
|
297
|
+
verification.capabilities = { workspace_context: true, capability_discovery: true };
|
|
298
|
+
phase = "verified";
|
|
299
|
+
const verifiedResult = result(true, "ok", "metadata_access_verified", {
|
|
300
|
+
plan,
|
|
301
|
+
verification,
|
|
302
|
+
receipt: receiptFor(plan, phase),
|
|
303
|
+
});
|
|
304
|
+
return withoutCredential(verifiedResult, token);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// A result that may be printed must not hold the credential anywhere, even
|
|
308
|
+
// where it only coincides with the origin, workspace id or a fixed string.
|
|
309
|
+
// Such a result is replaced by a minimal refusal with no plan, verification
|
|
310
|
+
// or receipt. Either way the credential is attached as non-enumerable
|
|
311
|
+
// knownCredentials for the caller's own output suppression.
|
|
312
|
+
function withoutCredential(value, token) {
|
|
313
|
+
let safe = value;
|
|
314
|
+
if (holdsKnown(safe, token)) {
|
|
315
|
+
safe = result(false, ...ECHO_REFUSAL);
|
|
316
|
+
if (holdsKnown(safe, token)) safe = result(false, ...ECHO_FALLBACK);
|
|
317
|
+
}
|
|
318
|
+
Object.defineProperty(safe, "knownCredentials", { value: Object.freeze([token]), enumerable: false });
|
|
319
|
+
return safe;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// True when any string in value, keys included, contains the credential.
|
|
323
|
+
function holdsKnown(value, token) {
|
|
324
|
+
if (typeof value === "string") return value.includes(token);
|
|
325
|
+
if (value === null || typeof value !== "object") return false;
|
|
326
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
327
|
+
if (holdsKnown(key, token) || holdsKnown(entry, token)) return true;
|
|
328
|
+
}
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// The fallback refusal can never hold a credential.
|
|
333
|
+
for (const text of [...ECHO_FALLBACK, ...Object.keys(result(false, ...ECHO_FALLBACK))]) {
|
|
334
|
+
if (text.length >= CREDENTIAL_MIN_LENGTH) throw new Error("fallback refusal text could contain a credential");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Checks every input against fixed enumerations. Rejected values are never
|
|
338
|
+
// echoed; the reason names only which input was wrong.
|
|
339
|
+
function routeContext(input) {
|
|
340
|
+
if (!isObject(input)) return { ok: false, reason: "input_invalid" };
|
|
341
|
+
const { model, runtime, prerequisites = {}, receipt = null } = input;
|
|
342
|
+
if (typeof model !== "string" || !Object.hasOwn(PROFILES, model)) return { ok: false, reason: "model_invalid" };
|
|
343
|
+
if (!RUNTIMES.includes(runtime) && !HANDOFF_RUNTIMES.includes(runtime)) {
|
|
344
|
+
return { ok: false, reason: "runtime_invalid" };
|
|
345
|
+
}
|
|
346
|
+
const origin = parseOrigin(input.origin);
|
|
347
|
+
if (origin === null) return { ok: false, reason: "origin_invalid" };
|
|
348
|
+
const workspaceId = normalizeUuid(input.workspaceId);
|
|
349
|
+
if (workspaceId === null) return { ok: false, reason: "workspace_id_invalid" };
|
|
350
|
+
|
|
351
|
+
if (!isObject(prerequisites)) return { ok: false, reason: "prerequisites_invalid" };
|
|
352
|
+
const names = PREREQUISITES[model];
|
|
353
|
+
const statuses = {};
|
|
354
|
+
for (const name of names) statuses[name] = "unknown";
|
|
355
|
+
for (const [name, status] of Object.entries(prerequisites)) {
|
|
356
|
+
if (!names.includes(name) || !STATUSES.includes(status)) return { ok: false, reason: "prerequisites_invalid" };
|
|
357
|
+
statuses[name] = status;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const context = { ok: true, model, runtime, origin, workspaceId, profile: PROFILES[model], statuses };
|
|
361
|
+
if (receipt !== null) {
|
|
362
|
+
const reason = receiptProblem(receipt, context);
|
|
363
|
+
if (reason !== null) return { ok: false, reason };
|
|
364
|
+
}
|
|
365
|
+
return context;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// A receipt must have exactly the documented keys, so one carrying a
|
|
369
|
+
// credential, path or anything else is refused, and must describe this exact
|
|
370
|
+
// route. Its phase is not used for anything.
|
|
371
|
+
function receiptProblem(receipt, context) {
|
|
372
|
+
if (!isObject(receipt)) return "receipt_invalid";
|
|
373
|
+
const keys = Object.keys(receipt);
|
|
374
|
+
if (keys.length !== RECEIPT_KEYS.length || !RECEIPT_KEYS.every((key) => Object.hasOwn(receipt, key))) {
|
|
375
|
+
return "receipt_invalid";
|
|
376
|
+
}
|
|
377
|
+
if (receipt.version !== RECEIPT_VERSION || !PHASES.includes(receipt.phase)) return "receipt_invalid";
|
|
378
|
+
for (const key of ["model", "runtime", "origin", "workspace_id", "deployment_profile"]) {
|
|
379
|
+
if (typeof receipt[key] !== "string") return "receipt_invalid";
|
|
380
|
+
}
|
|
381
|
+
if (
|
|
382
|
+
receipt.model !== context.model ||
|
|
383
|
+
receipt.runtime !== context.runtime ||
|
|
384
|
+
parseOrigin(receipt.origin) !== context.origin ||
|
|
385
|
+
normalizeUuid(receipt.workspace_id) !== context.workspaceId ||
|
|
386
|
+
receipt.deployment_profile !== context.profile
|
|
387
|
+
) {
|
|
388
|
+
return "receipt_context_mismatch";
|
|
389
|
+
}
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function receiptFor(plan, phase) {
|
|
394
|
+
return {
|
|
395
|
+
version: RECEIPT_VERSION,
|
|
396
|
+
model: plan.model,
|
|
397
|
+
runtime: plan.runtime,
|
|
398
|
+
origin: plan.origin,
|
|
399
|
+
workspace_id: plan.workspace_id,
|
|
400
|
+
deployment_profile: plan.deployment_profile,
|
|
401
|
+
phase,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// The doctor decision for GET /v1/deployment, which knows local and byoc-core,
|
|
406
|
+
// plus the oss profile, which only this module accepts. Returns
|
|
407
|
+
// { outcome: null, profile } or { outcome, reason }.
|
|
408
|
+
function discoveredProfile(response, model) {
|
|
409
|
+
const checked = checkDeployment(response);
|
|
410
|
+
if (checked.outcome === null) return { outcome: null, profile: checked.profile };
|
|
411
|
+
if (checked.reason === "deployment_endpoint_missing" && model === "oss") {
|
|
412
|
+
// This server has no deployment endpoint, and static agent tokens can
|
|
413
|
+
// carry broader scopes than Metadata. There is no
|
|
414
|
+
// fallback discovery, so the operator is handed off and no credential is
|
|
415
|
+
// read or sent.
|
|
416
|
+
return { outcome: "unsupported", reason: "oss_deployment_discovery_unavailable" };
|
|
417
|
+
}
|
|
418
|
+
if (checked.reason === "unrecognized_profile" && parseJsonObject(response)?.deployment_profile === "oss") {
|
|
419
|
+
return { outcome: null, profile: "oss" };
|
|
420
|
+
}
|
|
421
|
+
return { outcome: checked.outcome, reason: checked.reason };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// capabilitySummary refuses known capabilities that are not metadata-only and
|
|
425
|
+
// available. This also requires the two capabilities verification relies on,
|
|
426
|
+
// and refuses any available entry, under any name, that is not plainly
|
|
427
|
+
// metadata-only: no content, mutation, external (provider) calls, content or
|
|
428
|
+
// replay privacy class, or scope other than Metadata.
|
|
429
|
+
function capabilityRefusal(summary, agent) {
|
|
430
|
+
for (const name of ["workspace_context", "capability_discovery"]) {
|
|
431
|
+
if (summary.agent[name]?.available !== true) {
|
|
432
|
+
return { outcome: "verification_failed", reason: "required_capability_unavailable" };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
for (const entry of Object.values(agent)) {
|
|
436
|
+
if (!isObject(entry) || typeof entry.available !== "boolean") {
|
|
437
|
+
return { outcome: "verification_failed", reason: "capabilities_response_invalid" };
|
|
438
|
+
}
|
|
439
|
+
if (!entry.available) continue;
|
|
440
|
+
const metadataOnly =
|
|
441
|
+
entry.content === false &&
|
|
442
|
+
entry.mutates === false &&
|
|
443
|
+
entry.external_calls === false &&
|
|
444
|
+
entry.privacy_class === "metadata" &&
|
|
445
|
+
entry.required_scope === METADATA_SCOPE;
|
|
446
|
+
if (!metadataOnly) return { outcome: "verification_failed", reason: "sensitive_capability_available" };
|
|
447
|
+
}
|
|
448
|
+
return null;
|
|
449
|
+
}
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { CONNECTION_GUIDE_URL, SUPPORTED_PROFILES } from "./constants.js";
|
|
2
|
+
import { get, parseJsonObject } from "./http.js";
|
|
3
|
+
|
|
4
|
+
const CHECKS = Object.freeze([
|
|
5
|
+
{ name: "health", path: "/healthz", readBody: true },
|
|
6
|
+
{ name: "deployment", path: "/v1/deployment", readBody: true },
|
|
7
|
+
{ name: "capabilities", path: "/v1/agent/capabilities", readBody: false },
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
// Runs the read-only connection probe against an already validated origin.
|
|
11
|
+
// Every request shares one deadline of timeoutMs. The result contains only
|
|
12
|
+
// fixed tokens, numeric HTTP statuses, the validated origin and a profile from
|
|
13
|
+
// SUPPORTED_PROFILES. Nothing from a response body or header is copied.
|
|
14
|
+
export async function runDoctor({ origin, timeoutMs }) {
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
17
|
+
try {
|
|
18
|
+
return await probe(origin, controller.signal);
|
|
19
|
+
} finally {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function probe(origin, signal) {
|
|
25
|
+
const report = {
|
|
26
|
+
origin,
|
|
27
|
+
reachable: null,
|
|
28
|
+
healthy: null,
|
|
29
|
+
deployment_profile: null,
|
|
30
|
+
profile_status: "unknown",
|
|
31
|
+
authentication_required: null,
|
|
32
|
+
authenticated: false,
|
|
33
|
+
workspace: null,
|
|
34
|
+
checks: CHECKS.map(({ name, path }) => ({
|
|
35
|
+
name,
|
|
36
|
+
path,
|
|
37
|
+
result: "skipped",
|
|
38
|
+
http_status: null,
|
|
39
|
+
reason: null,
|
|
40
|
+
})),
|
|
41
|
+
next_action: null,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
for (const [index, check] of CHECKS.entries()) {
|
|
45
|
+
const response = await get(new URL(check.path, origin), {
|
|
46
|
+
signal,
|
|
47
|
+
readBody: check.readBody,
|
|
48
|
+
});
|
|
49
|
+
const entry = report.checks[index];
|
|
50
|
+
const verdict = evaluate(check.name, response, report);
|
|
51
|
+
entry.result = verdict.outcome === null || verdict.pass ? "pass" : "fail";
|
|
52
|
+
entry.http_status = response.status ?? null;
|
|
53
|
+
entry.reason = verdict.reason;
|
|
54
|
+
if (verdict.outcome !== null) {
|
|
55
|
+
return { outcome: verdict.outcome, reason: verdict.reason, report };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Unreachable in this preview: the capabilities check always ends the probe.
|
|
60
|
+
return { outcome: "internal_error", reason: "probe_incomplete", report };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The same decision doctor makes for one GET /v1/deployment response, for
|
|
64
|
+
// status. Returns { outcome, reason, profile } where outcome is null and
|
|
65
|
+
// profile is one of SUPPORTED_PROFILES when the service reported a supported
|
|
66
|
+
// profile.
|
|
67
|
+
export function checkDeployment(response) {
|
|
68
|
+
const report = { reachable: null, healthy: null, deployment_profile: null, profile_status: "unknown" };
|
|
69
|
+
const { outcome, reason } = evaluate("deployment", response, report);
|
|
70
|
+
return { outcome, reason, profile: report.deployment_profile };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Returns { outcome, reason } where outcome is null when the probe should
|
|
74
|
+
// continue to the next check.
|
|
75
|
+
function evaluate(name, response, report) {
|
|
76
|
+
if (response.kind === "error") {
|
|
77
|
+
// Headers that arrived before a body failure still prove the server
|
|
78
|
+
// answered over HTTP. They prove nothing about health or authentication.
|
|
79
|
+
if (name === "health") report.reachable = response.status !== null;
|
|
80
|
+
return { outcome: "connection_failed", reason: response.reason };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
report.reachable = true;
|
|
84
|
+
const { status } = response;
|
|
85
|
+
|
|
86
|
+
if (status >= 300 && status < 400) {
|
|
87
|
+
return { outcome: "redirect_rejected", reason: "redirect" };
|
|
88
|
+
}
|
|
89
|
+
if (status === 503) {
|
|
90
|
+
report.healthy = false;
|
|
91
|
+
return { outcome: "unhealthy", reason: "service_unavailable" };
|
|
92
|
+
}
|
|
93
|
+
if (status >= 500) {
|
|
94
|
+
report.healthy = false;
|
|
95
|
+
return { outcome: "unhealthy", reason: "server_error" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (name === "health") return evaluateHealth(response, report);
|
|
99
|
+
if (name === "deployment") return evaluateDeployment(response, report);
|
|
100
|
+
return evaluateCapabilities(response, report);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function evaluateHealth(response, report) {
|
|
104
|
+
if (response.status !== 200) {
|
|
105
|
+
return { outcome: "unsupported", reason: "unexpected_status" };
|
|
106
|
+
}
|
|
107
|
+
if (response.tooLarge) {
|
|
108
|
+
return { outcome: "unsupported", reason: "response_too_large" };
|
|
109
|
+
}
|
|
110
|
+
const body = parseJsonObject(response);
|
|
111
|
+
if (body === null || typeof body.ok !== "boolean") {
|
|
112
|
+
return { outcome: "unsupported", reason: "invalid_response" };
|
|
113
|
+
}
|
|
114
|
+
if (body.ok !== true) {
|
|
115
|
+
report.healthy = false;
|
|
116
|
+
return { outcome: "unhealthy", reason: "reported_unhealthy" };
|
|
117
|
+
}
|
|
118
|
+
report.healthy = true;
|
|
119
|
+
return { outcome: null, reason: null };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function evaluateDeployment(response, report) {
|
|
123
|
+
if (response.status === 404) {
|
|
124
|
+
// Servers without this endpoint, such as a self-hosted open source
|
|
125
|
+
// server, have no profile adapter yet. Never assume they are hosted.
|
|
126
|
+
report.profile_status = "unavailable";
|
|
127
|
+
return { outcome: "unsupported", reason: "deployment_endpoint_missing" };
|
|
128
|
+
}
|
|
129
|
+
if (response.status !== 200) {
|
|
130
|
+
return { outcome: "unsupported", reason: "unexpected_status" };
|
|
131
|
+
}
|
|
132
|
+
if (response.tooLarge) {
|
|
133
|
+
return { outcome: "unsupported", reason: "response_too_large" };
|
|
134
|
+
}
|
|
135
|
+
const body = parseJsonObject(response);
|
|
136
|
+
if (body === null || typeof body.deployment_profile !== "string") {
|
|
137
|
+
return { outcome: "unsupported", reason: "invalid_response" };
|
|
138
|
+
}
|
|
139
|
+
if (!SUPPORTED_PROFILES.includes(body.deployment_profile)) {
|
|
140
|
+
report.profile_status = "unrecognized";
|
|
141
|
+
return { outcome: "unsupported", reason: "unrecognized_profile" };
|
|
142
|
+
}
|
|
143
|
+
report.deployment_profile = body.deployment_profile;
|
|
144
|
+
report.profile_status = "supported";
|
|
145
|
+
return { outcome: null, reason: null };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function evaluateCapabilities(response, report) {
|
|
149
|
+
if (response.status === 401) {
|
|
150
|
+
if (!hasBearerChallenge(response.headers["www-authenticate"])) {
|
|
151
|
+
return { outcome: "unsupported", reason: "unexpected_auth_challenge" };
|
|
152
|
+
}
|
|
153
|
+
report.authentication_required = true;
|
|
154
|
+
report.next_action = { kind: "connection_guide", url: CONNECTION_GUIDE_URL };
|
|
155
|
+
// The check passed: the server behaved as expected. The outcome is still
|
|
156
|
+
// not a connection, because this CLI holds no credentials.
|
|
157
|
+
return { outcome: "authentication_required", reason: "bearer_token_required", pass: true };
|
|
158
|
+
}
|
|
159
|
+
if (response.status === 200) {
|
|
160
|
+
report.authentication_required = false;
|
|
161
|
+
return { outcome: "unsupported", reason: "unexpected_unauthenticated_access" };
|
|
162
|
+
}
|
|
163
|
+
return { outcome: "unsupported", reason: "unexpected_status" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// RFC 9110 challenge grammar, applied to one comma-separated list element:
|
|
167
|
+
// auth-scheme [ 1*SP ( token68 / auth-param ) ] or a further auth-param.
|
|
168
|
+
// Only the first form names a scheme, so "Bearer=x" or "Bearer = x" is a
|
|
169
|
+
// parameter, never a challenge.
|
|
170
|
+
const TOKEN = "[!#$%&'*+.^_`|~0-9A-Za-z-]+";
|
|
171
|
+
const QUOTED = '"(?:[^"\\\\]|\\\\.)*"';
|
|
172
|
+
const PARAM = `${TOKEN}[ \\t]*=[ \\t]*(?:${TOKEN}|${QUOTED})`;
|
|
173
|
+
const TOKEN68 = "[A-Za-z0-9._~+/-]+=*";
|
|
174
|
+
const ELEMENT = new RegExp(`^(?:(${TOKEN})(?:[ \\t]+(?:${TOKEN68}|${PARAM}))?|${PARAM})$`);
|
|
175
|
+
|
|
176
|
+
// True when the header holds a Bearer challenge. Commas inside quoted strings
|
|
177
|
+
// (with backslash escapes) do not split elements. Any malformed element,
|
|
178
|
+
// including unbalanced quoting, makes the whole header unacceptable.
|
|
179
|
+
function hasBearerChallenge(value) {
|
|
180
|
+
if (typeof value !== "string") return false;
|
|
181
|
+
const elements = [];
|
|
182
|
+
let start = 0;
|
|
183
|
+
let quoted = false;
|
|
184
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
185
|
+
const char = value[i];
|
|
186
|
+
if (quoted) {
|
|
187
|
+
if (char === "\\") i += 1;
|
|
188
|
+
else if (char === '"') quoted = false;
|
|
189
|
+
} else if (char === '"') {
|
|
190
|
+
quoted = true;
|
|
191
|
+
} else if (char === ",") {
|
|
192
|
+
elements.push(value.slice(start, i));
|
|
193
|
+
start = i + 1;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (quoted) return false;
|
|
197
|
+
elements.push(value.slice(start));
|
|
198
|
+
|
|
199
|
+
let bearer = false;
|
|
200
|
+
for (const element of elements) {
|
|
201
|
+
const trimmed = element.trim();
|
|
202
|
+
if (trimmed === "") continue;
|
|
203
|
+
const match = ELEMENT.exec(trimmed);
|
|
204
|
+
if (match === null) return false;
|
|
205
|
+
if (match[1] !== undefined && match[1].toLowerCase() === "bearer") bearer = true;
|
|
206
|
+
}
|
|
207
|
+
return bearer;
|
|
208
|
+
}
|