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,141 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { Stop } from "./auth-store.js";
|
|
6
|
+
|
|
7
|
+
// Public, non-credential project state. It is persisted before browser
|
|
8
|
+
// approval so a lost redemption response can be recovered by asking the
|
|
9
|
+
// owner to approve replacement of the pending family. No token, receipt,
|
|
10
|
+
// verifier or callback URL is ever stored here.
|
|
11
|
+
const FILE = "setup.json";
|
|
12
|
+
const LOCK = "setup.lock";
|
|
13
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
14
|
+
const MAX_BYTES = 4096;
|
|
15
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
16
|
+
const PROFILES = new Set(["managed", "local", "byoc-core"]);
|
|
17
|
+
const CLIENTS = new Set(["codex", "claude", "cursor"]);
|
|
18
|
+
const PHASES = new Set(["unsubmitted", "redeem_attempted", "delivered"]);
|
|
19
|
+
const SKILL = new Set(["pending", "installed", "skipped"]);
|
|
20
|
+
const LEGACY_KEYS = "family_id,key_id,origin,phase,schema_version,workspace_id";
|
|
21
|
+
const KEYS = "completed_steps,deployment_profile,family_id,key_id,origin,pending_steps,phase,schema_version,selected_client,skill_status,workspace_id";
|
|
22
|
+
|
|
23
|
+
function steps(value) {
|
|
24
|
+
const completed = ["login"];
|
|
25
|
+
if (value.phase === "delivered") completed.push("credential");
|
|
26
|
+
if (value.skill_status === "installed") completed.push("skill");
|
|
27
|
+
const pending = [];
|
|
28
|
+
if (value.phase !== "delivered") pending.push("credential");
|
|
29
|
+
if (value.skill_status !== "installed") pending.push("skill");
|
|
30
|
+
pending.push("instrument", "verify", "view");
|
|
31
|
+
return { completed, pending };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function withSetupState(value, patch = {}) {
|
|
35
|
+
const next = { ...value, ...patch };
|
|
36
|
+
const { completed, pending } = steps(next);
|
|
37
|
+
next.completed_steps = completed;
|
|
38
|
+
next.pending_steps = pending;
|
|
39
|
+
return next;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function setupReceipt(value) {
|
|
43
|
+
return {
|
|
44
|
+
origin: value.origin,
|
|
45
|
+
workspace_id: value.workspace_id,
|
|
46
|
+
deployment_profile: value.deployment_profile,
|
|
47
|
+
client: value.selected_client,
|
|
48
|
+
completed_steps: [...value.completed_steps],
|
|
49
|
+
pending_steps: [...value.pending_steps],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function core(value) {
|
|
54
|
+
return value.schema_version === 1 && UUID.test(value.family_id) &&
|
|
55
|
+
(value.key_id === null || UUID.test(value.key_id)) && PHASES.has(value.phase) &&
|
|
56
|
+
typeof value.origin === "string" && typeof value.workspace_id === "string" && UUID.test(value.workspace_id);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function valid(value) {
|
|
60
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) &&
|
|
61
|
+
Object.keys(value).sort().join() === KEYS && core(value) &&
|
|
62
|
+
PROFILES.has(value.deployment_profile) &&
|
|
63
|
+
(value.selected_client === null || CLIENTS.has(value.selected_client)) && SKILL.has(value.skill_status) &&
|
|
64
|
+
(value.skill_status !== "installed" || value.selected_client !== null) &&
|
|
65
|
+
JSON.stringify(value.completed_steps) === JSON.stringify(steps(value).completed) &&
|
|
66
|
+
JSON.stringify(value.pending_steps) === JSON.stringify(steps(value).pending);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function legacy(value) {
|
|
70
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) &&
|
|
71
|
+
Object.keys(value).sort().join() === LEGACY_KEYS && core(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fileFor(root) { return path.join(root, ".metergraph", FILE); }
|
|
75
|
+
|
|
76
|
+
export function readSetupState(root) {
|
|
77
|
+
const file = fileFor(root);
|
|
78
|
+
let stat;
|
|
79
|
+
try { stat = fs.lstatSync(file); }
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error.code === "ENOENT") return null;
|
|
82
|
+
throw new Stop("filesystem_error", "setup_state_read_failed");
|
|
83
|
+
}
|
|
84
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_BYTES) throw new Stop("conflict", "setup_state_unsafe");
|
|
85
|
+
let fd;
|
|
86
|
+
try {
|
|
87
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | NOFOLLOW);
|
|
88
|
+
const opened = fs.fstatSync(fd);
|
|
89
|
+
if (!opened.isFile() || opened.nlink !== 1 || opened.size > MAX_BYTES ||
|
|
90
|
+
opened.dev !== stat.dev || opened.ino !== stat.ino) throw new Stop("conflict", "setup_state_unsafe");
|
|
91
|
+
const content = fs.readFileSync(fd);
|
|
92
|
+
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(content));
|
|
93
|
+
if (!valid(value) && !legacy(value)) throw new Stop("conflict", "setup_state_invalid");
|
|
94
|
+
return { value: legacy(value) ? withSetupState({ ...value, deployment_profile: null,
|
|
95
|
+
selected_client: null, skill_status: "pending" }) : value, content };
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error instanceof Stop) throw error;
|
|
98
|
+
throw new Stop("conflict", "setup_state_invalid");
|
|
99
|
+
} finally {
|
|
100
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function newSetupState(origin, workspaceId, profile, client) {
|
|
105
|
+
return withSetupState({ schema_version: 1, origin, workspace_id: workspaceId,
|
|
106
|
+
deployment_profile: profile, selected_client: client, skill_status: client === null ? "skipped" : "pending",
|
|
107
|
+
family_id: randomUUID(), key_id: null, phase: "unsubmitted" });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Compare-and-swap under a project-local exclusive lock. A concurrent setup
|
|
111
|
+
// or a user edit wins; this command never overwrites it.
|
|
112
|
+
export function writeSetupState(root, next, previous) {
|
|
113
|
+
if (!valid(next)) throw new Error("invalid setup state");
|
|
114
|
+
const dir = path.join(root, ".metergraph");
|
|
115
|
+
const lock = path.join(dir, LOCK);
|
|
116
|
+
const dest = fileFor(root);
|
|
117
|
+
const temp = path.join(dir, `.setup.${randomBytes(8).toString("hex")}.tmp`);
|
|
118
|
+
let locked = false;
|
|
119
|
+
try {
|
|
120
|
+
const dirStat = fs.lstatSync(dir);
|
|
121
|
+
if (!dirStat.isDirectory()) throw new Stop("conflict", "setup_state_unsafe");
|
|
122
|
+
fs.closeSync(fs.openSync(lock, "wx", 0o600));
|
|
123
|
+
locked = true;
|
|
124
|
+
const now = readSetupState(root);
|
|
125
|
+
if (!(previous === null ? now === null : now !== null && now.content.equals(previous.content))) {
|
|
126
|
+
throw new Stop("conflict", "setup_state_changed");
|
|
127
|
+
}
|
|
128
|
+
const content = Buffer.from(`${JSON.stringify(next, null, 2)}\n`);
|
|
129
|
+
const fd = fs.openSync(temp, "wx", 0o600);
|
|
130
|
+
try { fs.writeFileSync(fd, content); fs.fsyncSync(fd); }
|
|
131
|
+
finally { fs.closeSync(fd); }
|
|
132
|
+
fs.renameSync(temp, dest);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error instanceof Stop) throw error;
|
|
135
|
+
if (error.code === "EEXIST") throw new Stop("conflict", "setup_state_locked");
|
|
136
|
+
throw new Stop("filesystem_error", "setup_state_write_failed");
|
|
137
|
+
} finally {
|
|
138
|
+
try { fs.unlinkSync(temp); } catch { /* no temp */ }
|
|
139
|
+
if (locked) { try { fs.unlinkSync(lock); } catch { /* next run refuses */ } }
|
|
140
|
+
}
|
|
141
|
+
}
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { detectRemoteSession, openBrowser } from "./auth-browser.js";
|
|
2
|
+
import { readBinding, resolveProject } from "./auth-binding.js";
|
|
3
|
+
import { startCallbackListener } from "./auth-callback.js";
|
|
4
|
+
import { runLogin } from "./auth-login.js";
|
|
5
|
+
import { newPkce, register, endpointsFor } from "./auth-oauth.js";
|
|
6
|
+
import { Stop } from "./auth-store.js";
|
|
7
|
+
import { verifiedSession } from "./auth-session.js";
|
|
8
|
+
import { AUTH_HTTP_TIMEOUT_MS } from "./constants.js";
|
|
9
|
+
import { preflightEnv, currentEnvValues, commitEnv, isAppToken } from "./setup-env.js";
|
|
10
|
+
import { newSetupState, readSetupState, setupReceipt, withSetupState, writeSetupState } from "./setup-state.js";
|
|
11
|
+
import { runSkill } from "./skill.js";
|
|
12
|
+
import { parseJsonObject } from "./http.js";
|
|
13
|
+
import { deadline, failureOf, send, trapSignals } from "./transport.js";
|
|
14
|
+
|
|
15
|
+
export const SETUP_PATHS = Object.freeze({
|
|
16
|
+
metadata: "/v1/cli/setup/metadata",
|
|
17
|
+
authorize: "/v1/cli/setup/authorize",
|
|
18
|
+
redeem: "/v1/cli/setup/redeem",
|
|
19
|
+
credential: "/v1/cli/setup/credential",
|
|
20
|
+
});
|
|
21
|
+
const SCHEMA = "metergraph.cli-setup/v1";
|
|
22
|
+
const PURPOSE = "ingest-bootstrap-v1";
|
|
23
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
24
|
+
const RECEIPT = /^mgbs_[A-Za-z0-9_-]{32,256}$/;
|
|
25
|
+
const KEY = (value) => typeof value === "string" && UUID.test(value);
|
|
26
|
+
const fail = (outcome, reason, status = "not_ready", receipt = null) => ({ outcome, reason,
|
|
27
|
+
data: { status, application_traffic_verified: false, receipt } });
|
|
28
|
+
|
|
29
|
+
// This command never receives an ingest key from argv, stdin, an environment
|
|
30
|
+
// variable or a Metadata OAuth grant. Only the purpose-bound redemption
|
|
31
|
+
// response can supply it. Every returned string is fixed and non-secret.
|
|
32
|
+
export async function runSetup(options, progress = () => {}) {
|
|
33
|
+
if (options.runtime !== "local" || detectRemoteSession() !== null) return fail("unsupported", "run_on_local_machine");
|
|
34
|
+
if (options.json && options.noBrowser) return fail("unsupported", "no_browser_requires_terminal");
|
|
35
|
+
const trap = trapSignals();
|
|
36
|
+
let receipt = null;
|
|
37
|
+
let loginVerified = false;
|
|
38
|
+
let credentialVerified = false;
|
|
39
|
+
const stop = (outcome, reason, status = "not_ready") => {
|
|
40
|
+
let current = receipt;
|
|
41
|
+
if (current !== null && (!loginVerified || !credentialVerified)) {
|
|
42
|
+
const completed = loginVerified ? ["login"] : [];
|
|
43
|
+
current = { ...current, completed_steps: completed,
|
|
44
|
+
pending_steps: [
|
|
45
|
+
...(!loginVerified ? ["login"] : []), "credential", "skill", "instrument", "verify", "view",
|
|
46
|
+
] };
|
|
47
|
+
}
|
|
48
|
+
return fail(outcome, reason, status, current);
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
const root = resolveProject(options.project);
|
|
52
|
+
const saved = readSetupState(root);
|
|
53
|
+
if (saved) receipt = setupReceipt(saved.value);
|
|
54
|
+
const bound = readBinding(root)?.binding ?? null;
|
|
55
|
+
const intendedOrigin = options.originExplicit ? options.origin : bound?.origin ?? saved?.value.origin ?? options.origin;
|
|
56
|
+
const intendedWorkspace = options.workspace ?? saved?.value.workspace_id ?? null;
|
|
57
|
+
// A family already tied to a workspace cannot be carried across a
|
|
58
|
+
// reconnect. Refuse before login changes the Metadata binding or the env.
|
|
59
|
+
if (saved && (saved.value.origin !== intendedOrigin || saved.value.workspace_id !== intendedWorkspace ||
|
|
60
|
+
(bound !== null && (bound.origin !== saved.value.origin || bound.workspace_id !== saved.value.workspace_id)))) {
|
|
61
|
+
return stop("conflict", "setup_binding_changed");
|
|
62
|
+
}
|
|
63
|
+
const bindingDiffers = bound !== null && (bound.origin !== intendedOrigin ||
|
|
64
|
+
(intendedWorkspace !== null && bound.workspace_id !== intendedWorkspace));
|
|
65
|
+
if (bindingDiffers && !options.reconnect) return stop("conflict", "bound_to_other_workspace");
|
|
66
|
+
let signedIn = bindingDiffers || options.reconnect ? { ok: false, outcome: "login_required" } :
|
|
67
|
+
await verifiedSession({ project: root, configDir: options.configDir, cancel: trap.signal });
|
|
68
|
+
if (!signedIn.ok && signedIn.outcome === "login_required") {
|
|
69
|
+
const login = await runLogin({ ...options, origin: intendedOrigin, workspace: intendedWorkspace,
|
|
70
|
+
project: root, reconnect: options.reconnect, expectedProfile: options.expectedProfile ?? null }, progress);
|
|
71
|
+
if (login.outcome !== "ok") return stop(login.outcome, login.reason, "login_pending");
|
|
72
|
+
signedIn = await verifiedSession({ project: root, configDir: options.configDir, cancel: trap.signal });
|
|
73
|
+
}
|
|
74
|
+
if (!signedIn.ok) return stop(signedIn.outcome, signedIn.reason, "login_pending");
|
|
75
|
+
const { origin, workspaceId, profile } = signedIn.session;
|
|
76
|
+
if (options.expectedProfile && profile !== options.expectedProfile) {
|
|
77
|
+
return stop("unsupported", "deployment_profile_mismatch", "login_pending");
|
|
78
|
+
}
|
|
79
|
+
if (origin !== intendedOrigin || (intendedWorkspace !== null && workspaceId !== intendedWorkspace)) {
|
|
80
|
+
return stop("verification_failed", "workspace_mismatch", "login_pending");
|
|
81
|
+
}
|
|
82
|
+
loginVerified = true;
|
|
83
|
+
const plan = preflightEnv({ project: root, envFile: options.envFile, signal: trap.signal });
|
|
84
|
+
const existing = currentEnvValues(plan);
|
|
85
|
+
let previous = readSetupState(root);
|
|
86
|
+
if (previous) receipt = setupReceipt(previous.value);
|
|
87
|
+
if (previous && (previous.value.origin !== origin || previous.value.workspace_id !== workspaceId)) {
|
|
88
|
+
return stop("conflict", "setup_binding_changed");
|
|
89
|
+
}
|
|
90
|
+
if (previous && previous.value.deployment_profile !== null && previous.value.deployment_profile !== profile) {
|
|
91
|
+
return stop("conflict", "setup_profile_changed");
|
|
92
|
+
}
|
|
93
|
+
// Upgrade a pre-composition state, or record the current client choice.
|
|
94
|
+
// A changed client leaves the existing key untouched and marks the new
|
|
95
|
+
// client's skill pending until its own installer confirms ownership.
|
|
96
|
+
if (previous && (previous.value.deployment_profile === null ||
|
|
97
|
+
previous.value.selected_client !== options.client ||
|
|
98
|
+
(options.client === null && previous.value.skill_status !== "skipped"))) {
|
|
99
|
+
const next = withSetupState(previous.value, { deployment_profile: profile,
|
|
100
|
+
selected_client: options.client, skill_status: options.client === null ? "skipped" : "pending" });
|
|
101
|
+
writeSetupState(root, next, previous);
|
|
102
|
+
previous = readSetupState(root);
|
|
103
|
+
receipt = setupReceipt(previous.value);
|
|
104
|
+
}
|
|
105
|
+
if (!previous && existing.token !== null) return stop("conflict", "existing_ingest_key_unowned");
|
|
106
|
+
if (existing.token !== null && !isAppToken(existing.token)) return stop("conflict", "existing_ingest_key_invalid");
|
|
107
|
+
|
|
108
|
+
// A saved token is checked against this family and workspace before
|
|
109
|
+
// being reused. Pending delivery is acknowledged, including after a
|
|
110
|
+
// previous process stopped after the env write.
|
|
111
|
+
if (previous && existing.token !== null) {
|
|
112
|
+
const checked = await checkCredential(origin, existing.token, previous.value, profile, trap.signal);
|
|
113
|
+
if (checked.ok) {
|
|
114
|
+
if (existing.ingestUrl !== `${origin}/v1/ingest`) return stop("conflict", "ingest_url_mismatch");
|
|
115
|
+
if (previous.value.key_id !== null && previous.value.key_id !== checked.keyId) {
|
|
116
|
+
return stop("conflict", "setup_key_changed");
|
|
117
|
+
}
|
|
118
|
+
const ack = await acknowledge(origin, existing.token, previous.value, profile, checked.keyId, trap.signal);
|
|
119
|
+
if (!ack.ok) return stop(ack.outcome, ack.reason, "delivery_pending");
|
|
120
|
+
credentialVerified = true;
|
|
121
|
+
if (previous.value.phase !== "delivered" || previous.value.key_id === null) {
|
|
122
|
+
writeSetupState(root, withSetupState(previous.value, { key_id: checked.keyId, phase: "delivered" }), previous);
|
|
123
|
+
}
|
|
124
|
+
return finishSkill(root, options, "unchanged");
|
|
125
|
+
}
|
|
126
|
+
if (!options.repair && previous.value.phase !== "redeem_attempted") return stop("verification_failed", "saved_key_unverified");
|
|
127
|
+
if (options.repair && previous.value.key_id === null) return stop("conflict", "repair_key_unknown");
|
|
128
|
+
} else if (options.repair && (previous === null || previous.value.key_id === null)) {
|
|
129
|
+
return stop("conflict", "repair_requires_saved_key");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const setup = await discoverSetup(origin, profile, trap.signal);
|
|
133
|
+
if (!setup.ok) return stop(setup.outcome, setup.reason);
|
|
134
|
+
|
|
135
|
+
if (previous === null) {
|
|
136
|
+
writeSetupState(root, newSetupState(origin, workspaceId, profile, options.client), null);
|
|
137
|
+
previous = readSetupState(root);
|
|
138
|
+
receipt = setupReceipt(previous.value);
|
|
139
|
+
}
|
|
140
|
+
const state = previous.value;
|
|
141
|
+
const intent = options.repair ? "repair" : state.phase === "redeem_attempted" || state.phase === "delivered"
|
|
142
|
+
? "replace_pending" : "create";
|
|
143
|
+
// An acknowledged family is never implicitly replaced. Repair requires
|
|
144
|
+
// the explicit flag and the key ID saved from its original issuance.
|
|
145
|
+
if (intent === "replace_pending" && state.phase === "delivered") return stop("conflict", "repair_required");
|
|
146
|
+
const listener = await startCallbackListener();
|
|
147
|
+
let callback;
|
|
148
|
+
let clientId;
|
|
149
|
+
let pkce;
|
|
150
|
+
try {
|
|
151
|
+
const registered = await register(endpointsFor(origin), listener.redirectUri, deadline(AUTH_HTTP_TIMEOUT_MS, trap.signal).signal);
|
|
152
|
+
if (!registered.ok) return stop(registered.outcome, registered.reason);
|
|
153
|
+
clientId = registered.clientId;
|
|
154
|
+
pkce = newPkce();
|
|
155
|
+
const pending = listener.wait({ state: pkce.state, issuer: `${origin}/v1/oauth`, requireIss: false,
|
|
156
|
+
timeoutMs: options.timeoutMs, cancel: trap.signal });
|
|
157
|
+
const url = new URL(`${origin}${SETUP_PATHS.authorize}`);
|
|
158
|
+
const params = {
|
|
159
|
+
client_id: clientId, redirect_uri: listener.redirectUri, state: pkce.state,
|
|
160
|
+
code_challenge: pkce.challenge, code_challenge_method: "S256",
|
|
161
|
+
workspace_id: workspaceId, family_id: state.family_id, intent,
|
|
162
|
+
};
|
|
163
|
+
if (intent === "repair") params.expected_key_id = state.key_id;
|
|
164
|
+
url.search = new URLSearchParams(params).toString();
|
|
165
|
+
if (trap.signal.aborted) return stop("authorization_failed", "cancelled");
|
|
166
|
+
if (options.noBrowser) progress(`Open this URL on this machine to approve ingest-only setup:\n${url.href}`);
|
|
167
|
+
else if (!(await openBrowser(url.href))) return stop("authorization_failed", "browser_unavailable");
|
|
168
|
+
else progress("Opened your browser for ingest-only setup approval.");
|
|
169
|
+
callback = await pending;
|
|
170
|
+
} finally {
|
|
171
|
+
await listener.close();
|
|
172
|
+
}
|
|
173
|
+
if (callback.kind === "denied") return stop("authorization_failed", "access_denied");
|
|
174
|
+
if (callback.kind === "timeout") return stop("authorization_failed", "timeout");
|
|
175
|
+
if (callback.kind === "cancelled") return stop("authorization_failed", "cancelled");
|
|
176
|
+
if (callback.kind !== "code" || !RECEIPT.test(callback.code)) return stop("authorization_failed", "callback_invalid");
|
|
177
|
+
|
|
178
|
+
// Persist the ambiguity boundary before a single-use receipt is sent.
|
|
179
|
+
// If the response is lost, a later run asks the browser to replace only
|
|
180
|
+
// this family's pending key; it never replays the receipt.
|
|
181
|
+
previous = readSetupState(root);
|
|
182
|
+
if (previous === null || JSON.stringify(previous.value) !== JSON.stringify(state)) {
|
|
183
|
+
return stop("conflict", "setup_state_changed");
|
|
184
|
+
}
|
|
185
|
+
writeSetupState(root, withSetupState(previous.value, { phase: "redeem_attempted" }), previous);
|
|
186
|
+
previous = readSetupState(root);
|
|
187
|
+
const redeemed = await redeem(origin, { code: callback.code, client_id: clientId,
|
|
188
|
+
redirect_uri: listener.redirectUri, code_verifier: pkce.verifier,
|
|
189
|
+
family_id: state.family_id, workspace_id: workspaceId }, profile, trap.signal);
|
|
190
|
+
if (!redeemed.ok) return stop(redeemed.outcome, redeemed.reason, "delivery_pending");
|
|
191
|
+
const written = await commitEnv(plan, { token: redeemed.token, ingestUrl: `${origin}/v1/ingest`, signal: trap.signal });
|
|
192
|
+
const checked = await checkCredential(origin, redeemed.token, state, profile, trap.signal);
|
|
193
|
+
if (!checked.ok || checked.keyId !== redeemed.keyId) return stop("verification_failed", "issued_key_unverified", "delivery_pending");
|
|
194
|
+
const ack = await acknowledge(origin, redeemed.token, state, profile, redeemed.keyId, trap.signal);
|
|
195
|
+
if (!ack.ok) return stop(ack.outcome, ack.reason, "delivery_pending");
|
|
196
|
+
credentialVerified = true;
|
|
197
|
+
writeSetupState(root, withSetupState(previous.value, { key_id: redeemed.keyId, phase: "delivered" }), previous);
|
|
198
|
+
return finishSkill(root, options, written.receipt.file);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (error instanceof Stop) return stop(error.outcome, error.reason);
|
|
201
|
+
throw error;
|
|
202
|
+
} finally { trap.release(); }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function finishSkill(root, options, envStatus) {
|
|
206
|
+
let previous = readSetupState(root);
|
|
207
|
+
if (previous === null || previous.value.phase !== "delivered") return fail("conflict", "setup_state_changed");
|
|
208
|
+
if (previous.value.selected_client !== options.client) {
|
|
209
|
+
return fail("conflict", "setup_state_changed", "credential_ready_skill_pending", setupReceipt(previous.value));
|
|
210
|
+
}
|
|
211
|
+
if (options.skipSkill) {
|
|
212
|
+
if (previous.value.skill_status !== "skipped") {
|
|
213
|
+
writeSetupState(root, withSetupState(previous.value, { selected_client: null, skill_status: "skipped" }), previous);
|
|
214
|
+
previous = readSetupState(root);
|
|
215
|
+
}
|
|
216
|
+
return { outcome: "ok", reason: null, data: { status: "ready_for_instrumentation",
|
|
217
|
+
application_traffic_verified: false, env: envStatus, skill: "skipped", receipt: setupReceipt(previous.value) } };
|
|
218
|
+
}
|
|
219
|
+
let skill = runSkill({ action: "install", client: options.client, runtime: "local", project: root });
|
|
220
|
+
if (skill.reason === "update_required") {
|
|
221
|
+
skill = runSkill({ action: "update", client: options.client, runtime: "local", project: root });
|
|
222
|
+
}
|
|
223
|
+
// Another process may have chosen a different client while installation
|
|
224
|
+
// ran. Re-read before changing the receipt, and let the CAS protect writes.
|
|
225
|
+
previous = readSetupState(root);
|
|
226
|
+
if (previous === null || previous.value.phase !== "delivered" ||
|
|
227
|
+
previous.value.selected_client !== options.client) {
|
|
228
|
+
return fail("conflict", "setup_state_changed", "credential_ready_skill_pending",
|
|
229
|
+
previous === null ? null : setupReceipt(previous.value));
|
|
230
|
+
}
|
|
231
|
+
if (skill.outcome !== "ok") {
|
|
232
|
+
if (previous.value.skill_status === "installed") {
|
|
233
|
+
writeSetupState(root, withSetupState(previous.value, { skill_status: "pending" }), previous);
|
|
234
|
+
previous = readSetupState(root);
|
|
235
|
+
}
|
|
236
|
+
return fail(skill.outcome, skill.reason, "credential_ready_skill_pending", setupReceipt(previous.value));
|
|
237
|
+
}
|
|
238
|
+
if (previous.value.skill_status !== "installed") {
|
|
239
|
+
writeSetupState(root, withSetupState(previous.value, { skill_status: "installed" }), previous);
|
|
240
|
+
previous = readSetupState(root);
|
|
241
|
+
}
|
|
242
|
+
return { outcome: "ok", reason: null, data: { status: "ready_for_instrumentation",
|
|
243
|
+
application_traffic_verified: false, env: envStatus, skill: skill.data.status,
|
|
244
|
+
receipt: setupReceipt(previous.value) } };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function discoverSetup(origin, profile, cancel) {
|
|
248
|
+
const response = await send(origin, SETUP_PATHS.metadata, { signal: deadline(AUTH_HTTP_TIMEOUT_MS, cancel).signal, maxBytes: 32768 });
|
|
249
|
+
const failed = failureOf(response);
|
|
250
|
+
if (failed) return { ok: false, ...failed };
|
|
251
|
+
if (response.status !== 200) return { ok: false, outcome: "unsupported", reason: "setup_discovery_unavailable" };
|
|
252
|
+
const doc = parseJsonObject(response);
|
|
253
|
+
if (doc?.schema_version !== SCHEMA || doc.supported !== true || doc.unsupported_reason !== null ||
|
|
254
|
+
doc.resource !== `${origin}/v1/cli/setup` || doc.purpose !== PURPOSE ||
|
|
255
|
+
doc.deployment_profile !== profile || doc.credential_scope !== "ingest" ||
|
|
256
|
+
doc.authorization_endpoint !== `${origin}${SETUP_PATHS.authorize}` ||
|
|
257
|
+
doc.redemption_endpoint !== `${origin}${SETUP_PATHS.redeem}` ||
|
|
258
|
+
doc.credential_endpoint !== `${origin}${SETUP_PATHS.credential}` ||
|
|
259
|
+
doc.registration_endpoint !== `${origin}/v1/oauth/register` ||
|
|
260
|
+
!Array.isArray(doc.profiles_supported) || !doc.profiles_supported.includes(profile) ||
|
|
261
|
+
!Array.isArray(doc.intents_supported) || !["create", "replace_pending", "repair"].every((x) => doc.intents_supported.includes(x)) ||
|
|
262
|
+
!Array.isArray(doc.code_challenge_methods_supported) || !doc.code_challenge_methods_supported.includes("S256")) {
|
|
263
|
+
return { ok: false, outcome: "unsupported", reason: "setup_contract_mismatch" };
|
|
264
|
+
}
|
|
265
|
+
return { ok: true };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function redeem(origin, form, profile, cancel) {
|
|
269
|
+
const response = await send(origin, SETUP_PATHS.redeem, { method: "POST", form,
|
|
270
|
+
signal: deadline(AUTH_HTTP_TIMEOUT_MS, cancel).signal, maxBytes: 32768 });
|
|
271
|
+
const failed = failureOf(response);
|
|
272
|
+
if (failed) return { ok: false, ...failed };
|
|
273
|
+
if (response.status !== 200) return { ok: false, outcome: "authorization_failed", reason: "setup_redeem_rejected" };
|
|
274
|
+
const body = parseJsonObject(response);
|
|
275
|
+
if (body?.schema_version !== SCHEMA || body.token_type !== "ingest" || body.scope !== "ingest" ||
|
|
276
|
+
body.delivery_state !== "pending" || body.workspace_id !== form.workspace_id ||
|
|
277
|
+
body.family_id !== form.family_id || !KEY(body.key_id) || !isAppToken(body.ingest_token) ||
|
|
278
|
+
body.deployment?.origin !== origin || body.deployment?.profile !== profile) {
|
|
279
|
+
return { ok: false, outcome: "verification_failed", reason: "setup_response_invalid" };
|
|
280
|
+
}
|
|
281
|
+
return { ok: true, token: body.ingest_token, keyId: body.key_id };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function checkCredential(origin, token, state, profile, cancel) {
|
|
285
|
+
const response = await send(origin, SETUP_PATHS.credential, { bearer: token,
|
|
286
|
+
query: new URLSearchParams({ family_id: state.family_id }),
|
|
287
|
+
signal: deadline(AUTH_HTTP_TIMEOUT_MS, cancel).signal, maxBytes: 32768 });
|
|
288
|
+
const failed = failureOf(response);
|
|
289
|
+
if (failed) return { ok: false, ...failed };
|
|
290
|
+
if (response.status !== 200) return { ok: false, outcome: "verification_failed", reason: "credential_rejected" };
|
|
291
|
+
const doc = parseJsonObject(response);
|
|
292
|
+
if (doc?.schema_version !== SCHEMA || doc.provenance?.origin !== origin ||
|
|
293
|
+
doc.provenance?.deployment_profile !== profile || doc.provenance?.purpose !== PURPOSE ||
|
|
294
|
+
doc.workspace_id !== state.workspace_id || doc.family_id !== state.family_id || !KEY(doc.key_id) ||
|
|
295
|
+
!["pending", "acknowledged"].includes(doc.delivery_state) ||
|
|
296
|
+
!Array.isArray(doc.scopes) || doc.scopes.length !== 1 || doc.scopes[0] !== "ingest") {
|
|
297
|
+
return { ok: false, outcome: "verification_failed", reason: "credential_context_mismatch" };
|
|
298
|
+
}
|
|
299
|
+
return { ok: true, keyId: doc.key_id, deliveryState: doc.delivery_state };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function acknowledge(origin, token, state, profile, keyId, cancel) {
|
|
303
|
+
const response = await send(origin, SETUP_PATHS.credential, { method: "POST", bearer: token,
|
|
304
|
+
form: { family_id: state.family_id }, signal: deadline(AUTH_HTTP_TIMEOUT_MS, cancel).signal, maxBytes: 32768 });
|
|
305
|
+
const failed = failureOf(response);
|
|
306
|
+
if (failed) return { ok: false, ...failed };
|
|
307
|
+
if (response.status !== 200) return { ok: false, outcome: "verification_failed", reason: "acknowledgement_rejected" };
|
|
308
|
+
const doc = parseJsonObject(response);
|
|
309
|
+
if (doc?.schema_version !== SCHEMA || doc.provenance?.origin !== origin ||
|
|
310
|
+
doc.provenance?.deployment_profile !== profile || doc.provenance?.purpose !== PURPOSE ||
|
|
311
|
+
doc.workspace_id !== state.workspace_id || doc.family_id !== state.family_id ||
|
|
312
|
+
doc.key_id !== keyId || doc.delivery_state !== "acknowledged" ||
|
|
313
|
+
!Array.isArray(doc.scopes) || doc.scopes.length !== 1 || doc.scopes[0] !== "ingest") {
|
|
314
|
+
return { ok: false, outcome: "verification_failed", reason: "acknowledgement_invalid" };
|
|
315
|
+
}
|
|
316
|
+
return { ok: true };
|
|
317
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
// The skill file shipped in assets/skill/ is a byte-for-byte copy of the
|
|
5
|
+
// public skill at the manifest's source_url. The source has no version of its
|
|
6
|
+
// own, so the revision is derived from the content hash. The hash is pinned
|
|
7
|
+
// here as well as in the manifest, so editing either file alone is detected.
|
|
8
|
+
const PINNED_SHA256 = "57b920677adf62759c7221629327192a2d16b7e6034f7948ffd96cee402d4891";
|
|
9
|
+
|
|
10
|
+
const ASSET_DIR = new URL("../assets/skill/", import.meta.url);
|
|
11
|
+
const MANIFEST_KEYS = ["file", "manifest_version", "name", "revision", "sha256", "size", "source_url"];
|
|
12
|
+
|
|
13
|
+
export function sha256Hex(content) {
|
|
14
|
+
return createHash("sha256").update(content).digest("hex");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function revisionFor(sha256) {
|
|
18
|
+
return `sha256-${sha256.slice(0, 12)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Returns { name, revision, sha256, content } for the bundled skill, or null
|
|
22
|
+
// when the manifest or the skill file is missing, malformed or does not match
|
|
23
|
+
// the pinned hash. Never downloads anything.
|
|
24
|
+
export function loadBundledSkill() {
|
|
25
|
+
let manifest;
|
|
26
|
+
let content;
|
|
27
|
+
try {
|
|
28
|
+
manifest = JSON.parse(readFileSync(new URL("manifest.json", ASSET_DIR), "utf8"));
|
|
29
|
+
content = readFileSync(new URL("SKILL.md", ASSET_DIR));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (
|
|
34
|
+
manifest === null ||
|
|
35
|
+
typeof manifest !== "object" ||
|
|
36
|
+
Array.isArray(manifest) ||
|
|
37
|
+
Object.keys(manifest).sort().join() !== MANIFEST_KEYS.join() ||
|
|
38
|
+
manifest.manifest_version !== 1 ||
|
|
39
|
+
manifest.file !== "SKILL.md" ||
|
|
40
|
+
manifest.source_url !== "https://www.metergraph.dev/SKILL.md" ||
|
|
41
|
+
typeof manifest.name !== "string" ||
|
|
42
|
+
!/^[a-z0-9-]{1,64}$/.test(manifest.name) ||
|
|
43
|
+
manifest.sha256 !== PINNED_SHA256 ||
|
|
44
|
+
manifest.revision !== revisionFor(PINNED_SHA256) ||
|
|
45
|
+
manifest.size !== content.length
|
|
46
|
+
) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const sha256 = sha256Hex(content);
|
|
50
|
+
if (sha256 !== PINNED_SHA256) return null;
|
|
51
|
+
if (!content.toString("utf8").startsWith(`---\nname: ${manifest.name}\ndescription: `)) return null;
|
|
52
|
+
return { name: manifest.name, revision: manifest.revision, sha256, content };
|
|
53
|
+
}
|