metergraph-cli 0.2.3 → 0.2.5

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 (60) 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 +37 -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-price-check/SKILL.md +219 -0
  14. package/assets/skills/metergraph-report/SKILL.md +60 -15
  15. package/assets/skills/metergraph-workloads/SKILL.md +97 -13
  16. package/native/README.md +85 -0
  17. package/native/anchored-fs.c +243 -0
  18. package/native/bin/anchored-fs-darwin-arm64 +0 -0
  19. package/native/bin/anchored-fs-darwin-x64 +0 -0
  20. package/native/bin/anchored-fs-linux-arm64 +0 -0
  21. package/native/bin/anchored-fs-linux-x64 +0 -0
  22. package/native/build.mjs +55 -0
  23. package/native/manifest.json +9 -0
  24. package/native/targets.mjs +24 -0
  25. package/package.json +10 -3
  26. package/src/action-help.js +130 -0
  27. package/src/agent-key.js +21 -14
  28. package/src/anchored-fs.js +163 -0
  29. package/src/args.js +125 -17
  30. package/src/auth-binding.js +12 -7
  31. package/src/auth-callback.js +10 -2
  32. package/src/auth-login.js +111 -28
  33. package/src/auth-oauth.js +89 -45
  34. package/src/auth-session.js +19 -12
  35. package/src/auth-store.js +28 -7
  36. package/src/cli.js +52 -8
  37. package/src/constants.js +37 -9
  38. package/src/experiments-output.js +7 -6
  39. package/src/experiments.js +26 -17
  40. package/src/output.js +274 -79
  41. package/src/read-contract.js +42 -8
  42. package/src/read-output.js +66 -13
  43. package/src/read.js +90 -15
  44. package/src/repository-identity.js +2 -2
  45. package/src/setup-env-git.js +1 -1
  46. package/src/setup-env.js +16 -9
  47. package/src/setup-interactive.js +19 -4
  48. package/src/setup-refusal.js +2 -2
  49. package/src/setup-state.js +9 -4
  50. package/src/setup.js +15 -3
  51. package/src/skill-bundle.js +2 -2
  52. package/src/skill-trusted.js +56 -0
  53. package/src/skill.js +28 -9
  54. package/src/style.js +33 -7
  55. package/src/tools-output.js +73 -0
  56. package/src/tools.js +249 -0
  57. package/src/trace-contract.js +1 -1
  58. package/src/transport.js +21 -0
  59. package/src/verify-output.js +2 -2
  60. package/src/verify.js +5 -2
