metergraph-cli 0.1.0 → 0.2.0-preview.1

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,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
+ }
@@ -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 CHANGED
@@ -60,6 +60,16 @@ async function probe(origin, signal) {
60
60
  return { outcome: "internal_error", reason: "probe_incomplete", report };
61
61
  }
62
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
+
63
73
  // Returns { outcome, reason } where outcome is null when the probe should
64
74
  // continue to the next check.
65
75
  function evaluate(name, response, report) {
package/src/http.js CHANGED
@@ -92,7 +92,7 @@ export function get(url, { signal, readBody }) {
92
92
  });
93
93
  }
94
94
 
95
- function classifyError(error, signal) {
95
+ export function classifyError(error, signal) {
96
96
  if (signal?.aborted) return "timeout";
97
97
  const code = typeof error?.code === "string" ? error.code : "";
98
98
  if (DNS_ERRORS.has(code)) return "dns_lookup_failed";