metergraph-cli 0.2.3 → 0.2.4

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 (59) hide show
  1. package/README.md +78 -14
  2. package/assets/skill/SKILL.md +124 -31
  3. package/assets/skill/manifest.json +3 -3
  4. package/assets/skills/manifest.json +31 -31
  5. package/assets/skills/metergraph-analyze/SKILL.md +161 -58
  6. package/assets/skills/metergraph-candidates/SKILL.md +50 -8
  7. package/assets/skills/metergraph-evals/SKILL.md +83 -16
  8. package/assets/skills/metergraph-investigate/SKILL.md +90 -34
  9. package/assets/skills/metergraph-iterate/SKILL.md +82 -12
  10. package/assets/skills/metergraph-local-bundle/SKILL.md +146 -128
  11. package/assets/skills/metergraph-model-swap/SKILL.md +142 -20
  12. package/assets/skills/metergraph-onboarding/SKILL.md +93 -49
  13. package/assets/skills/metergraph-report/SKILL.md +60 -15
  14. package/assets/skills/metergraph-workloads/SKILL.md +97 -13
  15. package/native/README.md +85 -0
  16. package/native/anchored-fs.c +243 -0
  17. package/native/bin/anchored-fs-darwin-arm64 +0 -0
  18. package/native/bin/anchored-fs-darwin-x64 +0 -0
  19. package/native/bin/anchored-fs-linux-arm64 +0 -0
  20. package/native/bin/anchored-fs-linux-x64 +0 -0
  21. package/native/build.mjs +55 -0
  22. package/native/manifest.json +9 -0
  23. package/native/targets.mjs +24 -0
  24. package/package.json +10 -3
  25. package/src/action-help.js +130 -0
  26. package/src/agent-key.js +21 -14
  27. package/src/anchored-fs.js +163 -0
  28. package/src/args.js +125 -17
  29. package/src/auth-binding.js +12 -7
  30. package/src/auth-callback.js +10 -2
  31. package/src/auth-login.js +111 -28
  32. package/src/auth-oauth.js +89 -45
  33. package/src/auth-session.js +19 -12
  34. package/src/auth-store.js +28 -7
  35. package/src/cli.js +52 -8
  36. package/src/constants.js +37 -9
  37. package/src/experiments-output.js +7 -6
  38. package/src/experiments.js +26 -17
  39. package/src/output.js +274 -79
  40. package/src/read-contract.js +42 -8
  41. package/src/read-output.js +66 -13
  42. package/src/read.js +90 -15
  43. package/src/repository-identity.js +2 -2
  44. package/src/setup-env-git.js +1 -1
  45. package/src/setup-env.js +16 -9
  46. package/src/setup-interactive.js +19 -4
  47. package/src/setup-refusal.js +2 -2
  48. package/src/setup-state.js +9 -4
  49. package/src/setup.js +15 -3
  50. package/src/skill-bundle.js +2 -2
  51. package/src/skill-trusted.js +56 -0
  52. package/src/skill.js +28 -9
  53. package/src/style.js +33 -7
  54. package/src/tools-output.js +73 -0
  55. package/src/tools.js +249 -0
  56. package/src/trace-contract.js +1 -1
  57. package/src/transport.js +21 -0
  58. package/src/verify-output.js +2 -2
  59. package/src/verify.js +5 -2
package/src/auth-login.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  exchangeCode,
17
17
  newPkce,
18
18
  revoke,
19
+ sameScopes,
19
20
  verifyContext,
20
21
  } from "./auth-oauth.js";
21
22
  import { LOCK_WAIT_MS, sessionFor } from "./auth-session.js";
@@ -25,26 +26,35 @@ import {
25
26
  deleteCredential,
26
27
  newSlot,
27
28
  openStore,
28
- readCredential,
29
+ readProjectCredential,
30
+ projectIdentity,
29
31
  resolveConfigDir,
30
32
  withSlotLock,
31
33
  writeCredential,
32
34
  } from "./auth-store.js";
33
- import { AUTH_HTTP_TIMEOUT_MS, METADATA_SCOPE } from "./constants.js";
35
+ import { AUTH_HTTP_TIMEOUT_MS, WORKFLOW_PERMISSIONS, loginScope } from "./constants.js";
34
36
  import { runDoctor } from "./doctor.js";
