metergraph-cli 0.2.2 → 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 (66) hide show
  1. package/README.md +121 -13
  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/bin/metergraph.js +1 -1
  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 +22 -12
  28. package/src/anchored-fs.js +163 -0
  29. package/src/args.js +335 -31
  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 +133 -36
  34. package/src/auth-session.js +19 -12
  35. package/src/auth-store.js +28 -7
  36. package/src/cli.js +147 -15
  37. package/src/constants.js +70 -1
  38. package/src/experiments-contract.js +462 -0
  39. package/src/experiments-output.js +190 -0
  40. package/src/experiments.js +406 -0
  41. package/src/output.js +374 -68
  42. package/src/pricing-args.js +163 -0
  43. package/src/pricing-output.js +206 -0
  44. package/src/pricing-reference.js +195 -0
  45. package/src/pricing.js +307 -0
  46. package/src/read-contract.js +64 -10
  47. package/src/read-output.js +66 -13
  48. package/src/read.js +105 -16
  49. package/src/repository-identity.js +2 -2
  50. package/src/setup-deployment.js +7 -0
  51. package/src/setup-env-git.js +1 -1
  52. package/src/setup-env.js +16 -9
  53. package/src/setup-interactive.js +356 -0
  54. package/src/setup-refusal.js +2 -2
  55. package/src/setup-state.js +9 -4
  56. package/src/setup.js +15 -3
  57. package/src/skill-bundle.js +2 -2
  58. package/src/skill-trusted.js +56 -0
  59. package/src/skill.js +28 -9
  60. package/src/style.js +33 -7
  61. package/src/tools-output.js +73 -0
  62. package/src/tools.js +249 -0
  63. package/src/trace-contract.js +1 -1
  64. package/src/transport.js +26 -3
  65. package/src/verify-output.js +2 -2
  66. package/src/verify.js +5 -2
package/src/auth-oauth.js CHANGED
@@ -1,6 +1,14 @@
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
+ DEBUG_SCOPE,
7
+ EXPERIMENT_START_KEY_SCOPES,
8
+ EXPERIMENT_START_SCOPE,
9
+ METADATA_SCOPE,
10
+ WORKFLOW_SCOPES,
11
+ } from "./constants.js";
4
12
  import { parseJsonObject } from "./http.js";
5
13
  import { failureOf, send } from "./transport.js";
6
14
 
@@ -28,6 +36,7 @@ const MAX_EXPIRES_IN = 366 * 24 * 60 * 60;
28
36
  // metadata and in the setup discovery document. Without it the CLI registers
29
37
  // a new public client for each sign in, as servers without the field expect.
30
38
  export const CLI_CLIENT_ID_FIELD = "metergraph_cli_client_id";
39
+ export const WORKSPACE_HINT_FIELD = "metergraph_workspace_hint_supported";
31
40
 
32
41
  // The access token claim that names the workspace the grant is for. The
33
42
  // claim is a sanity check only; the CLI does not verify token signatures.
@@ -54,7 +63,7 @@ const stop = (outcome, reason) => ({ ok: false, outcome, reason });
54
63
  // fixed path on this origin. A server that does not advertise the Metadata
55
64
  // scope, S256 or public clients is unsupported; there is no fallback to
56
65
  // another scope.
