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.
- package/README.md +500 -33
- package/assets/skill/SKILL.md +17 -1
- package/assets/skill/manifest.json +3 -3
- package/package.json +2 -2
- package/src/args.js +323 -17
- 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 +78 -2
- package/src/constants.js +99 -4
- package/src/deployment-credential.js +207 -0
- package/src/deployment-route.js +449 -0
- package/src/doctor.js +10 -0
- package/src/http.js +1 -1
- package/src/output.js +454 -2
- package/src/read-contract.js +609 -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 +326 -0
- package/src/skill-bundle.js +1 -1
- 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,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";
|