35
37
  import { agentKeyAction, classifyEnvironment } from "./environment.js";
38
+ import { workspaceLabel, workspacePinned } from "./read-contract.js";
36
39
  import { deadline, trapSignals } from "./transport.js";
37
40
 
38
41
  // login and logout. Each returns { outcome, reason, data }; every string in
39
- // data is a fixed token, the validated origin, a supported profile or a
40
- // server-verified workspace UUID. Token values, the authorization code, the
41
- // PKCE verifier, absolute paths and server text never leave this module.
42
+ // data is a fixed token, the validated origin, a supported profile, a
43
+ // server-verified workspace UUID, or that workspace's slug and name as the
44
+ // service reported them, checked as safe to print and dropped if they contain
45
+ // a credential this run holds. Token values, the authorization code, the PKCE
46
+ // verifier, absolute paths and other server text never leave this module.
42
47
  // progress(text) prints human progress lines; it is a no-op with --json.
43
48
  // options.announce(data), given only with --json --no-browser, receives the
44
49
  // login data with an open_url next action as soon as the authorization URL
45
50
  // exists, while the listener keeps waiting. The caller prints it as the one
46
51
  // JSON line, so an agent can hand the URL to a person; approval-handoff.js
47
52
  // lets the agent's command exit then while this process keeps waiting.
53
+ // options.role is "metadata" (the default) or "debug", and options.permissions
54
+ // the workflow permission scopes --allow-* added to Debug, which a workspace
55
+ // owner approves on the consent page. options.workspaceText is a workspace named by
56
+ // slug or name: sent to a consent page that can match it, and checked against
57
+ // the workspace the service reports.
48
58
  // options.ingestSetup, given only by setup, is { familyId, supported(origin,
49
59
  // profile, signal) }. When the service supports it, the sign-in consent also
50
60
  // approves the first ingest key for that family, and an ok result carries the
@@ -61,8 +71,12 @@ export async function runLogin(options, progress) {
61
71
  status: null,
62
72
  previousGrant: null,
63
73
  environment: null,
74
+ role: options.role === "debug" ? "debug" : "metadata",
75
+ scope: loginScope(options.role, options.permissions ?? []),
76
+ workspaceLabel: { slug: null, name: null },
64
77
  };
65
78
  const end = (outcome, reason, next = null) => ({ outcome, reason, data: loginData(ctx, next) });
79
+ const workspaceText = options.workspaceText ?? null;
66
80
 
67
81
  // Handoffs come first: no listener, request or file write happens for a
68
82
  // session that cannot finish sign in. The next action tells the agent how
@@ -74,6 +88,11 @@ export async function runLogin(options, progress) {
74
88
  return end("unsupported", "runtime_not_supported", agentKeyAction({ kind: "declared_cloud" }, options.origin));
75
89
  }
76
90
  if (environment.remote) return end("unsupported", environment.reason, agentKeyAction(environment, options.origin));
91
+ // Refuse before reading saved grants or creating a callback/manual URL.
92
+ // Browser consent through a host loopback port cannot establish identity.
93
+ if (new URL(options.origin).protocol === "http:") {
94
+ return end("unsupported", "local_docker_transport_required");
95
+ }
77
96
  const trap = trapSignals();
