metergraph-cli 0.2.1 → 0.2.3

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.
Files changed (43) hide show
  1. package/README.md +60 -11
  2. package/assets/skill/SKILL.md +52 -3
  3. package/assets/skill/manifest.json +3 -3
  4. package/assets/skills/manifest.json +13 -13
  5. package/assets/skills/metergraph-analyze/SKILL.md +62 -4
  6. package/assets/skills/metergraph-investigate/SKILL.md +222 -35
  7. package/assets/skills/metergraph-onboarding/SKILL.md +7 -0
  8. package/assets/skills/metergraph-report/SKILL.md +19 -2
  9. package/bin/metergraph.js +1 -1
  10. package/package.json +1 -1
  11. package/src/agent-key.js +160 -0
  12. package/src/approval-handoff.js +5 -0
  13. package/src/args.js +572 -17
  14. package/src/auth-browser.js +5 -19
  15. package/src/auth-callback.js +24 -15
  16. package/src/auth-login.js +55 -12
  17. package/src/auth-oauth.js +77 -7
  18. package/src/callback-page.js +58 -0
  19. package/src/cli.js +190 -45
  20. package/src/constants.js +73 -3
  21. package/src/environment.js +169 -0
  22. package/src/experiments-contract.js +462 -0
  23. package/src/experiments-output.js +189 -0
  24. package/src/experiments.js +397 -0
  25. package/src/origin.js +6 -0
  26. package/src/output.js +437 -131
  27. package/src/pricing-args.js +163 -0
  28. package/src/pricing-output.js +206 -0
  29. package/src/pricing-reference.js +195 -0
  30. package/src/pricing.js +307 -0
  31. package/src/read-contract.js +976 -16
  32. package/src/read-output.js +449 -30
  33. package/src/read.js +312 -60
  34. package/src/setup-deployment.js +11 -3
  35. package/src/setup-interactive.js +341 -0
  36. package/src/setup-refusal.js +172 -0
  37. package/src/setup-state.js +4 -2
  38. package/src/setup.js +227 -53
  39. package/src/skill-bundle.js +2 -2
  40. package/src/style.js +101 -0
  41. package/src/transport.js +13 -7
  42. package/src/usage-aggregates.js +197 -0
  43. package/src/verify-output.js +21 -11
@@ -1,6 +1,8 @@
1
1
  import childProcess from "node:child_process";
2
2
  import path from "node:path";
3
3
 
4
+ import { classifyEnvironment } from "./environment.js";
5
+
4
6
  // Opens a URL with the operating system's own launcher: a fixed executable
5
7
  // and an argument array, never a shell. The URL is the authorization request
6
8
  // (public client ID, state and PKCE challenge); no token, code or verifier is
@@ -51,24 +53,8 @@ export function systemRoot() {
51
53
  return "C:\\Windows";
52
54
  }
53
55
 
54
- // Variables whose presence means the browser a person uses is not on this
55
- // machine, or no person is present. Only presence is checked; values are
56
- // never read into output.
57
- const REMOTE_MARKERS = [
58
- ["ssh_session", ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]],
59
- ["cloud_workspace", ["CODESPACES", "GITPOD_WORKSPACE_ID", "CLOUD_SHELL"]],
60
- ["ci_environment", ["CI"]],
61
- ];
62
-
63
- // Returns null for an ordinary local session, or a fixed reason token.
56
+ // Returns null for an ordinary local session (including a local container),
57
+ // or the fixed reason token of a remote or CI session. See environment.js.
64
58
  export function detectRemoteSession(env = process.env) {
65
- for (const [reason, names] of REMOTE_MARKERS) {
66
- for (const name of names) {
67
- const value = env[name];
68
- if (typeof value === "string" && value !== "" && value !== "0" && value.toLowerCase() !== "false") {
69
- return reason;
70
- }
71
- }
72
- }
73
- return null;
59
+ return classifyEnvironment(env, { fileExists: () => false }).reason;
74
60
  }
@@ -1,6 +1,8 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import http from "node:http";
3
3
 
4
+ import { PAGE_CSP, PAGES } from "./callback-page.js";
5
+
4
6
  // Loopback listener for the OAuth redirect (RFC 8252 section 7.3). It binds
5
7
  // 127.0.0.1 on an ephemeral port and accepts exactly one callback: a GET on
6
8
  // the fixed path, with the exact Host, one state that matches, and either one
@@ -14,23 +16,19 @@ const HOST = "127.0.0.1";
14
16
  const MAX_URL_LENGTH = 4096;
15
17
  const MAX_CODE_LENGTH = 2048;
16
18
  const ALLOWED_PARAMS = new Set(["code", "state", "error", "error_description", "error_uri", "iss"]);