@@ -1,4 +1,4 @@
1
- import { normalizeUuid } from "./auth-oauth.js";
1
+ import { credentialAccess, normalizeUuid } from "./auth-oauth.js";
2
2
  import {
3
3
  AGENT_CONTRACT_VERSION,
4
4
  MAX_CURSOR_LENGTH,
@@ -17,7 +17,8 @@ import { traceLink } from "./trace-contract.js";
17
17
  // with fixed tokens. notices is a Set that collects fixed notice tokens.
18
18
 
19
19
  // Every capability name the contract defines. Content, replay and start
20
- // entries are never available to a Metadata grant.
20
+ // entries are never available to a Metadata grant; a Debug credential with
21
+ // workflow permissions may be offered content and starts.
21
22
  export const CAPABILITY_NAMES = Object.freeze([
22
23
  "workspace_context",
23
24
  "capability_discovery",
@@ -1111,6 +1112,33 @@ export function combinePages(pages) {
1111
1112
  };
1112
1113
  }
1113
1114
 
1115
+ const SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
1116
+ const MAX_WORKSPACE_NAME = 200;
1117
+
1118
+ // A workspace document's slug and name, each kept only when it is safe to
1119
+ // print (a well-formed slug; a name with no control or bidirectional
1120
+ // characters), else null. This is how output names a workspace: never by ID.
1121
+ export function workspaceLabel(workspace) {
1122
+ const slug = typeof workspace?.slug === "string" && SLUG.test(workspace.slug) ? workspace.slug : null;
1123
+ const raw = workspace?.name;
1124
+ const name = typeof raw === "string" && raw.trim() !== "" && raw.length <= MAX_WORKSPACE_NAME && !hasUnsafeText(raw)
1125
+ ? raw : null;
1126
+ return { slug, name };
1127
+ }
1128
+
1129
+ // Whether a workspace document is the one a person named with --workspace.
1130
+ // Slugs are unique, names are not, and anyone who owns a workspace chooses
1131
+ // its name, including a workspace a person was invited to. So the match is
1132
+ // strict, for sign in and for every credential pin: text shaped like a slug
1133
+ // matches only the slug, and other text only the exact name, ignoring case.
1134
+ export function workspacePinned(workspace, text) {
1135
+ const { slug, name } = workspaceLabel(workspace);
1136
+ const wanted = text.trim().toLowerCase();
1137
+ if (SLUG.test(wanted)) return slug === wanted;
1138
+ return name !== null && name.trim().toLowerCase() === wanted;
1139
+ }
1140
+
1141
+
1114
1142
  // GET /v1/agent/workspace, already checked by checkWorkspace during the
1115
1143
  // session. Returns the approved workspace fields only.
1116
1144
  export function workspaceContext(body, ctx, notices) {
@@ -1128,7 +1156,8 @@ export function workspaceContext(body, ctx, notices) {
1128
1156
  throw new Invalid("verification_failed", "content_access_granted");
1129
1157
  }
1130
1158
  const scopes = body.access?.scopes;
1131
- if (!Array.isArray(scopes) || scopes.length !== 1 || scopes[0] !== METADATA_SCOPE) {
1159
+ const access = credentialAccess(scopes);
1160
+ if (access === null || access !== (ctx.access ?? "metadata")) {
1132
1161
  throw new Invalid("verification_failed", "scope_mismatch");
1133
1162
  }
1134
1163
  return {
@@ -1141,12 +1170,15 @@ export function workspaceContext(body, ctx, notices) {
1141
1170
  },
1142
1171
  retention: { metadata_days: body.retention.metadata_days },
1143
1172
  content: { captured: body.content.captured, included: false },
1144
- access: { scopes: [METADATA_SCOPE] },
1173
+ access: { scopes: [...scopes].sort() },
1145
1174
  };
1146
1175
  });
1147
1176
  }
1148
1177
 
1149
- function capabilityEntry(entry, reason) {
1178
+ // access is the credential's: a Metadata credential is never offered a
1179
+ // sensitive capability; a Debug credential may be, and read commands still
1180
+ // never use one.
1181
+ function capabilityEntry(entry, reason, access = "metadata") {
1150
1182
  if (
1151
1183
  !isObject(entry) ||
1152
1184
  typeof entry.available !== "boolean" ||
@@ -1166,8 +1198,10 @@ function capabilityEntry(entry, reason) {
1166
1198
  entry.privacy_class === "metadata" &&
1167
1199
  entry.required_scope === METADATA_SCOPE;
1168
1200
  // checkCapabilities already refuses this; it is checked again here so a
1169
- // summary can never show a sensitive capability as available.
1170
- if (!metadataOnly && entry.available) throw new Invalid("verification_failed", "content_access_granted");
1201
+ // summary can never show a sensitive capability as available to Metadata.
1202
+ if (!metadataOnly && entry.available && access === "metadata") {
1203
+ throw new Invalid("verification_failed", "content_access_granted");
1204
+ }
1171
1205
  return {
1172
1206
  available: entry.available,
1173
1207
  content: entry.content,
@@ -1194,7 +1228,7 @@ export function capabilitySummary(body, ctx, notices) {
1194
1228
  if (!isObject(body.agent)) throw new Invalid("verification_failed", reason);
1195
1229
  const agent = {};
1196
1230
  for (const name of CAPABILITY_NAMES) {
1197
- agent[name] = Object.hasOwn(body.agent, name) ? capabilityEntry(body.agent[name], reason) : null;
1231
+ agent[name] = Object.hasOwn(body.agent, name) ? capabilityEntry(body.agent[name], reason, ctx.access) : null;
1198
1232
  }
1199
1233
  if (Object.keys(body.agent).some((name) => !CAPABILITY_NAMES.includes(name))) {
1200
1234
  notices.add("unrecognized_capabilities_ignored");
@@ -1,5 +1,6 @@
1
+ import { WORKFLOW_PERMISSIONS } from "./constants.js";
1
2
  import { authMessage } from "./output.js";
2
- import { PLAIN, fields, headline, paragraph, render, steps, toneFor } from "./style.js";
3
+ import { PLAIN, fields, headline, paragraph, render, steps, toneFor, workspaceShown } from "./style.js";
3
4
 
4
5
  // Fixed text for read command reasons and notices. Reasons shared with sign
5
6
  // in and doctor fall back to their wording. Nothing here contains a value from
@@ -7,18 +8,18 @@ import { PLAIN, fields, headline, paragraph, render, steps, toneFor } from "./st
7
8
 
8
9
  const READ_MESSAGES = {
9
10
  not_signed_in:
10
- 'This project is not signed in and no Metadata key was given. Run "metergraph login --runtime local" first, ' +
11
- "or, where browser sign in cannot finish, give the CLI a coding-agent key with Metadata access in " +
12
- "METERGRAPH_AGENT_TOKEN.",
11
+ 'This project is not signed in and no coding-agent key was given. Run "metergraph login --runtime local" first, ' +
12
+ "or, where browser sign in cannot finish, give the CLI a coding-agent key with the Metadata or Debug role " +
13
+ "in METERGRAPH_AGENT_TOKEN.",
13
14
  agent_key_rejected:
14
- "The service refused the Metadata key: it is unknown, expired or revoked. Nothing was read.",
15
+ "The service refused the coding-agent key: it is unknown, expired or revoked. Nothing was read.",
15
16
  agent_key_insufficient_scope:
16
17
  "The key is valid but lacks Metadata access to this workspace, for example because it is an ingest key. " +
17
18
  "No broader access was requested. Nothing was read.",
18
19
  agent_token_malformed:
19
20
  "METERGRAPH_AGENT_TOKEN does not hold exactly one key with no label, spaces or extra lines. Its value is not " +
20
21
  "shown. Nothing was sent.",
21
- agent_token_missing: "No Metadata key was found. Nothing was sent.",
22
+ agent_token_missing: "No coding-agent key was found. Nothing was sent.",
22
23
  metergraph_url_invalid:
23
24
  "METERGRAPH_URL is not a bare https origin, or an http origin on localhost. Its value is not shown. Nothing was sent.",
24
25
  bound_to_other_origin:
@@ -28,13 +29,14 @@ const READ_MESSAGES = {
28
29
  "This project's binding names a non-hosted origin, and a key is never sent to an origin taken only from a " +
29
30
  "project file. Set METERGRAPH_URL or pass --url with the same origin to confirm it. Nothing was sent.",
30
31
  scope_mismatch:
31
- "The credential is not limited to exactly the Metadata scope, so it was not used. The CLI never uses broader access.",
32
+ "The credential has Replay access or a scope this CLI does not use, so it was not used. Use a credential with " +
33
+ "the Metadata or Debug role.",
32
34
  content_access_granted:
33
- "The credential would allow content, replay or provider access, so it was not used. The CLI never uses broader access.",
35
+ "The service offered content, replay or provider access to a Metadata credential, so it was not used.",
34
36
  platform_credential_handoff:
35
37
  "--agent-token-file cannot be checked for privacy on this platform. Use METERGRAPH_AGENT_TOKEN instead.",
36
38
  capability_unavailable:
37
- "The service does not offer this read to the project's Metadata grant. No data was read and no broader access was requested.",
39
+ "The service does not offer this read to this credential. No data was read and no broader access was requested.",
38
40
  exceeds_service_bounds:
39
41
  "The requested --days or --limit is larger than the service allows. It was not reduced automatically. No data was read.",
40
42
  insufficient_scope: "The service refused this read because the grant lacks a required scope. No broader scope was requested.",
@@ -279,7 +281,7 @@ export function readText(result, message, style = PLAIN) {
279
281
  statusRows(data, rows);
280
282
  lines.push(...fields(style, rows));
281
283
  } else {
282
- rows.push(["Workspace", data.workspace?.id ?? null]);
284
+ rows.push(["Workspace", workspaceShown(data.workspace)]);
283
285
  if (result.ok) {
284
286
  const body = [];
285
287
  resultLines(result.command, data.result, body);
@@ -325,16 +327,30 @@ export function readText(result, message, style = PLAIN) {
325
327
  ["Connection guide", handoff ? next.url : null],
326
328
  ]));
327
329
  }
330
+ const access = result.command === "status" && result.ok ? data.access : null;
331
+ const step = access && !allPermissions(access) && access.permissions_offered !== false
332
+ ? permissionsStep(data.deployment_profile)
333
+ : null;
334
+ if (step !== null) lines.push("", ...steps(style, [["", `${PERMISSIONS_NEEDED} ${step}`]]));
328
335
  return render(lines);
329
336
  }
330
337
 
338
+ // The verified workspace by name and slug, and whether it is the bound one.
339
+ function statusWorkspace(workspace) {
340
+ const shown = workspaceShown(workspace);
341
+ if (workspace.actual === null) return workspace.intended === null ? "none" : "bound, not verified";
342
+ const name = shown ?? "verified";
343
+ if (workspace.match === false) return `${name}, which is not the workspace this project is bound to`;
344
+ return workspace.match === true ? `${name}, the bound workspace` : name;
345
+ }
346
+
331
347
  function statusRows(data, rows) {
332
348
  rows.push(
333
349
  ["Configured", data.configured ? "yes" : "no"],
334
- ["Credential", { oauth_grant: "saved sign in", agent_key: "Metadata key" }[data.credential] ?? "none"],
350
+ ["Credential", { oauth_grant: "saved sign in", agent_key: "coding-agent key" }[data.credential] ?? "none"],
335
351
  ["Reachable", yesNo(data.reachable)],
336
352
  ["Authenticated", data.authenticated ? "yes, verified by the service" : "no"],
337
- ["Workspace", `bound ${show(data.workspace.intended)}, verified ${show(data.workspace.actual)}`],
353
+ ["Workspace", statusWorkspace(data.workspace)],
338
354
  ["Deployment profile", `bound ${show(data.deployment_profile)}, reported by the service: ${
339
355
  data.deployment_profile_verified === true
340
356
  ? "same"
@@ -348,12 +364,49 @@ function statusRows(data, rows) {
348
364
  const flag = (name) => yesNo(data.capabilities[name]);
349
365
  rows.push(
350
366
  ["Metadata reads", `usage ${flag("usage")}, routes ${flag("routes")}, traces ${flag("trace_metadata")}`],
351
- ["Content and replay", "not available to this grant"],
367
+ ["Content", data.content_access
368
+ ? "available to this credential; the read commands never request it"
369
+ : "not available to this credential"],
352
370
  );
353
371
  }
372
+ if (data.access) rows.push(["Role", ROLE_LABELS[data.access.role] ?? "none"], ["Permissions", permissionsText(data.access)]);
354
373
  rows.push(["Application traffic verified", "no"]);
355
374
  }
356
375
 
376
+ // The role and permission labels the Keys and consent pages use.
377
+ const ROLE_LABELS = { metadata: "Metadata", debug: "Debug" };
378
+
379
+ const PERMISSIONS_NEEDED =
380
+ "Writing and testing evaluations and starting analyses need the Debug role with Allow evaluation writes, " +
381
+ "Allow evaluation previews and Allow analysis starts.";
382
+
383
+ const allPermissions = (access) =>
384
+ access.role === "debug" && WORKFLOW_PERMISSIONS.every(({ field }) => access.permissions[field] === true);
385
+
386
+ function permissionsText(access) {
387
+ if (access.permissions_offered === false) return "not offered by this deployment";
388
+ const granted = WORKFLOW_PERMISSIONS.filter(({ field }) => access.permissions[field] === true);
389
+ return granted.length > 0 ? granted.map(({ label }) => label).join(", ") : "none";
390
+ }
391
+
392
+ // How a person gives this agent Debug with all three permissions, by
393
+ // deployment. Fixed text: no value from the service is printed.
394
+ export function permissionsStep(profile) {
395
+ if (profile === "local") {
396
+ return "From the bundle directory, a workspace owner runs ./bin/agent-key create --role debug " +
397
+ "--allow-evaluation-writes --allow-evaluation-previews --allow-analysis-starts " +
398
+ "--write-to ~/.config/metergraph/agent.env, then loads that file in the shell that starts the agent.";
399
+ }
400
+ if (profile === "managed") {
401
+ return 'Run "metergraph login --runtime local --role debug --allow-evaluation-writes ' +
402
+ '--allow-evaluation-previews --allow-analysis-starts"; a workspace owner approves it in the browser. ' +
403
+ 'Or a workspace owner creates a coding-agent key on the Keys page, choosing "Debug with all three ' +
404
+ 'permissions" under Access, saved as METERGRAPH_AGENT_TOKEN in a private file outside the project. ' +
405
+ "Do not paste a key into chat.";
406
+ }
407
+ return null;
408
+ }
409
+
357
410
  function resultLines(command, report, lines) {
358
411
  if (command === "context") {
359
412
  const { workspace } = report;
package/src/read.js CHANGED
@@ -2,12 +2,22 @@ import { agentKeySource, keySession, keyTarget, readAgentKey } from "./agent-key
2
2
  import { readBinding, resolveProject } from "./auth-binding.js";
3
3
  import { checkDeployment } from "./doctor.js";
4
4
  import { sessionFor } from "./auth-session.js";
5
- import { Stop, openStore, readCredential, resolveConfigDir } from "./auth-store.js";
6
- import { DEFAULT_ORIGIN, MAX_BODY_BYTES, METADATA_SCOPE, READ_MAX_BYTES, READ_MAX_LIMIT, READ_PATHS } from "./constants.js";
5
+ import { Stop, openStore, readProjectCredential, resolveConfigDir } from "./auth-store.js";
6
+ import {
7
+ DEFAULT_ORIGIN,
8
+ MAX_BODY_BYTES,
9
+ READ_MAX_BYTES,
10
+ READ_MAX_LIMIT,
11
+ READ_PATHS,
12
+ WORKFLOW_DEPLOYMENT_CAPABILITY,
13
+ WORKFLOW_PERMISSIONS,
14
+ } from "./constants.js";
7
15
  import { parseJsonObject } from "./http.js";
8
16
  import {
9
17
  readQuery,
10
18
  groupedRequest,
19
+ workspaceLabel,
20
+ workspacePinned,
11
21
  READ_ENVIRONMENTS,
12
22
  CAPABILITY_NAMES,
13
23
  capabilitySummary,
@@ -20,6 +30,7 @@ import {
20
30
  import { compareReports, priorWindow, rankReport } from "./usage-aggregates.js";
21
31
  import { deadline, failureOf, send, trapSignals } from "./transport.js";
22
32
  import { experimentNextAction, experimentRead } from "./experiments.js";
33
+ import { readArguments, toolsNextAction, toolsRead } from "./tools.js";
23
34
  import { agentKeyAction, classifyEnvironment, handoffPages } from "./environment.js";
24
35
 
25
36
  // The read commands: status, context, capabilities, usage, routes and traces.
@@ -61,6 +72,11 @@ export async function runRead(options) {
61
72
  healthy: null,
62
73
  profileVerified: null,
63
74
  authenticated: false,
75
+ // The credential's access ("metadata" or "debug") and scopes, once verified.
76
+ access: null,
77
+ scopes: [],
78
+ // The verified workspace's slug and name, for output. Never its ID.
79
+ workspaceLabel: { slug: null, name: null },
64
80
  verifiedWorkspace: null,
65
81
  capabilities: null,
66
82
  };
@@ -82,13 +98,16 @@ export async function runRead(options) {
82
98
  if (holdsKnown(data, known)) {
83
99
  notices.clear();
84
100
  ctx.reason = "credential_in_metadata_response";
101
+ // The workspace's name or slug may be what echoed the credential.
102
+ ctx.workspaceLabel = { slug: null, name: null };
85
103
  return {
86
104
  outcome: "verification_failed",
87
105
  reason: "credential_in_metadata_response",
88
106
  data: build("verification_failed", null),
107
+ validateOutput: (text) => !outputHoldsKnown(text, known),
89
108
  };
90
109
  }
91
- return { outcome, reason, data };
110
+ return { outcome, reason, data, validateOutput: (text) => !outputHoldsKnown(text, known) };
92
111
  };
93
112
  // A deadline or Ctrl+C reported by a lower layer as a timeout or
94
113
  // cancellation is reported here by what actually happened.
@@ -102,6 +121,11 @@ export async function runRead(options) {
102
121
  };
103
122
 
104
123
  try {
124
+ if (options.command === "tools" && options.action === "call") {
125
+ const args = readArguments(options.tool);
126
+ if (!args.ok) return end(args.outcome, args.reason);
127
+ options = { ...options, toolArguments: args };
128
+ }
105
129
  const root = resolveProject(options.project);
106
130
  const configDir = resolveConfigDir(options.configDir);
107
131
  const found = readBinding(root);
@@ -116,7 +140,7 @@ export async function runRead(options) {
116
140
  if (binding !== null) {
117
141
  try {
118
142
  store = openStore(configDir, { create: false });
119
- const record = store === null ? null : readCredential(store, binding.credential_slot);
143
+ const record = store === null ? null : readProjectCredential(store, binding.credential_slot, root);
120
144
  grantSaved = record !== null;
121
145
  originConfirmed = record !== null && record.origin === binding.origin;
122
146
  } catch (error) {
@@ -179,18 +203,26 @@ export async function runRead(options) {
179
203
  }
180
204
 
181
205
  const session = useKey
182
- ? await keySession({ target, source, env, signal: limit.signal, verifiedProfile })
183
- : await sessionFor(binding, store, limit.signal);
206
+ ? await keySession({ target, source, env, signal: limit.signal, verifiedProfile, access: "read" })
207
+ : await sessionFor(binding, store, limit.signal, root);
184
208
  if (!session.ok) {
185
209
  const settled = settle(session);
186
210
  return end(settled.outcome, settled.reason);
187
211
  }
188
212
  ctx.profile = session.session.profile;
213
+ ctx.access = session.session.access;
214
+ ctx.scopes = session.session.scopes;
215
+ known = session.knownCredentials;
216
+ ctx.workspaceLabel = workspaceLabel(session.documents.workspace?.workspace);
217
+ // A workspace named by slug or name is checked against the verified one.
218
+ if (target.workspaceText !== null && !workspacePinned(session.documents.workspace?.workspace, target.workspaceText)) {
219
+ return end("verification_failed", "workspace_context_mismatch");
220
+ }
189
221
  if (ctx.workspaceId === null) {
190
222
  ctx.workspaceId = session.session.workspaceId;
191
- notices.add("workspace_not_pinned");
223
+ if (target.workspaceText === null) notices.add("workspace_not_pinned");
224
+ else ctx.intendedWorkspace = ctx.workspaceId;
192
225
  }
193
- known = session.knownCredentials;
194
226
  ctx.authenticated = true;
195
227
  const { documents } = session;
196
228
 
@@ -212,6 +244,15 @@ export async function runRead(options) {
212
244
  return end("ok", null, context.value);
213
245
  }
214
246
  if (options.command === "capabilities") return end("ok", null, capabilities.value);
247
+ if (options.command === "tools") {
248
+ const tools = await toolsRead(session.session, options, limit.signal);
249
+ if (!tools.ok) {
250
+ if (ctx.credential === "agent_key" && tools.reason === "access_revoked") tools.reason = "agent_key_rejected";
251
+ const settled = ["refused", "unconfirmed", "action_required"].includes(tools.outcome) ? tools : settle(tools);
252
+ return end(settled.outcome, settled.reason, tools.report);
253
+ }
254
+ return end("ok", null, tools.value);
255
+ }
215
256
  if (options.command === "experiments") {
216
257
  const read = await experimentRead(session.session, options, ctx, documents, limit.signal, notices);
217
258
  if (!read.ok) {
@@ -351,7 +392,7 @@ export function holdsKnown(value, known) {
351
392
 
352
393
  const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
353
394
 
354
- function retryAfterOf(response) {
395
+ export function retryAfterOf(response) {
355
396
  const header = response.headers["retry-after"];
356
397
  const seconds = typeof header === "string" && RETRY_AFTER.test(header.trim()) ? Number(header.trim()) : null;
357
398
  return seconds !== null && seconds <= 86400 ? seconds : null;
@@ -405,16 +446,18 @@ async function fetchRead(session, path, query, signal) {
405
446
  function readData(ctx, report, notices, outcome, options, extra = {}) {
406
447
  return {
407
448
  origin: ctx.origin,
408
- workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId },
449
+ workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId, ...ctx.workspaceLabel },
409
450
  deployment_profile: ctx.profile,
410
451
  environment: ctx.environment.kind,
411
452
  credential: ctx.credential,
412
453
  authenticated: ctx.authenticated,
413
- scopes: ctx.authenticated ? [METADATA_SCOPE] : [],
454
+ scopes: ctx.authenticated ? ctx.scopes : [],
414
455
  result: outcome === "ok" ? report : null,
415
456
  retry_after_seconds: outcome === "rate_limited" || report?.retry_after_seconds !== undefined
416
457
  ? report?.retry_after_seconds ?? null : null,
417
458
  ...(options.command === "experiments" ? { refusal: outcome === "refused" ? report?.refusal ?? null : null } : {}),
459
+ // A tool's own error document, as the service returned it.
460
+ ...(options.command === "tools" ? { tool_error: outcome === "refused" ? report?.tool_error ?? null : null } : {}),
418
461
  notices: [...notices].sort(),
419
462
  next_action: nextAction(outcome, ctx, options, extra),
420
463
  };
@@ -446,17 +489,38 @@ function statusData(ctx, notices, outcome, options) {
446
489
  ctx.verifiedWorkspace === null || ctx.intendedWorkspace === null
447
490
  ? null
448
491
  : ctx.verifiedWorkspace === ctx.intendedWorkspace,
492
+ // The verified workspace's slug and name, which output shows instead
493
+ // of either ID.
494
+ ...ctx.workspaceLabel,
449
495
  },
450
- scopes: ctx.authenticated ? [METADATA_SCOPE] : [],
496
+ scopes: ctx.authenticated ? ctx.scopes : [],
451
497
  capabilities:
452
498
  agent === null ? null : Object.fromEntries(CAPABILITY_NAMES.map((name) => [name, agent[name]?.available ?? null])),
453
- content_access: false,
499
+ // Whether this credential may read content through Agent Access. The
500
+ // read commands never do, whatever it allows.
501
+ content_access: agent !== null && Object.values(agent).some((entry) => entry?.available === true && entry.content === true),
502
+ access: accessFor(ctx),
454
503
  application_traffic_verified: false,
455
504
  notices: [...notices].sort(),
456
505
  next_action: nextAction(outcome, ctx, options),
457
506
  };
458
507
  }
459
508
 
509
+ // The credential's role and workflow permissions, by the names the Keys page
510
+ // uses. Either role reads. Writing and testing evaluations and starting
511
+ // analyses need Debug with all three permissions, on a deployment that offers
512
+ // managed evaluations: permissions_offered is false where it does not, and
513
+ // null when the capabilities were not read.
514
+ export function accessFor(ctx) {
515
+ if (!ctx.authenticated) return null;
516
+ const deployment = ctx.capabilities?.deployment_capabilities ?? null;
517
+ return {
518
+ role: ctx.access,
519
+ permissions: Object.fromEntries(WORKFLOW_PERMISSIONS.map(({ field, scope }) => [field, ctx.scopes.includes(scope)])),
520
+ permissions_offered: deployment === null ? null : deployment.includes(WORKFLOW_DEPLOYMENT_CAPABILITY),
521
+ };
522
+ }
523
+
460
524
  const NARROW =
461
525
  "Narrow the read: a shorter --days or --since/--until window, exact --route, --provider or --model filters, " +
462
526
  "fewer --group-by dimensions or a lower --limit.";
@@ -474,6 +538,8 @@ function nextAction(outcome, ctx, options, { grouped = false, report = null } =
474
538
  if (outcome === "rate_limited") return { kind: "retry_later", message: "Wait, then run the same command again." };
475
539
  const experiment = experimentNextAction(outcome, ctx, options, report);
476
540
  if (experiment !== undefined) return experiment;
541
+ const tool = toolsNextAction(ctx, options);
542
+ if (tool !== undefined) return tool;
477
543
  if (reason === "usage_aggregates_unavailable" || (grouped && reason === "endpoint_unavailable")) {
478
544
  // Offer the route-by-day read only when this grant actually has it.
479
545
  if (capabilities?.agent?.usage?.available !== true) return null;
@@ -527,8 +593,8 @@ function nextAction(outcome, ctx, options, { grouped = false, report = null } =
527
593
  kind: "use_metadata_only_key",
528
594
  url: handoffPages(origin).keys_url,
529
595
  message:
530
- "This key grants more than Metadata access, so the CLI does not use it. Ask the person for a " +
531
- "coding-agent key limited to Metadata access.",
596
+ "This key has Replay access or another scope the CLI does not use. Ask the person for a coding-agent " +
597
+ "key with the Metadata or Debug role, and to replace the secret.",
532
598
  };
533
599
  }
534
600
  if (keyed && reason === "workspace_context_mismatch") {
@@ -575,3 +641,12 @@ function nextAction(outcome, ctx, options, { grouped = false, report = null } =
575
641
  }
576
642
  return null;
577
643
  }
644
+
645
+ // The final renderer may join independent service fields or insert styling.
646
+ // Check exact emitted bytes and their terminal-visible equivalent.
647
+ export function outputHoldsKnown(text, known) {
648
+ const visible = (value) => value.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
649
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
650
+ .replace(/[\x00-\x1f\x7f-\x9f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "").normalize("NFKC");
651
+ return holdsKnown(text, known) || known.some((secret) => visible(text).includes(visible(secret)));
652
+ }
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import fs from "node:fs";
2
+ import fs, { withRollback } from "./anchored-fs.js";
3
3
  import path from "node:path";
4
4
 
5
5
  // Repository identity for the SDKs: `.metergraph/config.json` with
@@ -157,7 +157,7 @@ function writeNewConfig(root, repository) {
157
157
  try { fs.writeFileSync(fd, content); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
158
158
  fs.linkSync(temp, dest);
159
159
  } finally {
160
- try { fs.unlinkSync(temp); } catch { /* already gone */ }
160
+ try { withRollback(() => fs.unlinkSync(temp)); } catch { /* already gone */ }
161
161
  }
162
162
  }
163
163
 
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import fs from "node:fs";
2
+ import fs from "./anchored-fs.js";
3
3
  import path from "node:path";
4
4
 
5
5
  import { Stop } from "./auth-store.js";
package/src/setup-env.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import fs from "node:fs";
2
+ import fs, { validatePath, withRollback } from "./anchored-fs.js";
3
3
  import path from "node:path";
4
4
 
5
5
  import { resolveProject } from "./auth-binding.js";
@@ -62,6 +62,8 @@ const PLANS = new WeakMap();
62
62
  const HANDLES = new WeakMap();
63
63
 
64
64
  export const ENV_MESSAGES = Object.freeze({
65
+ secure_writes_unavailable:
66
+ "Secure automatic file writes are unavailable on this platform or the bundled filesystem helper failed verification. Nothing was written.",
65
67
  invalid_project: "--project must name an existing directory.",
66
68
  env_path_invalid:
67
69
  "The env file path is not allowed. Use a project-relative path with forward slashes to a file named .env, .env.<name> or <name>.env.",
@@ -193,12 +195,12 @@ export async function rollbackEnv(handle, { signal = null, waitMs = DEFAULT_WAIT
193
195
  if (state === undefined) throw new Stop("internal_error", "invalid_plan");
194
196
  checkCancel(signal);
195
197
  if (state.journal.length > 0) {
196
- const lock = await takeLock(state.lockDir, waitMs, signal);
198
+ const lock = await takeLock(state.lockDir, waitMs, signal, true);
197
199
  try {
198
200
  checkCancel(signal);
199
201
  deferSignals(() => {
200
202
  try {
201
- undo(state.journal);
203
+ withRollback(() => undo(state.journal));
202
204
  } catch (error) {
203
205
  if (error instanceof Stop && error.outcome === "conflict") throw new Stop("conflict", "rollback_conflict");
204
206
  throw new Stop("filesystem_error", "rollback_incomplete");
@@ -352,6 +354,8 @@ function apply(state, updates) {
352
354
  }
353
355
  }
354
356
 
357
+ validatePath(target.dir);
358
+ if (ignore !== null) validatePath(state.root);
355
359
  const receipt = freeze({
356
360
  path: target.rel,
357
361
  location: target.location,
@@ -363,7 +367,7 @@ function apply(state, updates) {
363
367
  return { receipt, journal };
364
368
  } catch (error) {
365
369
  try {
366
- undo(journal);
370
+ withRollback(() => undo(journal));
367
371
  } catch {
368
372
  throw new Stop("filesystem_error", "rollback_incomplete");
369
373
  }
@@ -575,7 +579,7 @@ function replaceFile(file, content, { before, mode, protect, reasons, changed })
575
579
  } catch (error) {
576
580
  if (created) {
577
581
  try {
578
- fs.unlinkSync(temp);
582
+ withRollback(() => fs.unlinkSync(temp));
579
583
  } catch {
580
584
  // Reported by the caller's failure; nothing else to do.
581
585
  }
@@ -602,7 +606,7 @@ function syncDir(dir) {
602
606
  // An exclusive lock file in the project (or, outside a project, in the env
603
607
  // file's directory). A held lock is waited on for at most waitMs, then
604
608
  // reported; it is never taken over or removed, even if it looks stale.
605
- async function takeLock(dir, waitMs, signal) {
609
+ async function takeLock(dir, waitMs, signal, rollback = false) {
606
610
  const file = path.join(dir, LOCK);
607
611
  const wait = Number.isFinite(waitMs) ? Math.min(Math.max(waitMs, 0), MAX_WAIT_MS) : DEFAULT_WAIT_MS;
608
612
  const until = Date.now() + wait;
@@ -610,7 +614,7 @@ async function takeLock(dir, waitMs, signal) {
610
614
  checkCancel(signal);
611
615
  let fd;
612
616
  try {
613
- fd = fs.openSync(file, "wx", 0o600);
617
+ fd = rollback ? withRollback(() => fs.openSync(file, "wx", 0o600)) : fs.openSync(file, "wx", 0o600);
614
618
  } catch (error) {
615
619
  if (error.code !== "EEXIST") throw fixed(error, "env_write_failed");
616
620
  if (Date.now() >= until) throw new Stop("conflict", "env_locked");
@@ -629,8 +633,10 @@ async function takeLock(dir, waitMs, signal) {
629
633
  // Removes the lock only if it is still the file this process created.
630
634
  function dropLock(lock) {
631
635
  try {
632
- const stat = fs.lstatSync(lock.file);
633
- if (IS_WINDOWS || (stat.dev === lock.dev && stat.ino === lock.ino)) fs.unlinkSync(lock.file);
636
+ withRollback(() => {
637
+ const stat = fs.lstatSync(lock.file);
638
+ if (stat.dev === lock.dev && stat.ino === lock.ino) fs.unlinkSync(lock.file);
639
+ });
634
640
  } catch {
635
641
  // Reported by the next run as a held lock.
636
642
  }
@@ -670,6 +676,7 @@ function deferSignals(work) {
670
676
  // error, which can hold a path, is never passed on.
671
677
  function fixed(error, reason) {
672
678
  if (error instanceof Stop) return error;
679
+ if (error?.code === "ENOTSUP") return new Stop("unsupported", "secure_writes_unavailable");
673
680
  if (DENIED.has(error?.code)) {
674
681
  return new Stop("filesystem_error", reason === "env_read_failed" ? "env_read_denied" : "env_write_denied");
675
682
  }
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { spawn } from "node:child_process";
5
5
  import { createInterface } from "node:readline/promises";
6
6
 
7
+ import { parseArgs } from "./args.js";
7
8
  import { resolveProject } from "./auth-binding.js";
8
9
  import { runDoctor } from "./doctor.js";
9
10
  import { classifyEnvironment, handoffPages, shellArg } from "./environment.js";
@@ -50,8 +51,18 @@ const WORKSPACE_QUESTIONS = {
50
51
  oss: "Workspace ID",
51
52
  };
52
53
 
53
- export function canAsk(argv, { stdin, stdout, env = process.env }) {
54
- return !argv.includes("--json") && Boolean(stdin?.isTTY) && Boolean(stdout?.isTTY) && !env.CI;
54
+ export function canAsk(argv, { stdin, stdout, stderr = stdout, env = process.env }) {
55
+ return !argv.includes("--json") && Boolean(stdin?.isTTY) && Boolean(stdout?.isTTY)
56
+ && Boolean(stderr?.isTTY) && !classifyEnvironment(env).remote;
57
+ }
58
+
59
+ // Login needs no client or deployment questions. Only supply the local runtime
60
+ // after the rest of the command validates; callers gate this on canAsk.
61
+ export function fillLoginArgv(argv) {
62
+ const parsed = parseArgs(argv);
63
+ if (parsed.command !== "login" || parsed.code !== "missing_runtime") return argv;
64
+ const candidate = [...argv, "--runtime", "local"];
65
+ return parseArgs(candidate).ok ? candidate : argv;
55
66
  }
56
67
 
57
68
  // Questions on the terminal. ask resolves with the trimmed answer, or null
@@ -145,7 +156,10 @@ export async function fillSetupArgv(argv, {
145
156
  }
146
157
 
147
158
  if (!has("--client") && !has("--skip-skill")) {
148
- const client = await choose(ask, say, "Install the Metergraph skill for which coding agent?", CLIENTS, "claude");
159
+ const client = await choose(ask, say, "Install the Metergraph skill for which coding agent?", CLIENTS, "claude", {
160
+ ...Object.fromEntries(CLIENTS.map(([value, label]) => [label.toLowerCase(), value])),
161
+ "skip skill installation": null,
162
+ });
149
163
  if (client === undefined) return null;
150
164
  if (client === null) added.push("--skip-skill");
151
165
  else added.push("--client", client);
@@ -167,13 +181,14 @@ function valueOf(argv, flag) {
167
181
 
168
182
  // A numbered menu. Resolves with the chosen value, or undefined on cancel
169
183
  // (null is a valid choice: no client).
170
- async function choose(ask, say, question, options, fallback) {
184
+ async function choose(ask, say, question, options, fallback, aliases = {}) {
171
185
  say("", question);
172
186
  options.forEach(([, label], index) => say(` ${index + 1}) ${label}`));
173
187
  const fallbackIndex = options.findIndex(([value]) => value === fallback) + 1;
174
188
  for (;;) {
175
189
  const answer = await ask(`Choose 1-${options.length} [${fallbackIndex}]: `);
176
190
  if (answer === null) return undefined;
191
+ if (Object.hasOwn(aliases, answer.toLowerCase())) return aliases[answer.toLowerCase()];
177
192
  const index = answer === "" ? fallbackIndex : Number(answer);
178
193
  if (Number.isInteger(index) && index >= 1 && index <= options.length) return options[index - 1][0];
179
194
  say(`Enter a number from 1 to ${options.length}.`);