57
- export async function discover(origin, signal) {
66
+ export async function discover(origin, signal, baseScope = METADATA_SCOPE) {
58
67
  const expected = endpointsFor(origin);
59
68
 
60
69
  const prm = await fetchMetadata(origin, [
@@ -74,6 +83,9 @@ export async function discover(origin, signal) {
74
83
  if (resource.scopes_supported !== undefined && !includes(resource.scopes_supported, METADATA_SCOPE)) {
75
84
  return stop("unsupported", "metadata_scope_unsupported");
76
85
  }
86
+ if (resource.scopes_supported !== undefined && !includes(resource.scopes_supported, baseScope)) {
87
+ return stop("unsupported", "debug_scope_unsupported");
88
+ }
77
89
  if (resource.bearer_methods_supported !== undefined && !includes(resource.bearer_methods_supported, "header")) {
78
90
  return stop("unsupported", "oauth_metadata_invalid");
79
91
  }
@@ -97,6 +109,7 @@ export async function discover(origin, signal) {
97
109
  if (!includes(server.scopes_supported, METADATA_SCOPE)) {
98
110
  return stop("unsupported", "metadata_scope_unsupported");
99
111
  }
112
+ if (!includes(server.scopes_supported, baseScope)) return stop("unsupported", "debug_scope_unsupported");
100
113
  if (!includes(server.code_challenge_methods_supported, "S256")) return stop("unsupported", "pkce_unsupported");
101
114
  if (!includes(server.token_endpoint_auth_methods_supported, "none")) {
102
115
  return stop("unsupported", "public_client_unsupported");
@@ -122,6 +135,9 @@ export async function discover(origin, signal) {
122
135
  endpoints: expected,
123
136
  requireIss: server.authorization_response_iss_parameter_supported === true,
124
137
  offeredClientId: offered,
138
+ // The consent page can match a workspace the person named by slug or
139
+ // name, and offers approval for that workspace alone.
140
+ workspaceHint: server[WORKSPACE_HINT_FIELD] === true,
125
141
  };
126
142
  }
127
143
 
@@ -137,9 +153,9 @@ export function offeredClientId(doc) {
137
153
  // The client for one sign in: the pre-registered client the service offered,
138
154
  // or a newly registered one when it offered none. The offered client accepts
139
155
  // the loopback redirect on any port, so it needs no registration request.
140
- export async function clientFor(endpoints, offered, redirectUri, signal) {
156
+ export async function clientFor(endpoints, offered, redirectUri, signal, scopes = [METADATA_SCOPE]) {
141
157
  if (offered !== null) return { ok: true, clientId: offered };
142
- return register(endpoints, redirectUri, signal);
158
+ return register(endpoints, redirectUri, signal, scopes);
143
159
  }
144
160
 
145
161
  async function fetchMetadata(origin, paths, signal) {
@@ -164,7 +180,7 @@ function includes(list, value) {
164
180
  // Dynamic client registration for a public client bound to one exact
165
181
  // loopback redirect. A response that carries a client secret, a different
166
182
  // redirect or another authentication method is refused.
167
- export async function register(endpoints, redirectUri, signal) {
183
+ export async function register(endpoints, redirectUri, signal, scopes = [METADATA_SCOPE]) {
168
184
  const response = await send(endpoints.origin, AUTH_PATHS.registration, {
169
185
  method: "POST",
170
186
  signal,
@@ -175,7 +191,7 @@ export async function register(endpoints, redirectUri, signal) {
175
191
  grant_types: ["authorization_code", "refresh_token"],
176
192
  response_types: ["code"],
177
193
  token_endpoint_auth_method: "none",
178
- scope: METADATA_SCOPE,
194
+ scope: scopes.join(" "),
179
195
  },
180
196
  });
181
197
  const failure = failureOf(response);
@@ -229,13 +245,18 @@ export function newPkce() {
229
245
  // also approve the first ingest key for a new setup family. The service
230
246
  // returns that receipt next to the code; it is redeemed separately with its
231
247
  // own PKCE verifier.
232
- export function authorizationUrl(endpoints, { clientId, redirectUri, state, challenge, signup, ingestSetup = null }) {
248
+ // scopes is the access asked for: Metadata, or Debug with the chosen workflow
249
+ // permissions. workspaceHint, when given, is the workspace the person named,
250
+ // sent only to a service that advertises it.
251
+ export function authorizationUrl(endpoints, {
252
+ clientId, redirectUri, state, challenge, signup, ingestSetup = null, scopes = [METADATA_SCOPE], workspaceHint = null,
253
+ }) {
233
254
  const url = new URL(endpoints.authorization);
234
255
  url.search = new URLSearchParams({
235
256
  response_type: "code",
236
257
  client_id: clientId,
237
258
  redirect_uri: redirectUri,
238
- scope: METADATA_SCOPE,
259
+ scope: scopes.join(" "),
239
260
  state,
240
261
  code_challenge: challenge,
241
262
  code_challenge_method: "S256",
@@ -245,6 +266,7 @@ export function authorizationUrl(endpoints, { clientId, redirectUri, state, chal
245
266
  ingest_setup_family_id: ingestSetup.familyId,
246
267
  ingest_setup_code_challenge: ingestSetup.challenge,
247
268
  }),
269
+ ...(workspaceHint === null ? {} : { workspace_hint: workspaceHint }),
248
270
  }).toString();
249
271
  if (!signup) return url.href;
250
272
  const entry = new URL(`${endpoints.origin}${AUTH_PATHS.signup}`);
@@ -254,7 +276,7 @@ export function authorizationUrl(endpoints, { clientId, redirectUri, state, chal
254
276
 
255
277
  // Exchanges an authorization code. Codes are single use, so there is never
256
278
  // a retry.
257
- export async function exchangeCode(endpoints, { clientId, redirectUri, code, verifier }, signal) {
279
+ export async function exchangeCode(endpoints, { clientId, redirectUri, code, verifier, scopes = [METADATA_SCOPE] }, signal) {
258
280
  const response = await send(endpoints.origin, AUTH_PATHS.token, {
259
281
  method: "POST",
260
282
  signal,
@@ -272,13 +294,13 @@ export async function exchangeCode(endpoints, { clientId, redirectUri, code, ver
272
294
  if (failure !== null) return { ok: false, ...failure };
273
295
  if (response.status === 400 || response.status === 401) return stop("authorization_failed", "code_rejected");
274
296
  if (response.status !== 200) return stop("unsupported", "unexpected_status");
275
- return validateGrant(parseJsonObject(response), endpoints, clientId, { requireRefresh: true });
297
+ return validateGrant(parseJsonObject(response), endpoints, clientId, { requireRefresh: true, scopes });
276
298
  }
277
299
 
278
300
  // Refreshes a grant once. The result says whether the refresh token may have
279
301
  // been consumed without the CLI learning its replacement (ambiguous), so the
280
302
  // caller never retries with it.
281
- export async function refreshGrant(endpoints, { clientId, refreshToken }, signal) {
303
+ export async function refreshGrant(endpoints, { clientId, refreshToken, scopes = [METADATA_SCOPE] }, signal) {
282
304
  const response = await send(endpoints.origin, AUTH_PATHS.token, {
283
305
  method: "POST",
284
306
  signal,
@@ -288,7 +310,7 @@ export async function refreshGrant(endpoints, { clientId, refreshToken }, signal
288
310
  refresh_token: refreshToken,
289
311
  client_id: clientId,
290
312
  resource: endpoints.resource,
291
- scope: METADATA_SCOPE,
313
+ scope: scopes.join(" "),
292
314
  },
293
315
  });
294
316
  if (response.kind === "error" && !response.sent) {
@@ -306,16 +328,17 @@ export async function refreshGrant(endpoints, { clientId, refreshToken }, signal
306
328
  if (response.kind !== "response" || response.status !== 200 || response.tooLarge) {
307
329
  return { ok: false, outcome: "login_required", reason: "refresh_interrupted", ambiguous: true };
308
330
  }
309
- const result = validateGrant(parseJsonObject(response), endpoints, clientId, { requireRefresh: false });
331
+ const result = validateGrant(parseJsonObject(response), endpoints, clientId, { requireRefresh: false, scopes });
310
332
  // A 200 that cannot be used may still have rotated the refresh token.
311
333
  return result.ok ? result : { ...result, ambiguous: true };
312
334
  }
313
335
 
314
- // Accepts only a Bearer grant for exactly the Metadata scope with bounded,
315
- // non-empty tokens and an expiry. The access token claims must name this
316
- // issuer, resource and client and one workspace. These are sanity checks on
317
- // what the server said, not a signature check.
318
- export function validateGrant(body, endpoints, clientId, { requireRefresh }) {
336
+ // Accepts only a Bearer grant for exactly the scopes asked for (Metadata, or
337
+ // Debug with the chosen workflow permissions) with bounded, non-empty tokens and an expiry. The
338
+ // access token claims must name this issuer, resource and client and one
339
+ // workspace. These are sanity checks on what the server said, not a
340
+ // signature check.
341
+ export function validateGrant(body, endpoints, clientId, { requireRefresh, scopes = [METADATA_SCOPE] }) {
319
342
  if (body === null) return stop("verification_failed", "token_response_invalid");
320
343
  if (typeof body.token_type !== "string" || body.token_type.toLowerCase() !== "bearer") {
321
344
  return stop("verification_failed", "token_type_invalid");
@@ -334,9 +357,7 @@ export function validateGrant(body, endpoints, clientId, { requireRefresh }) {
334
357
  if (!Number.isInteger(body.expires_in) || body.expires_in < 1 || body.expires_in > MAX_EXPIRES_IN) {
335
358
  return stop("verification_failed", "token_response_invalid");
336
359
  }
337
- if (typeof body.scope !== "string" || body.scope !== METADATA_SCOPE) {
338
- return stop("verification_failed", "scope_mismatch");
339
- }
360
+ if (!sameScopes(body.scope, scopes)) return stop("verification_failed", "scope_mismatch");
340
361
 
341
362
  const claims = decodeClaims(body.access_token);
342
363
  if (claims === null) return stop("verification_failed", "token_claims_invalid");
@@ -344,7 +365,7 @@ export function validateGrant(body, endpoints, clientId, { requireRefresh }) {
344
365
  const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
345
366
  if (!audience.includes(endpoints.resource)) return stop("verification_failed", "resource_mismatch");
346
367
  if (claims.client_id !== clientId) return stop("verification_failed", "client_mismatch");
347
- if (claims.scope !== undefined && claims.scope !== METADATA_SCOPE) {
368
+ if (claims.scope !== undefined && !sameScopes(claims.scope, scopes)) {
348
369
  return stop("verification_failed", "scope_mismatch");
349
370
  }
350
371
  const tenant = normalizeUuid(claims[TENANT_CLAIM]);
@@ -365,6 +386,13 @@ export function validateGrant(body, endpoints, clientId, { requireRefresh }) {
365
386
  };
366
387
  }
367
388
 
389
+ // Whether a space-separated scope string names exactly these scopes.
390
+ export function sameScopes(value, scopes) {
391
+ if (typeof value !== "string") return false;
392
+ const given = value.split(" ");
393
+ return given.length === scopes.length && new Set(given).size === given.length && scopes.every((scope) => given.includes(scope));
394
+ }
395
+
368
396
  function decodeClaims(token) {
369
397
  const parts = token.split(".");
370
398
  if (parts.length !== 3 || !/^[A-Za-z0-9_-]+$/.test(parts[1])) return null;
@@ -378,32 +406,53 @@ function decodeClaims(token) {
378
406
  }
379
407
  }
380
408
 
409
+ // What a credential's scopes amount to: "metadata" for exactly the Metadata
410
+ // scope, "debug" for Debug with any of the workflow permissions, or null for
411
+ // anything else, including Replay.
412
+ export function credentialAccess(scopes) {
413
+ if (!Array.isArray(scopes) || !scopes.every((scope) => typeof scope === "string")) return null;
414
+ if (new Set(scopes).size !== scopes.length) return null;
415
+ if (scopes.length === 1 && scopes[0] === METADATA_SCOPE) return "metadata";
416
+ if (!scopes.includes(DEBUG_SCOPE)) return null;
417
+ return scopes.every((scope) => scope === DEBUG_SCOPE || WORKFLOW_SCOPES.includes(scope)) ? "debug" : null;
418
+ }
419
+
381
420
  // Asks the service, with the access token, which workspace and profile the
382
421
  // grant is for and what it may do. The answers are server-authoritative:
383
422
  // GET /v1/agent/workspace: workspace.id, provenance.workspace_id and the
384
423
  // expected workspace must agree, provenance.deployment_profile must be
385
- // the profile seen before sign in, access.scopes must be exactly the
386
- // Metadata scope and content.included must be false. Capture being
424
+ // the profile seen before sign in, access.scopes must be what this use
425
+ // accepts (below) and content.included must be false. Capture being
387
426
  // enabled for the workspace (content.captured) is not a reason to refuse.
388
427
  // GET /v1/agent/capabilities: provenance.workspace_id and both
389
- // provenance.deployment_profile and deployment_profile must match, and
390
- // every capability that reads content or replays, or calls a configured
391
- // provider, must be unavailable to this grant.
428
+ // provenance.deployment_profile and deployment_profile must match. For a
429
+ // Metadata credential, every capability that reads content or replays,
430
+ // or calls a configured provider, must be unavailable.
392
431
  // Nothing else is read: no telemetry, content or provider requests.
393
- // Returns { ok: true, workspaceId, documents } or { ok: false, outcome, reason, denied }
394
- // where denied means the service refused the token (401 or 403).
432
+ // Returns { ok: true, workspaceId, access, scopes, documents } or
433
+ // { ok: false, outcome, reason, denied } where denied means the service
434
+ // refused the token (401 or 403).
395
435
  // workspaceId is the workspace the credential must be for. Only an agent key
396
436
  // with nothing to pin it to passes anyWorkspace: true with workspaceId null,
397
437
  // and then the workspace the service reports is checked for consistency and
398
438
  // returned as the verified one.
399
- export async function verifyContext(origin, accessToken, { profile, workspaceId, anyWorkspace = false }, signal) {
439
+ // access says what this use accepts:
440
+ // "metadata": exactly the Metadata scope (sign in).
441
+ // "read": Metadata, or Debug with any workflow permissions, which a person
442
+ // grants for writing evaluations and starting analyses (the read
443
+ // commands). Replay is refused.
444
+ // A read command still sends only its own Metadata requests.
445
+ // "experiment_start": Debug with the start grant (experiments start).
446
+ export async function verifyContext(origin, accessToken, { profile, workspaceId, anyWorkspace = false, access = "metadata" }, signal) {
400
447
  const workspace = await bearerJson(origin, AUTH_PATHS.workspace, accessToken, WORKSPACE_BYTES, signal, "workspace");
401
448
  if (!workspace.ok) return workspace;
402
449
  if (workspaceId === null && anyWorkspace === true) {
403
450
  workspaceId = normalizeUuid(workspace.body.workspace?.id);
404
451
  if (workspaceId === null) return stop("verification_failed", "workspace_response_invalid");
405
452
  }
406
- const checked = checkWorkspace(workspace.body, { profile, workspaceId });
453
+ const checked = access === "experiment_start"
454
+ ? checkStartWorkspace(workspace.body, { profile, workspaceId })
455
+ : checkWorkspace(workspace.body, { profile, workspaceId, debug: access === "read" });
407
456
  if (!checked.ok) return checked;
408
457
 
409
458
  const capabilities = await bearerJson(
@@ -415,17 +464,26 @@ export async function verifyContext(origin, accessToken, { profile, workspaceId,
415
464
  "capabilities",
416
465
  );
417
466
  if (!capabilities.ok) return capabilities;
418
- const allowed = checkCapabilities(capabilities.body, { profile, workspaceId });
467
+ const allowed = access === "experiment_start"
468
+ ? checkStartCapabilities(capabilities.body, { profile, workspaceId })
469
+ : checkCapabilities(capabilities.body, { profile, workspaceId, access: checked.access });
419
470
  if (!allowed.ok) return allowed;
420
471
  // The verified documents stay in memory for read commands, which print
421
472
  // only fields they validate themselves.
422
- return { ok: true, workspaceId, documents: { workspace: workspace.body, capabilities: capabilities.body } };
473
+ return {
474
+ ok: true,
475
+ workspaceId,
476
+ access: checked.access,
477
+ scopes: checked.scopes,
478
+ documents: { workspace: workspace.body, capabilities: capabilities.body },
479
+ };
423
480
  }
424
481
 
425
482
  // Both documents carry the service's contract version string in
426
483
  // schema_version. Any other value, including a number, is a contract this
427
484
  // CLI does not know.
428
- export function checkWorkspace(body, { profile, workspaceId }) {
485
+ // debug: true accepts Debug with workflow permissions as well as Metadata.
486
+ export function checkWorkspace(body, { profile, workspaceId, debug = false }) {
429
487
  const id = normalizeUuid(body.workspace?.id);
430
488
  if (body.schema_version !== AGENT_CONTRACT_VERSION || id === null || !isObject(body.provenance)) {
431
489
  return stop("verification_failed", "workspace_response_invalid");
@@ -435,16 +493,51 @@ export function checkWorkspace(body, { profile, workspaceId }) {
435
493
  }
436
494
  if (body.provenance.deployment_profile !== profile) return stop("verification_failed", "profile_mismatch");
437
495
  const scopes = body.access?.scopes;
438
- if (!Array.isArray(scopes) || scopes.length !== 1 || scopes[0] !== METADATA_SCOPE) {
496
+ const access = credentialAccess(scopes);
497
+ if (access === null || (access === "debug" && !debug)) {
439
498
  return stop("verification_failed", "scope_mismatch");
440
499
  }
441
500
  if (!isObject(body.content) || body.content.included !== false) {
442
501
  return stop("verification_failed", "content_access_granted");
443
502
  }
503
+ return { ok: true, access, scopes: [...scopes].sort() };
504
+ }
505
+
506
+ // A start key's workspace document: the same identity checks as
507
+ // checkWorkspace, and Debug with the start grant (other workflow permissions
508
+ // may come with it; Replay may not).
509
+ export function checkStartWorkspace(body, { profile, workspaceId }) {
510
+ const identity = checkWorkspace({ ...body, access: { scopes: [METADATA_SCOPE] } }, { profile, workspaceId });
511
+ if (!identity.ok) return identity;
512
+ const scopes = body.access?.scopes;
513
+ if (credentialAccess(scopes) !== "debug" || !EXPERIMENT_START_KEY_SCOPES.every((scope) => scopes.includes(scope))) {
514
+ return stop("permission_denied", "experiment_start_not_granted");
515
+ }
516
+ return { ok: true, access: "debug", scopes: [...scopes].sort() };
517
+ }
518
+
519
+ // A start key's capabilities document: the same identity checks as
520
+ // checkCapabilities, and the start capability offered to this key.
521
+ export function checkStartCapabilities(body, { profile, workspaceId }) {
522
+ if (body.schema_version !== AGENT_CONTRACT_VERSION || !isObject(body.provenance) || !isObject(body.agent)) {
523
+ return stop("verification_failed", "capabilities_response_invalid");
524
+ }
525
+ if (normalizeUuid(body.provenance.workspace_id) !== workspaceId) {
526
+ return stop("verification_failed", "workspace_context_mismatch");
527
+ }
528
+ if (body.provenance.deployment_profile !== profile || body.deployment_profile !== profile) {
529
+ return stop("verification_failed", "profile_mismatch");
530
+ }
531
+ const start = body.agent.experiment_start;
532
+ if (!isObject(start) || start.available !== true || start.required_scope !== EXPERIMENT_START_SCOPE || start.mutates !== true) {
533
+ return stop("capability_unavailable", "experiment_start_unavailable");
534
+ }
444
535
  return { ok: true };
445
536
  }
446
537
 
447
- export function checkCapabilities(body, { profile, workspaceId }) {
538
+ // access is the credential's ("metadata" or "debug"). A Debug credential is
539
+ // offered content and starts by design; the read commands never use them.
540
+ export function checkCapabilities(body, { profile, workspaceId, access = "metadata" }) {
448
541
  if (body.schema_version !== AGENT_CONTRACT_VERSION || !isObject(body.provenance) || !isObject(body.agent)) {
449
542
  return stop("verification_failed", "capabilities_response_invalid");
450
543
  }
@@ -456,6 +549,10 @@ export function checkCapabilities(body, { profile, workspaceId }) {
456
549
  }
457
550
  for (const entry of Object.values(body.agent)) {
458
551
  if (!isObject(entry)) return stop("verification_failed", "capabilities_response_invalid");
552
+ if (access !== "metadata" && entry.required_scope === "agent:replay" && entry.available !== false) {
553
+ return stop("verification_failed", "scope_mismatch");
554
+ }
555
+ if (access !== "metadata") continue;
459
556
  const sensitive =
460
557
  entry.content !== false ||
461
558
  entry.privacy_class !== "metadata" ||
@@ -1,6 +1,6 @@
1
- import { endpointsFor, refreshGrant, verifyContext } from "./auth-oauth.js";
1
+ import { endpointsFor, refreshGrant, sameScopes, verifyContext } from "./auth-oauth.js";
2
2
  import { readBinding, resolveProject } from "./auth-binding.js";
3
- import { Stop, openStore, readCredential, resolveConfigDir, withSlotLock, writeCredential } from "./auth-store.js";
3
+ import { Stop, openStore, readProjectCredential, resolveConfigDir, withSlotLock, writeCredential } from "./auth-store.js";
4
4
  import { AUTH_HTTP_TIMEOUT_MS, METADATA_SCOPE } from "./constants.js";
5
5
  import { deadline } from "./transport.js";
6
6
 
@@ -14,7 +14,7 @@ const REFRESH_MARGIN_MS = 60 * 1000;
14
14
  export const LOCK_WAIT_MS = AUTH_HTTP_TIMEOUT_MS + 5000;
15
15
 
16
16
  // Resolves with
17
- // { ok: true, session: { origin, workspaceId, profile, scopes, accessToken }, documents, knownCredentials }
17
+ // { ok: true, session: { origin, workspaceId, profile, access, scopes, accessToken }, documents, knownCredentials }
18
18
  // { ok: false, outcome, reason }
19
19
  // documents holds the workspace and capabilities documents the service
20
20
  // returned during verification, already checked by verifyContext.
@@ -25,7 +25,7 @@ export async function verifiedSession({ project = null, configDir = null, cancel
25
25
  const store = openStore(resolveConfigDir(configDir), { create: false });
26
26
  const found = readBinding(root);
27
27
  if (found === null) return stop("login_required", "not_signed_in");
28
- return await sessionFor(found.binding, store, cancel);
28
+ return await sessionFor(found.binding, store, cancel, root);
29
29
  } catch (error) {
30
30
  if (error instanceof Stop) return stop(error.outcome, error.reason);
31
31
  throw error;
@@ -34,10 +34,10 @@ export async function verifiedSession({ project = null, configDir = null, cancel
34
34
 
35
35
  // The same, for a binding and store already read by the caller. store may be
36
36
  // null when the private store does not exist.
37
- export async function sessionFor(binding, store, cancel) {
37
+ export async function sessionFor(binding, store, cancel, root) {
38
38
  if (store === null) return stop("login_required", "credential_missing");
39
39
  const slot = binding.credential_slot;
40
- let record = readCredential(store, slot);
40
+ let record = readProjectCredential(store, slot, root);
41
41
  if (record === null) return stop("login_required", "credential_missing");
42
42
  const context = checkIdentity(record, binding);
43
43
  if (!context.ok) return context;
@@ -52,7 +52,7 @@ export async function sessionFor(binding, store, cancel) {
52
52
  if (record.refresh_pending || record.expires_at - Date.now() <= REFRESH_MARGIN_MS) {
53
53
  let refreshed;
54
54
  try {
55
- refreshed = await withSlotLock(store, slot, () => refreshLocked(store, slot, binding, cancel), {
55
+ refreshed = await withSlotLock(store, slot, () => refreshLocked(store, slot, binding, cancel, root), {
56
56
  waitMs: LOCK_WAIT_MS,
57
57
  signal: cancel,
58
58
  });
@@ -68,12 +68,18 @@ export async function sessionFor(binding, store, cancel) {
68
68
  }
69
69
 
70
70
  const limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
71
+ // A Debug sign in (login --role debug) is verified as Debug with workflow
72
+ // permissions, and must still hold exactly the scopes it was granted.
73
+ const debug = record.scope !== METADATA_SCOPE;
71
74
  const verified = await verifyContext(
72
75
  binding.origin,
73
76
  record.access_token,
74
- { profile: binding.deployment_profile, workspaceId: binding.workspace_id },
77
+ { profile: binding.deployment_profile, workspaceId: binding.workspace_id, access: debug ? "read" : "metadata" },
75
78
  limit.signal,
76
79
  );
80
+ if (verified.ok && debug && !sameScopes(record.scope, verified.scopes)) {
81
+ return stop("verification_failed", "scope_mismatch");
82
+ }
77
83
  if (!verified.ok) {
78
84
  // The service refused a token that was valid when issued: the grant was
79
85
  // revoked, or the user lost access to the workspace. Fail closed.
@@ -87,7 +93,8 @@ export async function sessionFor(binding, store, cancel) {
87
93
  origin: binding.origin,
88
94
  workspaceId: binding.workspace_id,
89
95
  profile: binding.deployment_profile,
90
- scopes: [METADATA_SCOPE],
96
+ access: verified.access,
97
+ scopes: verified.scopes,
91
98
  accessToken: record.access_token,
92
99
  },
93
100
  documents: verified.documents,
@@ -127,8 +134,8 @@ function checkContext(record, binding) {
127
134
  // unusable answer, a failed save or a killed process) leaves the mark, so the
128
135
  // possibly consumed refresh token is never sent again and the user is asked
129
136
  // to reconnect.
130
- async function refreshLocked(store, slot, binding, cancel) {
131
- const current = readCredential(store, slot);
137
+ async function refreshLocked(store, slot, binding, cancel, root) {
138
+ const current = readProjectCredential(store, slot, root);
132
139
  if (current === null) return stop("login_required", "credential_missing");
133
140
  const context = checkContext(current, binding);
134
141
  if (!context.ok) return context;
@@ -139,7 +146,7 @@ async function refreshLocked(store, slot, binding, cancel) {
139
146
  const limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
140
147
  const result = await refreshGrant(
141
148
  endpoints,
142
- { clientId: current.client_id, refreshToken: current.refresh_token },
149
+ { clientId: current.client_id, refreshToken: current.refresh_token, scopes: current.scope.split(" ") },
143
150
  limit.signal,
144
151
  );
145
152
  if (!result.ok) {
package/src/auth-store.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { randomBytes } from "node:crypto";
3
- import fs from "node:fs";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import fs, { withRollback } from "./anchored-fs.js";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
 
7
7
  import { systemRoot } from "./auth-browser.js";
8
- import { AUTH_PATHS, METADATA_SCOPE, SUPPORTED_PROFILES } from "./constants.js";
8
+ import { AUTH_PATHS, SUPPORTED_PROFILES, isLoginScope } from "./constants.js";
9
9
  import { parseOrigin } from "./origin.js";
10
10
 
11
11
  // Per-user credential storage for the delegated Metadata OAuth grant. Only
@@ -90,6 +90,7 @@ export function openStore(dir, { create }) {
90
90
  return { dir, credentials };
91
91
  } catch (error) {
92
92
  if (error instanceof Stop) throw error;
93
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
93
94
  throw new Stop("filesystem_error", "credential_store_unavailable");
94
95
  }
95
96
  }
@@ -111,6 +112,7 @@ export function readCredential(store, slot) {
111
112
  content = readPrivateFile(file);
112
113
  } catch (error) {
113
114
  if (error instanceof Stop) throw error;
115
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
114
116
  throw new Stop("filesystem_error", "credential_store_unavailable");
115
117
  }
116
118
  if (content === null) return null;
@@ -119,6 +121,20 @@ export function readCredential(store, slot) {
119
121
  return record;
120
122
  }
121
123
 
124
+
125
+ // Only a private record created for this canonical checkout authorizes use or
126
+ // retirement. Public bindings and copied slot names carry no such authority.
127
+ export function projectIdentity(root) {
128
+ const canonical = fs.realpathSync(root);
129
+ const identity = fs.statSync(canonical, { bigint: true });
130
+ return createHash("sha256").update(`${canonical}\0${identity.dev}\0${identity.ino}`).digest("hex");
131
+ }
132
+
133
+ export function readProjectCredential(store, slot, root) {
134
+ const record = readCredential(store, slot);
135
+ return record?.project_id === projectIdentity(root) ? record : null;
136
+ }
137
+
122
138
  // Atomically replaces the slot file with record.
123
139
  export function writeCredential(store, slot, record) {
124
140
  if (!validRecord(record)) throw new Error("credential record is not valid");
@@ -127,6 +143,7 @@ export function writeCredential(store, slot, record) {
127
143
  writePrivateFile(store.credentials, `${slot}.json`, content);
128
144
  } catch (error) {
129
145
  if (error instanceof Stop) throw error;
146
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
130
147
  throw new Stop("filesystem_error", "credential_write_failed");
131
148
  }
132
149
  }
@@ -143,6 +160,7 @@ export function deleteCredential(store, slot) {
143
160
  return true;
144
161
  } catch (error) {
145
162
  if (error instanceof Stop) throw error;
163
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
146
164
  throw new Stop("filesystem_error", "credential_write_failed");
147
165
  }
148
166
  }
@@ -165,6 +183,7 @@ export async function withSlotLock(store, slot, work, { waitMs, signal = null })
165
183
  fs.closeSync(fd);
166
184
  break;
167
185
  } catch (error) {
186
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
168
187
  if (error.code !== "EEXIST") throw new Stop("filesystem_error", "credential_store_unavailable");
169
188
  if (Date.now() >= until) throw new Stop("conflict", "credential_locked");
170
189
  await pause(50, signal);
@@ -175,7 +194,7 @@ export async function withSlotLock(store, slot, work, { waitMs, signal = null })
175
194
  return await work();
176
195
  } finally {
177
196
  try {
178
- fs.unlinkSync(lock);
197
+ withRollback(() => fs.unlinkSync(lock));
179
198
  } catch {
180
199
  // Reported by the next run as a held lock.
181
200
  }
@@ -197,7 +216,9 @@ function pause(ms, signal) {
197
216
 
198
217
  export function validRecord(record) {
199
218
  if (record === null || typeof record !== "object" || Array.isArray(record)) return false;
200
- if (Object.keys(record).sort().join() !== RECORD_KEYS) return false;
219
+ const keys = Object.keys(record).filter((key) => key !== "project_id").sort().join();
220
+ if (keys !== RECORD_KEYS) return false;
221
+ if (Object.hasOwn(record, "project_id") && !/^[0-9a-f]{64}$/.test(record.project_id)) return false;
201
222
  const origin = parseOrigin(record.origin);
202
223
  return (
203
224
  record.schema_version === 1 &&
@@ -207,7 +228,7 @@ export function validRecord(record) {
207
228
  SUPPORTED_PROFILES.includes(record.deployment_profile) &&
208
229
  typeof record.workspace_id === "string" &&
209
230
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(record.workspace_id) &&
210
- record.scope === METADATA_SCOPE &&
231
+ isLoginScope(record.scope) &&
211
232
  printable(record.client_id, 256) &&
212
233
  printable(record.access_token, 8192) &&
213
234
  printable(record.refresh_token, 4096) &&
@@ -373,7 +394,7 @@ function writePrivateFile(dir, name, content) {
373
394
  fs.renameSync(temp, dest);
374
395
  } catch (error) {
375
396
  try {
376
- fs.unlinkSync(temp);
397
+ withRollback(() => fs.unlinkSync(temp));
377
398
  } catch {
378
399
  // Already renamed or never created.
379
400
  }