19
+ // The setup receipt a combined sign-in approval returns next to the code.
20
+ // Accepted only by a wait that asked for it.
21
+ export const SETUP_CODE_PARAM = "ingest_setup_code";
17
22
 
18
23
  const PAGE_HEADERS = {
19
24
  "content-type": "text/html; charset=utf-8",
20
25
  "cache-control": "no-store",
21
- "content-security-policy": "default-src 'none'",
26
+ "content-security-policy": PAGE_CSP,
22
27
  "referrer-policy": "no-referrer",
23
28
  "x-content-type-options": "nosniff",
24
29
  "x-frame-options": "DENY",
25
30
  connection: "close",
26
31
  };
27
- const page = (text) =>
28
- `<!doctype html><meta charset="utf-8"><title>Metergraph CLI</title><p>${text}</p>\n`;
29
- const PAGES = {
30
- done: page("Metergraph CLI: authorization received. You can close this tab and return to the terminal."),
31
- failed: page("Metergraph CLI: authorization did not complete. Return to the terminal for details."),
32
- invalid: page("Metergraph CLI: this request was not accepted."),
33
- };
34
32
 
35
33
  // Starts listening. Resolves with { redirectUri, wait, close }.
36
34
  // wait({ state, issuer, requireIss, timeoutMs, cancel }) arms the listener