78
97
  try {
79
98
  const root = resolveProject(options.project);
@@ -94,18 +113,25 @@ export async function runLogin(options, progress) {
94
113
  }
95
114
  const store = openStore(configDir, { create: true });
96
115
 
97
- // A rerun with a working grant changes nothing and opens no browser.
98
- if (bound !== null && !options.reconnect) {
99
- const session = await sessionFor(bound, store, trap.signal);
116
+ // A rerun with a working grant for the same role and permissions changes
117
+ // nothing and opens no browser. Asking for others signs in again, for the
118
+ // same workspace.
119
+ const saved = bound === null ? null : readOrNull(store, bound.credential_slot, root);
120
+ if (bound !== null && !options.reconnect && saved?.scope === ctx.scope) {
121
+ const session = await sessionFor(bound, store, trap.signal, root);
100
122
  if (session.ok) {
123
+ if (workspaceText !== null && !workspacePinned(session.documents.workspace?.workspace, workspaceText)) {
124
+ return end("conflict", "bound_to_other_workspace", RECONNECT);
125
+ }
101
126
  Object.assign(ctx, {
102
127
  profile: bound.deployment_profile,
103
128
  workspaceId: bound.workspace_id,
129
+ workspaceLabel: labelWithout(workspaceLabel(session.documents.workspace?.workspace), session.knownCredentials),
104
130
  authenticated: true,
105
131
  configured: true,
106
132
  status: "reused",
107
133
  });
108
- return end("ok", null, CONNECTED);
134
+ return end("ok", null, connected(ctx));
109
135
  }
110
136
  if (session.outcome !== "login_required") return end(session.outcome, session.reason);
111
137
  }
@@ -128,13 +154,14 @@ export async function runLogin(options, progress) {
128
154
  try {
129
155
  writeCredential(store, slot, {
130
156
  schema_version: 1,
157
+ project_id: projectIdentity(root),
131
158
  origin: ctx.origin,
132
159
  issuer: endpoints.issuer,
133
160
  resource: endpoints.resource,
134
161
  deployment_profile: ctx.profile,
135
162
  workspace_id: grant.tenant,
136
163
  client_id: clientId,
137
- scope: METADATA_SCOPE,
164
+ scope: ctx.scope,
138
165
  access_token: grant.accessToken,
139
166
  refresh_token: grant.refreshToken,
140
167
  expires_at: grant.expiresAt,
@@ -162,9 +189,9 @@ export async function runLogin(options, progress) {
162
189
  // The old grant is no longer referenced. Ask the service to revoke it
163
190
  // and remove it locally; neither affects the new binding.
164
191
  if (bound !== null && bound.credential_slot !== slot) {
165
- ctx.previousGrant = await retireSlot(store, bound);
192
+ ctx.previousGrant = await retireSlot(store, bound, root);
166
193
  }
167
- return { ...end("ok", null, CONNECTED), ingestSetup: flow.ingestSetup };
194
+ return { ...end("ok", null, connected(ctx)), ingestSetup: flow.ingestSetup };
168
195
  } catch (error) {
169
196
  if (error instanceof Stop) return end(error.outcome, error.reason);
170
197
  throw error;
@@ -189,9 +216,18 @@ async function authorize(options, ctx, cancel, progress, announce) {
189
216
  if (options.signup && ctx.profile !== "managed") return fail("unsupported", "signup_unsupported");
190
217
 
191
218
  let limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
192
- const discovered = await discover(ctx.origin, limit.signal);
219
+ const discovered = await discover(ctx.origin, limit.signal, ctx.scope.split(" ")[0]);
193
220
  if (!discovered.ok) return httpFailure(discovered, cancel);
194
221
  const { endpoints, requireIss, offeredClientId } = discovered;
222
+ const scopes = ctx.scope.split(" ");
223
+ // The consent page can match a slug or name only where the service says
224
+ // so. Without that, a slug is still checked after sign in, but a name,
225
+ // which may be ambiguous, is refused before the browser opens.
226
+ const workspaceText = options.workspaceText ?? null;
227
+ if (workspaceText !== null && !discovered.workspaceHint && !SLUG.test(workspaceText.toLowerCase())) {
228
+ return fail("unsupported", "workspace_name_unsupported", USE_SLUG);
229
+ }
230
+ const workspaceHint = discovered.workspaceHint ? workspaceText ?? options.workspace ?? null : null;
195
231
 
196
232
  // Setup asks this consent to approve the first ingest key too, but only
197
233
  // when the service says it can. Otherwise sign in is unchanged and setup
@@ -209,7 +245,7 @@ async function authorize(options, ctx, cancel, progress, announce) {
209
245
  let verifier;
210
246
  try {
211
247
  limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
212
- const client = await clientFor(endpoints, offeredClientId, listener.redirectUri, limit.signal);
248
+ const client = await clientFor(endpoints, offeredClientId, listener.redirectUri, limit.signal, scopes);
213
249
  if (!client.ok) return httpFailure(client, cancel);
214
250
  clientId = client.clientId;
215
251
 
@@ -234,9 +270,12 @@ async function authorize(options, ctx, cancel, progress, announce) {
234
270
  challenge: pkce.challenge,
235
271
  signup: options.signup,
236
272
  ingestSetup: setupPkce === null ? null : { familyId: options.ingestSetup.familyId, challenge: setupPkce.challenge },
273
+ scopes,
274
+ workspaceHint,
237
275
  });
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";
276
+ const access = accessText(ctx) + (ctx.scope.includes(" ") ? ", which a workspace owner approves" : "");
277
+ const purpose = setupPkce === null ? `sign in with ${access}`
278
+ : `sign in with ${access} and approve an ingest-only key for this project`;
240
279
  const seconds = Math.round(options.timeoutMs / 1000);
241
280
  // A wait armed after Ctrl+C has already settled; do not show the URL.
242
281
  if (cancel.aborted) return fail("authorization_failed", "cancelled");
@@ -272,6 +311,8 @@ async function authorize(options, ctx, cancel, progress, announce) {
272
311
  }
273
312
 
274
313
  if (callback.kind === "code" && setupPkce !== null && callback.setupCode) options.ingestSetup.onApproved?.();
314
+ // The consent page refused the workspace the CLI named.
315
+ if (callback.kind === "denied" && callback.hint) return fail("invalid_input", callback.hint, USE_SLUG);
275
316
  if (callback.kind === "denied") return fail("authorization_failed", "access_denied");
276
317
  if (callback.kind === "timeout") return fail("authorization_failed", "timeout");
277
318
  if (callback.kind === "cancelled") return fail("authorization_failed", "cancelled");
@@ -280,7 +321,7 @@ async function authorize(options, ctx, cancel, progress, announce) {
280
321
  limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
281
322
  const exchanged = await exchangeCode(
282
323
  endpoints,
283
- { clientId, redirectUri: listener.redirectUri, code: callback.code, verifier },
324
+ { clientId, redirectUri: listener.redirectUri, code: callback.code, verifier, scopes },
284
325
  limit.signal,
285
326
  );
286
327
  if (!exchanged.ok) return httpFailure(exchanged, cancel);
@@ -297,14 +338,22 @@ async function authorize(options, ctx, cancel, progress, announce) {
297
338
  const verified = await verifyContext(
298
339
  ctx.origin,
299
340
  grant.accessToken,
300
- { profile: ctx.profile, workspaceId: grant.tenant },
341
+ { profile: ctx.profile, workspaceId: grant.tenant, access: ctx.role === "debug" ? "read" : "metadata" },
301
342
  limit.signal,
302
343
  );
303
344
  if (!verified.ok) {
304
345
  if (cancel.aborted) return rejectGrant("authorization_failed", "cancelled");
305
346
  return rejectGrant(verified.outcome, verified.reason);
306
347
  }
348
+ if (ctx.role === "debug" && !sameScopes(ctx.scope, verified.scopes)) return rejectGrant("verification_failed", "scope_mismatch");
349
+ const workspace = verified.documents.workspace?.workspace;
350
+ if (workspaceText !== null && !workspacePinned(workspace, workspaceText)) {
351
+ return rejectGrant("verification_failed", "workspace_mismatch");
352
+ }
307
353
  ctx.workspaceId = grant.tenant;
354
+ ctx.workspaceLabel = labelWithout(workspaceLabel(workspace), [
355
+ grant.accessToken, grant.refreshToken, callback.code, verifier, callback.setupCode, setupPkce?.verifier,
356
+ ]);
308
357
  // The receipt is a separate credential: setup redeems it with its own
309
358
  // verifier, for the family it named, after binding this sign in. A service
310
359
  // that showed only the sign in returns no receipt, and setup falls back to
@@ -335,7 +384,8 @@ export async function runLogout(options) {
335
384
  store,
336
385
  bound.credential_slot,
337
386
  async () => {
338
- const record = readOrNull(store, bound.credential_slot);
387
+ const record = readOrNull(store, bound.credential_slot, root);
388
+ if (record === null) return false;
339
389
  // A grant is only ever sent back to the origin it came from.
340
390
  if (record !== null && record.origin === bound.origin) {
341
391
  const limit = deadline(AUTH_HTTP_TIMEOUT_MS, trap.signal);
@@ -372,13 +422,14 @@ export async function runLogout(options) {
372
422
  // Revokes and removes the grant a previous binding pointed to. Returns
373
423
  // "accepted", "unconfirmed" or "not_attempted". Failures here never undo the
374
424
  // new binding.
375
- async function retireSlot(store, bound) {
425
+ async function retireSlot(store, bound, root) {
376
426
  try {
377
427
  return await withSlotLock(
378
428
  store,
379
429
  bound.credential_slot,
380
430
  async () => {
381
- const record = readOrNull(store, bound.credential_slot);
431
+ const record = readOrNull(store, bound.credential_slot, root);
432
+ if (record === null) return "not_attempted";
382
433
  let revocation = "not_attempted";
383
434
  if (record !== null && record.origin === bound.origin) {
384
435
  revocation = await revokeQuietly(endpointsFor(record.origin), record.client_id, record.refresh_token);
@@ -393,9 +444,9 @@ async function retireSlot(store, bound) {
393
444
  }
394
445
  }
395
446
 
396
- function readOrNull(store, slot) {
447
+ function readOrNull(store, slot, root) {
397
448
  try {
398
- return readCredential(store, slot);
449
+ return readProjectCredential(store, slot, root);
399
450
  } catch (error) {
400
451
  if (error instanceof Stop && error.outcome === "login_required") return null;
401
452
  throw error;
@@ -436,10 +487,38 @@ const RECONNECT = {
436
487
  kind: "reconnect",
437
488
  message: "Run login again with --reconnect to switch this project, or run logout first.",
438
489
  };
439
- const CONNECTED = {
440
- kind: "connected",
441
- message: 'This project is signed in with Metadata access. Run "metergraph logout" to sign out.',
490
+ const USE_SLUG = {
491
+ kind: "use_workspace_slug",
492
+ message:
493
+ "Run login again with --workspace and the workspace's slug, which its Settings page and " +
494
+ "\"metergraph context\" show.",
442
495
  };
496
+ const SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
497
+
498
+ // A workspace label is service text. If it holds a credential this run knows,
499
+ // it is not printed; sign in itself is unaffected.
500
+ function labelWithout(label, secrets) {
501
+ const values = [label.slug, label.name].filter((value) => value !== null);
502
+ const echoes = secrets.some((secret) => typeof secret === "string" && secret.length > 0 &&
503
+ values.some((value) => value.includes(secret)));
504
+ return echoes ? { slug: null, name: null } : label;
505
+ }
506
+
507
+ // The role and permissions a sign in asks for, by the Keys page's labels.
508
+ function accessText(ctx) {
509
+ if (ctx.role !== "debug") return "the Metadata role";
510
+ const labels = WORKFLOW_PERMISSIONS.filter(({ scope }) => ctx.scope.split(" ").includes(scope)).map(({ label }) => label);
511
+ if (labels.length === 0) return "the Debug role";
512
+ const list = labels.length === 1 ? labels[0] : `${labels.slice(0, -1).join(", ")} and ${labels.at(-1)}`;
513
+ return `the Debug role with ${list}`;
514
+ }
515
+
516
+ function connected(ctx) {
517
+ return {
518
+ kind: "connected",
519
+ message: `This project is signed in with ${accessText(ctx)}. Run "metergraph logout" to sign out.`,
520
+ };
521
+ }
443
522
 
444
523
  function loginData(ctx, next) {
445
524
  return {
@@ -447,8 +526,12 @@ function loginData(ctx, next) {
447
526
  runtime: ctx.runtime,
448
527
  environment: ctx.environment,
449
528
  deployment_profile: ctx.profile,
450
- workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId },
451
- scopes: ctx.authenticated ? [METADATA_SCOPE] : [],
529
+ workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId, ...ctx.workspaceLabel },
530
+ access: {
531
+ role: ctx.role,
532
+ permissions: Object.fromEntries(WORKFLOW_PERMISSIONS.map(({ field, scope }) => [field, ctx.scope.split(" ").includes(scope)])),
533
+ },
534
+ scopes: ctx.authenticated ? ctx.scope.split(" ").sort() : [],
452
535
  authenticated: ctx.authenticated,
453
536
  configured: ctx.configured,
454
537
  status: ctx.status,