@@ -38,7 +36,9 @@ const PAGES = {
38
36
  // accepted and the timeout is running. Call it before the authorization
39
37
  // URL is opened or printed, so a fast browser can never reach an unarmed
40
38
  // listener. It may be called once. The promise resolves with
41
- // { kind: "code", code }
39
+ // { kind: "code", code[, setupCode] } setupCode, the ingest_setup_code
40
+ // value, only when wait() was given
41
+ // acceptSetupCode and it was sent
42
42
  // { kind: "denied" } the user or server declined
43
43
  // { kind: "error", reason } authorization_error,
44
44
  // callback_issuer_mismatch or
@@ -85,7 +85,7 @@ export async function startCallbackListener() {
85
85
  await done;
86
86
  };
87
87
 
88
- const wait = ({ state, issuer, requireIss, timeoutMs, cancel }) => {
88
+ const wait = ({ state, issuer, requireIss, timeoutMs, cancel, acceptSetupCode = false }) => {
89
89
  if (armed) throw new Error("callback listener is already armed");
90
90
  armed = true;
91
91
  // The executor runs synchronously, so the handler and timer are in
@@ -105,7 +105,7 @@ export async function startCallbackListener() {
105
105
 
106
106
  handler = (request, response) => {
107
107
  if (settled) return respond(response, 410, PAGES.invalid);
108
- const verdict = inspect(request, { host, state, issuer, requireIss });
108
+ const verdict = inspect(request, { host, state, issuer, requireIss, acceptSetupCode });
109
109
  if (verdict.result === undefined) return respond(response, verdict.status, PAGES.invalid);
110
110
  const result = verdict.result;
111
111
  respond(response, verdict.status, result.kind === "code" ? PAGES.done : PAGES.failed);
@@ -131,7 +131,7 @@ function respond(response, status, body) {
131
131
 
132
132
  // Returns { status } for a request that is ignored, or { status, result }
133
133
  // for the one request that ends the flow.
134
- function inspect(request, { host, state, issuer, requireIss }) {
134
+ function inspect(request, { host, state, issuer, requireIss, acceptSetupCode }) {
135
135
  const url = request.url ?? "";
136
136
  if (url.length > MAX_URL_LENGTH) return { status: 414 };
137
137
  if (request.method !== "GET") return { status: 405 };
@@ -148,7 +148,9 @@ function inspect(request, { host, state, issuer, requireIss }) {
148
148
  }
149
149
  const seen = new Set();
150
150
  for (const key of params.keys()) {
151
- if (!ALLOWED_PARAMS.has(key) || seen.has(key)) return { status: 400 };
151
+ if (!(ALLOWED_PARAMS.has(key) || (acceptSetupCode && key === SETUP_CODE_PARAM)) || seen.has(key)) {
152
+ return { status: 400 };
153
+ }
152
154
  seen.add(key);
153
155
  }
154
156
  // Only a request that carries this flow's exact state can end it.
@@ -158,7 +160,9 @@ function inspect(request, { host, state, issuer, requireIss }) {
158
160
  return { status: 400, result: { kind: "error", reason: "callback_issuer_mismatch" } };
159
161
  }
160
162
  if (seen.has("error")) {
161
- if (seen.has("code")) return { status: 400, result: { kind: "error", reason: "callback_invalid" } };
163
+ if (seen.has("code") || seen.has(SETUP_CODE_PARAM)) {
164
+ return { status: 400, result: { kind: "error", reason: "callback_invalid" } };
165
+ }
162
166
  return params.get("error") === "access_denied"
163
167
  ? { status: 200, result: { kind: "denied" } }
164
168
  : { status: 200, result: { kind: "error", reason: "authorization_error" } };
@@ -167,7 +171,12 @@ function inspect(request, { host, state, issuer, requireIss }) {
167
171
  if (code === null || code.length === 0 || code.length > MAX_CODE_LENGTH || /[^\x21-\x7e]/.test(code)) {
168
172
  return { status: 400, result: { kind: "error", reason: "callback_invalid" } };
169
173
  }
170
- return { status: 200, result: { kind: "code", code } };
174
+ if (!seen.has(SETUP_CODE_PARAM)) return { status: 200, result: { kind: "code", code } };
175
+ const setupCode = params.get(SETUP_CODE_PARAM);
176
+ if (setupCode.length === 0 || setupCode.length > MAX_CODE_LENGTH || /[^\x21-\x7e]/.test(setupCode)) {
177
+ return { status: 400, result: { kind: "error", reason: "callback_invalid" } };
178
+ }
179
+ return { status: 200, result: { kind: "code", code, setupCode } };
171
180
  }
172
181
 
173
182
  function sameText(actual, expected) {
package/src/auth-login.js CHANGED
@@ -1,4 +1,4 @@
1
- import { detectRemoteSession, openBrowser } from "./auth-browser.js";
1
+ import { openBrowser } from "./auth-browser.js";
2
2
  import { startCallbackListener } from "./auth-callback.js";
3
3
  import {
4
4
  BINDING_PATH,
@@ -30,8 +30,9 @@ import {
30
30
  withSlotLock,
31
31
  writeCredential,
32
32
  } from "./auth-store.js";
33
- import { AUTH_HTTP_TIMEOUT_MS, CONNECTION_GUIDE_URL, METADATA_SCOPE } from "./constants.js";
33
+ import { AUTH_HTTP_TIMEOUT_MS, METADATA_SCOPE } from "./constants.js";
34
34
  import { runDoctor } from "./doctor.js";
35
+ import { agentKeyAction, classifyEnvironment } from "./environment.js";
35
36
  import { deadline, trapSignals } from "./transport.js";
36
37
 
37
38
  // login and logout. Each returns { outcome, reason, data }; every string in
@@ -44,6 +45,10 @@ import { deadline, trapSignals } from "./transport.js";
44
45
  // exists, while the listener keeps waiting. The caller prints it as the one
45
46
  // JSON line, so an agent can hand the URL to a person; approval-handoff.js
46
47
  // lets the agent's command exit then while this process keeps waiting.
48
+ // options.ingestSetup, given only by setup, is { familyId, supported(origin,
49
+ // profile, signal) }. When the service supports it, the sign-in consent also
50
+ // approves the first ingest key for that family, and an ok result carries the
51
+ // unredeemed receipt as result.ingestSetup (never in data).
47
52
 
48
53
  export async function runLogin(options, progress) {
49
54
  const ctx = {
@@ -55,14 +60,20 @@ export async function runLogin(options, progress) {
55
60
  configured: false,
56
61
  status: null,
57
62
  previousGrant: null,
63
+ environment: null,
58
64
  };
59
65
  const end = (outcome, reason, next = null) => ({ outcome, reason, data: loginData(ctx, next) });
60
66
 
61
67
  // Handoffs come first: no listener, request or file write happens for a
62
- // session that cannot finish sign in.
63
- if (options.runtime !== "local") return end("unsupported", "runtime_not_supported", GUIDE);
64
- const remote = detectRemoteSession();
65
- if (remote !== null) return end("unsupported", remote, GUIDE);
68
+ // session that cannot finish sign in. The next action tells the agent how
69
+ // a person hands it a Metadata key instead.
70
+ const environment = options.environment ?? classifyEnvironment();
71
+ ctx.environment = environment.kind;
72
+ if (options.runtime !== "local") {
73
+ ctx.environment = "declared_cloud";
74
+ return end("unsupported", "runtime_not_supported", agentKeyAction({ kind: "declared_cloud" }, options.origin));
75
+ }
76
+ if (environment.remote) return end("unsupported", environment.reason, agentKeyAction(environment, options.origin));
66
77
  const trap = trapSignals();
67
78
  try {
68
79
  const root = resolveProject(options.project);
@@ -153,7 +164,7 @@ export async function runLogin(options, progress) {
153
164
  if (bound !== null && bound.credential_slot !== slot) {
154
165
  ctx.previousGrant = await retireSlot(store, bound);
155
166
  }
156
- return end("ok", null, CONNECTED);
167
+ return { ...end("ok", null, CONNECTED), ingestSetup: flow.ingestSetup };
157
168
  } catch (error) {
158
169
  if (error instanceof Stop) return end(error.outcome, error.reason);
159
170
  throw error;
@@ -182,6 +193,16 @@ async function authorize(options, ctx, cancel, progress, announce) {
182
193
  if (!discovered.ok) return httpFailure(discovered, cancel);
183
194
  const { endpoints, requireIss, offeredClientId } = discovered;
184
195
 
196
+ // Setup asks this consent to approve the first ingest key too, but only
197
+ // when the service says it can. Otherwise sign in is unchanged and setup
198
+ // asks for its own approval afterwards.
199
+ let setupPkce = null;
200
+ if (options.ingestSetup) {
201
+ limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
202
+ if (await options.ingestSetup.supported(ctx.origin, ctx.profile, limit.signal)) setupPkce = newPkce();
203
+ if (cancel.aborted) return fail("authorization_failed", "cancelled");
204
+ }
205
+
185
206
  const listener = await startCallbackListener();
186
207
  let callback;
187
208
  let clientId;
@@ -204,6 +225,7 @@ async function authorize(options, ctx, cancel, progress, announce) {
204
225
  requireIss,
205
226
  timeoutMs: options.timeoutMs,
206
227
  cancel,
228
+ acceptSetupCode: setupPkce !== null,
207
229
  });
208
230
  const url = authorizationUrl(endpoints, {
209
231
  clientId,
@@ -211,15 +233,18 @@ async function authorize(options, ctx, cancel, progress, announce) {
211
233
  state: pkce.state,
212
234
  challenge: pkce.challenge,
213
235
  signup: options.signup,
236
+ ingestSetup: setupPkce === null ? null : { familyId: options.ingestSetup.familyId, challenge: setupPkce.challenge },
214
237
  });
238
+ const purpose = setupPkce === null ? "sign in with Metadata access"
239
+ : "sign in with Metadata access and approve an ingest-only key for this project";
215
240
  const seconds = Math.round(options.timeoutMs / 1000);
216
241
  // A wait armed after Ctrl+C has already settled; do not show the URL.
217
242
  if (cancel.aborted) return fail("authorization_failed", "cancelled");
218
243
  if (options.noBrowser && announce) {
219
- announce(openUrl(url, seconds, "sign in with Metadata access"));
244
+ announce(openUrl(url, seconds, purpose));
220
245
  } else if (options.noBrowser) {
221
246
  progress(
222
- "Open this URL in a browser on this machine to sign in:\n" +
247
+ `Open this URL in a browser on this machine to ${purpose}:\n` +
223
248
  `${url}\n` +
224
249
  `Waiting up to ${seconds} seconds for the browser. Press Ctrl+C to cancel.`,
225
250
  );
@@ -228,15 +253,25 @@ async function authorize(options, ctx, cancel, progress, announce) {
228
253
  return fail("authorization_failed", "browser_unavailable", {
229
254
  kind: "no_browser",
230
255
  message: "Run login again with --no-browser, without --json, and open the URL it prints.",
256
+ // A local container's loopback callback is reachable only when its
257
+ // port is forwarded to the person's machine. A Metadata key avoids
258
+ // the callback altogether.
259
+ ...(ctx.environment === "local_container"
260
+ ? { alternative: agentKeyAction({ kind: "local_container", remote: false }, ctx.origin) }
261
+ : {}),
231
262
  });
232
263
  }
233
- progress(`Opened your browser to sign in. Waiting up to ${seconds} seconds. Press Ctrl+C to cancel.`);
264
+ progress(setupPkce === null
265
+ ? `Opened your browser to sign in. Waiting up to ${seconds} seconds. Press Ctrl+C to cancel.`
266
+ : "Opened your browser to sign in and approve this project's ingest key in one step. " +
267
+ `Waiting up to ${seconds} seconds. Press Ctrl+C to cancel.`);
234
268
  }
235
269
  callback = await pending;
236
270
  } finally {
237
271
  await listener.close();
238
272
  }
239
273
 
274
+ if (callback.kind === "code" && setupPkce !== null && callback.setupCode) options.ingestSetup.onApproved?.();
240
275
  if (callback.kind === "denied") return fail("authorization_failed", "access_denied");
241
276
  if (callback.kind === "timeout") return fail("authorization_failed", "timeout");
242
277
  if (callback.kind === "cancelled") return fail("authorization_failed", "cancelled");
@@ -270,7 +305,15 @@ async function authorize(options, ctx, cancel, progress, announce) {
270
305
  return rejectGrant(verified.outcome, verified.reason);
271
306
  }
272
307
  ctx.workspaceId = grant.tenant;
273
- return { ok: true, endpoints, clientId, grant };
308
+ // The receipt is a separate credential: setup redeems it with its own
309
+ // verifier, for the family it named, after binding this sign in. A service
310
+ // that showed only the sign in returns no receipt, and setup falls back to
311
+ // its own approval.
312
+ const ingestSetup = setupPkce !== null && callback.setupCode
313
+ ? { familyId: options.ingestSetup.familyId, code: callback.setupCode, verifier: setupPkce.verifier,
314
+ clientId, redirectUri: listener.redirectUri }
315
+ : null;
316
+ return { ok: true, endpoints, clientId, grant, ingestSetup };
274
317
  }
275
318
 
276
319
  export async function runLogout(options) {
@@ -389,7 +432,6 @@ export function openUrl(url, seconds, purpose) {
389
432
  };
390
433
  }
391
434
 
392
- const GUIDE = { kind: "connection_guide", url: CONNECTION_GUIDE_URL };
393
435
  const RECONNECT = {
394
436
  kind: "reconnect",
395
437
  message: "Run login again with --reconnect to switch this project, or run logout first.",
@@ -403,6 +445,7 @@ function loginData(ctx, next) {
403
445
  return {
404
446
  origin: ctx.origin,
405
447
  runtime: ctx.runtime,
448
+ environment: ctx.environment,
406
449
  deployment_profile: ctx.profile,
407
450
  workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId },
408
451
  scopes: ctx.authenticated ? [METADATA_SCOPE] : [],
package/src/auth-oauth.js CHANGED
@@ -1,6 +1,12 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
 
3
- import { AGENT_CONTRACT_VERSION, AUTH_PATHS, METADATA_SCOPE } from "./constants.js";
3
+ import {
4
+ AGENT_CONTRACT_VERSION,
5
+ AUTH_PATHS,
6
+ EXPERIMENT_START_KEY_SCOPES,
7
+ EXPERIMENT_START_SCOPE,
8
+ METADATA_SCOPE,
9
+ } from "./constants.js";
4
10
  import { parseJsonObject } from "./http.js";
5
11
  import { failureOf, send } from "./transport.js";
6
12
 
@@ -225,7 +231,11 @@ export function newPkce() {
225
231
  // The authorization URL the browser opens. With signup, the browser starts
226
232
  // at the service's own signup page, which returns to the same authorization
227
233
  // request on this origin after the account exists.
228
- export function authorizationUrl(endpoints, { clientId, redirectUri, state, challenge, signup }) {
234
+ // ingestSetup, when given as { familyId, challenge }, asks this one consent to
235
+ // also approve the first ingest key for a new setup family. The service
236
+ // returns that receipt next to the code; it is redeemed separately with its
237
+ // own PKCE verifier.
238
+ export function authorizationUrl(endpoints, { clientId, redirectUri, state, challenge, signup, ingestSetup = null }) {
229
239
  const url = new URL(endpoints.authorization);
230
240
  url.search = new URLSearchParams({
231
241
  response_type: "code",
@@ -236,6 +246,11 @@ export function authorizationUrl(endpoints, { clientId, redirectUri, state, chal
236
246
  code_challenge: challenge,
237
247
  code_challenge_method: "S256",
238
248
  resource: endpoints.resource,
249
+ ...(ingestSetup === null ? {} : {
250
+ ingest_setup: "1",
251
+ ingest_setup_family_id: ingestSetup.familyId,
252
+ ingest_setup_code_challenge: ingestSetup.challenge,
253
+ }),
239
254
  }).toString();
240
255
  if (!signup) return url.href;
241
256
  const entry = new URL(`${endpoints.origin}${AUTH_PATHS.signup}`);
@@ -383,10 +398,23 @@ function decodeClaims(token) {
383
398
  // Nothing else is read: no telemetry, content or provider requests.
384
399
  // Returns { ok: true, workspaceId, documents } or { ok: false, outcome, reason, denied }
385
400
  // where denied means the service refused the token (401 or 403).
386
- export async function verifyContext(origin, accessToken, { profile, workspaceId }, signal) {
401
+ // workspaceId is the workspace the credential must be for. Only an agent key
402
+ // with nothing to pin it to passes anyWorkspace: true with workspaceId null,
403
+ // and then the workspace the service reports is checked for consistency and
404
+ // returned as the verified one.
405
+ // access is "metadata" (every read command and sign in) or
406
+ // "experiment_start" (experiments start only: a coding-agent key with Debug
407
+ // access and the start grant, checked for exactly that and used for nothing else).
408
+ export async function verifyContext(origin, accessToken, { profile, workspaceId, anyWorkspace = false, access = "metadata" }, signal) {
387
409
  const workspace = await bearerJson(origin, AUTH_PATHS.workspace, accessToken, WORKSPACE_BYTES, signal, "workspace");
388
410
  if (!workspace.ok) return workspace;
389
- const checked = checkWorkspace(workspace.body, { profile, workspaceId });
411
+ if (workspaceId === null && anyWorkspace === true) {
412
+ workspaceId = normalizeUuid(workspace.body.workspace?.id);
413
+ if (workspaceId === null) return stop("verification_failed", "workspace_response_invalid");
414
+ }
415
+ const checked = access === "experiment_start"
416
+ ? checkStartWorkspace(workspace.body, { profile, workspaceId })
417
+ : checkWorkspace(workspace.body, { profile, workspaceId });
390
418
  if (!checked.ok) return checked;
391
419
 
392
420
  const capabilities = await bearerJson(
@@ -398,11 +426,18 @@ export async function verifyContext(origin, accessToken, { profile, workspaceId
398
426
  "capabilities",
399
427
  );
400
428
  if (!capabilities.ok) return capabilities;
401
- const allowed = checkCapabilities(capabilities.body, { profile, workspaceId });
429
+ const allowed = access === "experiment_start"
430
+ ? checkStartCapabilities(capabilities.body, { profile, workspaceId })
431
+ : checkCapabilities(capabilities.body, { profile, workspaceId });
402
432
  if (!allowed.ok) return allowed;
403
433
  // The verified documents stay in memory for read commands, which print
404
434
  // only fields they validate themselves.
405
- return { ok: true, workspaceId, documents: { workspace: workspace.body, capabilities: capabilities.body } };
435
+ return {
436
+ ok: true,
437
+ workspaceId,
438
+ scopes: checked.scopes ?? [METADATA_SCOPE],
439
+ documents: { workspace: workspace.body, capabilities: capabilities.body },
440
+ };
406
441
  }
407
442
 
408
443
  // Both documents carry the service's contract version string in
@@ -427,6 +462,41 @@ export function checkWorkspace(body, { profile, workspaceId }) {
427
462
  return { ok: true };
428
463
  }
429
464
 
465
+ // A start key's workspace document: the same identity checks as
466
+ // checkWorkspace, and exactly the scopes Debug and the start grant.
467
+ export function checkStartWorkspace(body, { profile, workspaceId }) {
468
+ const identity = checkWorkspace({ ...body, access: { scopes: [METADATA_SCOPE] } }, { profile, workspaceId });
469
+ if (!identity.ok) return identity;
470
+ const scopes = body.access?.scopes;
471
+ if (
472
+ !Array.isArray(scopes) ||
473
+ scopes.length !== EXPERIMENT_START_KEY_SCOPES.length ||
474
+ !EXPERIMENT_START_KEY_SCOPES.every((scope) => scopes.includes(scope))
475
+ ) {
476
+ return stop("permission_denied", "experiment_start_not_granted");
477
+ }
478
+ return { ok: true, scopes: [...scopes].sort() };
479
+ }
480
+
481
+ // A start key's capabilities document: the same identity checks as
482
+ // checkCapabilities, and the start capability offered to this key.
483
+ export function checkStartCapabilities(body, { profile, workspaceId }) {
484
+ if (body.schema_version !== AGENT_CONTRACT_VERSION || !isObject(body.provenance) || !isObject(body.agent)) {
485
+ return stop("verification_failed", "capabilities_response_invalid");
486
+ }
487
+ if (normalizeUuid(body.provenance.workspace_id) !== workspaceId) {
488
+ return stop("verification_failed", "workspace_context_mismatch");
489
+ }
490
+ if (body.provenance.deployment_profile !== profile || body.deployment_profile !== profile) {
491
+ return stop("verification_failed", "profile_mismatch");
492
+ }
493
+ const start = body.agent.experiment_start;
494
+ if (!isObject(start) || start.available !== true || start.required_scope !== EXPERIMENT_START_SCOPE || start.mutates !== true) {
495
+ return stop("capability_unavailable", "experiment_start_unavailable");
496
+ }
497
+ return { ok: true };
498
+ }
499
+
430
500
  export function checkCapabilities(body, { profile, workspaceId }) {
431
501
  if (body.schema_version !== AGENT_CONTRACT_VERSION || !isObject(body.provenance) || !isObject(body.agent)) {
432
502
  return stop("verification_failed", "capabilities_response_invalid");
@@ -466,7 +536,7 @@ async function bearerJson(origin, path, accessToken, maxBytes, signal, document)
466
536
  const failure = failureOf(response);
467
537
  if (failure !== null) return { ok: false, ...failure };
468
538
  if (response.status === 401 || response.status === 403) {
469
- return { ok: false, outcome: "verification_failed", reason: "access_rejected", denied: true };
539
+ return { ok: false, outcome: "verification_failed", reason: "access_rejected", denied: true, status: response.status };
470
540
  }
471
541
  if (response.status !== 200) return stop("unsupported", "unexpected_status");
472
542
  const body = parseJsonObject(response);
@@ -0,0 +1,58 @@
1
+ // The static pages the loopback callback listener answers with. They follow
2
+ // the look of the server's consent page (green band with the Metergraph mark,
3
+ // white card) so the hand-off from the browser back to the terminal reads as
4
+ // one flow. Pages are fixed strings: nothing from a request is ever rendered.
5
+ // The stylesheet is inline and allowed by its hash, so the policy stays
6
+ // default-src 'none' with no script and no 'unsafe-inline'.
7
+ import { createHash } from "node:crypto";
8
+
9
+ const LOGO = `<svg class="logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 134 25" width="134" height="25" role="img" aria-label="Metergraph"><g fill="white" transform="scale(0.03681885)"><circle cx="105" cy="128" r="62"/><circle cx="105" cy="239" r="62"/><circle cx="105" cy="350" r="62"/><circle cx="105" cy="461" r="62"/><circle cx="105" cy="572" r="62"/><circle cx="578" cy="128" r="62"/><circle cx="578" cy="239" r="62"/><circle cx="578" cy="350" r="62"/><circle cx="578" cy="461" r="62"/><circle cx="578" cy="572" r="62"/><circle cx="221" cy="238" r="62"/><circle cx="282.5" cy="346" r="62"/><circle cx="341.5" cy="456" r="62"/><circle cx="400.5" cy="346" r="62"/><circle cx="462" cy="238" r="62"/></g><path d="M34.68 20V6H38.2L42.08 16.38L42.56 17.78C42.7 17.32 42.82 16.9 43.02 16.38L46.82 6H50.28V20H48.12V10.02L48.14 8.16L43.84 20H41.22L36.8 8.16L36.84 10.02V20H34.68ZM56.8091 20.32C53.9091 20.32 51.9691 18.48 51.9691 15.3C51.9691 12.1 53.9691 9.98 56.7091 9.98C59.6491 9.98 61.2891 12.02 61.2891 15.16V15.6H54.0891C54.1091 17.48 55.2491 18.7 56.8091 18.7C57.9291 18.7 58.9291 18.16 59.1291 16.92H61.2091C61.0091 18.76 59.4891 20.32 56.8091 20.32ZM59.1291 14.12C59.1091 12.6 58.2091 11.56 56.7091 11.56C55.2291 11.56 54.2291 12.68 54.1091 14.12H59.1291ZM66.6303 20.16C64.8703 20.16 63.6703 19.3 63.6703 17.14V11.98H61.6703V10.3H63.6703V6.94H65.7503V10.3H67.9503V11.98H65.7503V16.86C65.7503 17.94 66.2303 18.42 67.1903 18.42C67.4303 18.42 67.6503 18.4 67.9103 18.36V20.02C67.6103 20.1 67.1503 20.16 66.6303 20.16ZM73.4997 20.32C70.5997 20.32 68.6597 18.48 68.6597 15.3C68.6597 12.1 70.6597 9.98 73.3997 9.98C76.3397 9.98 77.9797 12.02 77.9797 15.16V15.6H70.7797C70.7997 17.48 71.9397 18.7 73.4997 18.7C74.6197 18.7 75.6197 18.16 75.8197 16.92H77.8997C77.6997 18.76 76.1797 20.32 73.4997 20.32ZM75.8197 14.12C75.7997 12.6 74.8997 11.56 73.3997 11.56C71.9197 11.56 70.9197 12.68 70.7997 14.12H75.8197ZM79.5581 20V10.3H81.6381V10.88L81.5981 11.98C82.0981 10.88 83.0581 9.98 84.5381 9.98C84.6981 9.98 84.8381 9.98 85.0181 10.02V12.04C84.8581 12.02 84.6781 11.98 84.3981 11.98C82.7181 11.98 81.6381 12.88 81.6381 15.02V20H79.5581ZM90.0131 24.44C87.3131 24.44 85.6531 22.96 85.6531 20.8H87.7531C87.7731 22.02 88.6131 22.82 90.0531 22.82C91.5931 22.82 92.6931 21.96 92.6931 20.2V19.08C92.6931 18.76 92.6931 18.46 92.7331 18.16C92.1931 19.08 91.0931 19.76 89.6331 19.76C87.0331 19.76 85.2531 17.92 85.2531 14.88C85.2531 11.88 87.2531 9.98 89.5931 9.98C91.2531 9.98 92.1931 10.74 92.7331 11.64C92.6931 11.36 92.6931 11.08 92.6931 10.78V10.3H94.7731V19.6C94.7731 22.86 92.9931 24.44 90.0131 24.44ZM90.1131 18.12C91.6931 18.12 92.8131 16.8 92.8131 14.88C92.8131 12.98 91.6931 11.62 90.1131 11.62C88.4931 11.62 87.3731 12.96 87.3731 14.88C87.3731 16.82 88.4931 18.12 90.1131 18.12ZM96.9128 20V10.3H98.9928V10.88L98.9528 11.98C99.4528 10.88 100.413 9.98 101.893 9.98C102.053 9.98 102.193 9.98 102.373 10.02V12.04C102.213 12.02 102.033 11.98 101.753 11.98C100.073 11.98 98.9928 12.88 98.9928 15.02V20H96.9128ZM105.929 20.32C104.169 20.32 102.589 19.2 102.589 17.34C102.589 15.3 104.189 14.48 106.089 14.22L107.829 13.96C108.589 13.86 108.849 13.58 108.849 13.04C108.849 12.18 108.169 11.56 107.089 11.56C105.769 11.56 105.029 12.28 104.969 13.38H102.849C102.949 11.5 104.469 9.98 106.949 9.98C109.629 9.98 110.909 11.48 110.909 13.96V20H108.969V19.56C108.969 19.26 108.969 19 109.009 18.68C108.509 19.66 107.489 20.32 105.929 20.32ZM106.389 18.76C107.809 18.76 108.889 17.76 108.889 16.28V15.04C108.729 15.22 108.369 15.36 107.789 15.46L106.689 15.68C105.549 15.9 104.729 16.34 104.729 17.28C104.729 18.22 105.469 18.76 106.389 18.76ZM112.92 24.12V10.3H115V10.86C115 11.12 114.98 11.36 114.96 11.66C115.54 10.7 116.66 9.98 118.14 9.98C120.82 9.98 122.5 12.14 122.5 15.16C122.5 18.28 120.64 20.32 118.08 20.32C116.56 20.32 115.58 19.64 114.96 18.66L115 19.52V24.12H112.92ZM117.64 18.7C119.28 18.7 120.36 17.24 120.36 15.14C120.36 13.08 119.32 11.62 117.64 11.62C116.04 11.62 114.9 12.98 114.9 15.14C114.9 17.26 116.02 18.7 117.64 18.7ZM124.058 20V6H126.138V10.76L126.098 11.64C126.618 10.7 127.658 9.98 129.078 9.98C131.258 9.98 132.578 11.44 132.578 13.88V20H130.498V14.16C130.498 12.54 129.838 11.62 128.478 11.62C127.058 11.62 126.138 12.8 126.138 14.24V20H124.058Z" fill="white"/></svg>`;
10
+
11
+ const STYLE = `
12
+ :root{color-scheme:light}
13
+ *{box-sizing:border-box}
14
+ body{margin:0;background:#edeee9;color:#151b17;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.55}
15
+ .shell{max-width:600px;margin:0 auto;padding:28px 12px 48px}
16
+ .hero{background:linear-gradient(90deg,#0a6146,#0a5944 54%,#073d32);color:#fff;padding:26px 30px 34px}
17
+ .logo{display:block;width:150px;height:auto;margin:0 0 36px}
18
+ .eyebrow{margin:0 0 6px;color:#d7f68e;font-size:13px;font-weight:700;letter-spacing:.06em;text-transform:uppercase}
19
+ h1{margin:0;font-size:30px;font-weight:400;letter-spacing:-.6px;line-height:1.2}
20
+ .card{background:#fff;padding:30px}
21
+ p{margin:0 0 18px}
22
+ p:last-child{margin-bottom:0}
23
+ .note{color:#505b53;font-size:14px}
24
+ @media (max-width:520px){
25
+ .shell{padding:0 0 32px}
26
+ .hero{padding:22px 20px 28px}
27
+ .logo{margin-bottom:28px}
28
+ h1{font-size:25px}
29
+ .card{padding:22px 20px}
30
+ }
31
+ `;
32
+
33
+ export const PAGE_CSP = `default-src 'none'; style-src 'sha256-${createHash("sha256").update(STYLE).digest("base64")}'`;
34
+
35
+ const page = (title, lead, note) =>
36
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">' +
37
+ '<meta name="viewport" content="width=device-width, initial-scale=1">' +
38
+ `<title>${title} | Metergraph CLI</title><style>${STYLE}</style></head><body>` +
39
+ `<div class="shell"><header class="hero">${LOGO}<p class="eyebrow">Metergraph CLI</p><h1>${title}</h1></header>` +
40
+ `<main class="card"><p>${lead}</p><p class="note">${note}</p></main></div></body></html>\n`;
41
+
42
+ export const PAGES = {
43
+ done: page(
44
+ "Authorization received",
45
+ "Return to the terminal. The command finishes signing in there and reports the result.",
46
+ "You can close this tab.",
47
+ ),
48
+ failed: page(
49
+ "Authorization did not complete",
50
+ "Return to the terminal for details and the next step.",
51
+ "You can close this tab.",
52
+ ),
53
+ invalid: page(
54
+ "Request not accepted",
55
+ "This page only accepts the browser hand-off for a command that is waiting in your terminal.",
56
+ "If you started a sign-in, return to the terminal and try again.",
57
+ ),
58
+ };