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.
- package/README.md +78 -14
- package/assets/skill/SKILL.md +124 -31
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +37 -31
- package/assets/skills/metergraph-analyze/SKILL.md +161 -58
- package/assets/skills/metergraph-candidates/SKILL.md +50 -8
- package/assets/skills/metergraph-evals/SKILL.md +83 -16
- package/assets/skills/metergraph-investigate/SKILL.md +90 -34
- package/assets/skills/metergraph-iterate/SKILL.md +82 -12
- package/assets/skills/metergraph-local-bundle/SKILL.md +146 -128
- package/assets/skills/metergraph-model-swap/SKILL.md +142 -20
- package/assets/skills/metergraph-onboarding/SKILL.md +93 -49
- package/assets/skills/metergraph-price-check/SKILL.md +219 -0
- package/assets/skills/metergraph-report/SKILL.md +60 -15
- package/assets/skills/metergraph-workloads/SKILL.md +97 -13
- package/native/README.md +85 -0
- package/native/anchored-fs.c +243 -0
- package/native/bin/anchored-fs-darwin-arm64 +0 -0
- package/native/bin/anchored-fs-darwin-x64 +0 -0
- package/native/bin/anchored-fs-linux-arm64 +0 -0
- package/native/bin/anchored-fs-linux-x64 +0 -0
- package/native/build.mjs +55 -0
- package/native/manifest.json +9 -0
- package/native/targets.mjs +24 -0
- package/package.json +10 -3
- package/src/action-help.js +130 -0
- package/src/agent-key.js +21 -14
- package/src/anchored-fs.js +163 -0
- package/src/args.js +125 -17
- package/src/auth-binding.js +12 -7
- package/src/auth-callback.js +10 -2
- package/src/auth-login.js +111 -28
- package/src/auth-oauth.js +89 -45
- package/src/auth-session.js +19 -12
- package/src/auth-store.js +28 -7
- package/src/cli.js +52 -8
- package/src/constants.js +37 -9
- package/src/experiments-output.js +7 -6
- package/src/experiments.js +26 -17
- package/src/output.js +274 -79
- package/src/read-contract.js +42 -8
- package/src/read-output.js +66 -13
- package/src/read.js +90 -15
- package/src/repository-identity.js +2 -2
- package/src/setup-env-git.js +1 -1
- package/src/setup-env.js +16 -9
- package/src/setup-interactive.js +19 -4
- package/src/setup-refusal.js +2 -2
- package/src/setup-state.js +9 -4
- package/src/setup.js +15 -3
- package/src/skill-bundle.js +2 -2
- package/src/skill-trusted.js +56 -0
- package/src/skill.js +28 -9
- package/src/style.js +33 -7
- package/src/tools-output.js +73 -0
- package/src/tools.js +249 -0
- package/src/trace-contract.js +1 -1
- package/src/transport.js +21 -0
- package/src/verify-output.js +2 -2
- package/src/verify.js +5 -2
package/src/auth-oauth.js
CHANGED
|
@@ -3,9 +3,11 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
3
3
|
import {
|
|
4
4
|
AGENT_CONTRACT_VERSION,
|
|
5
5
|
AUTH_PATHS,
|
|
6
|
+
DEBUG_SCOPE,
|
|
6
7
|
EXPERIMENT_START_KEY_SCOPES,
|
|
7
8
|
EXPERIMENT_START_SCOPE,
|
|
8
9
|
METADATA_SCOPE,
|
|
10
|
+
WORKFLOW_SCOPES,
|
|
9
11
|
} from "./constants.js";
|
|
10
12
|
import { parseJsonObject } from "./http.js";
|
|
11
13
|
import { failureOf, send } from "./transport.js";
|
|
@@ -34,6 +36,7 @@ const MAX_EXPIRES_IN = 366 * 24 * 60 * 60;
|
|
|
34
36
|
// metadata and in the setup discovery document. Without it the CLI registers
|
|
35
37
|
// a new public client for each sign in, as servers without the field expect.
|
|
36
38
|
export const CLI_CLIENT_ID_FIELD = "metergraph_cli_client_id";
|
|
39
|
+
export const WORKSPACE_HINT_FIELD = "metergraph_workspace_hint_supported";
|
|
37
40
|
|
|
38
41
|
// The access token claim that names the workspace the grant is for. The
|
|
39
42
|
// claim is a sanity check only; the CLI does not verify token signatures.
|
|
@@ -60,7 +63,7 @@ const stop = (outcome, reason) => ({ ok: false, outcome, reason });
|
|
|
60
63
|
// fixed path on this origin. A server that does not advertise the Metadata
|
|
61
64
|
// scope, S256 or public clients is unsupported; there is no fallback to
|
|
62
65
|
// another scope.
|
|
63
|
-
export async function discover(origin, signal) {
|
|
66
|
+
export async function discover(origin, signal, baseScope = METADATA_SCOPE) {
|
|
64
67
|
const expected = endpointsFor(origin);
|
|
65
68
|
|
|
66
69
|
const prm = await fetchMetadata(origin, [
|
|
@@ -80,6 +83,9 @@ export async function discover(origin, signal) {
|
|
|
80
83
|
if (resource.scopes_supported !== undefined && !includes(resource.scopes_supported, METADATA_SCOPE)) {
|
|
81
84
|
return stop("unsupported", "metadata_scope_unsupported");
|
|
82
85
|
}
|
|
86
|
+
if (resource.scopes_supported !== undefined && !includes(resource.scopes_supported, baseScope)) {
|
|
87
|
+
return stop("unsupported", "debug_scope_unsupported");
|
|
88
|
+
}
|
|
83
89
|
if (resource.bearer_methods_supported !== undefined && !includes(resource.bearer_methods_supported, "header")) {
|
|
84
90
|
return stop("unsupported", "oauth_metadata_invalid");
|
|
85
91
|
}
|
|
@@ -103,6 +109,7 @@ export async function discover(origin, signal) {
|
|
|
103
109
|
if (!includes(server.scopes_supported, METADATA_SCOPE)) {
|
|
104
110
|
return stop("unsupported", "metadata_scope_unsupported");
|
|
105
111
|
}
|
|
112
|
+
if (!includes(server.scopes_supported, baseScope)) return stop("unsupported", "debug_scope_unsupported");
|
|
106
113
|
if (!includes(server.code_challenge_methods_supported, "S256")) return stop("unsupported", "pkce_unsupported");
|
|
107
114
|
if (!includes(server.token_endpoint_auth_methods_supported, "none")) {
|
|
108
115
|
return stop("unsupported", "public_client_unsupported");
|
|
@@ -128,6 +135,9 @@ export async function discover(origin, signal) {
|
|
|
128
135
|
endpoints: expected,
|
|
129
136
|
requireIss: server.authorization_response_iss_parameter_supported === true,
|
|
130
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,
|
|
131
141
|
};
|
|
132
142
|
}
|
|
133
143
|
|
|
@@ -143,9 +153,9 @@ export function offeredClientId(doc) {
|
|
|
143
153
|
// The client for one sign in: the pre-registered client the service offered,
|
|
144
154
|
// or a newly registered one when it offered none. The offered client accepts
|
|
145
155
|
// the loopback redirect on any port, so it needs no registration request.
|
|
146
|
-
export async function clientFor(endpoints, offered, redirectUri, signal) {
|
|
156
|
+
export async function clientFor(endpoints, offered, redirectUri, signal, scopes = [METADATA_SCOPE]) {
|
|
147
157
|
if (offered !== null) return { ok: true, clientId: offered };
|
|
148
|
-
return register(endpoints, redirectUri, signal);
|
|
158
|
+
return register(endpoints, redirectUri, signal, scopes);
|
|
149
159
|
}
|
|
150
160
|
|
|
151
161
|
async function fetchMetadata(origin, paths, signal) {
|
|
@@ -170,7 +180,7 @@ function includes(list, value) {
|
|
|
170
180
|
// Dynamic client registration for a public client bound to one exact
|
|
171
181
|
// loopback redirect. A response that carries a client secret, a different
|
|
172
182
|
// redirect or another authentication method is refused.
|
|
173
|
-
export async function register(endpoints, redirectUri, signal) {
|
|
183
|
+
export async function register(endpoints, redirectUri, signal, scopes = [METADATA_SCOPE]) {
|
|
174
184
|
const response = await send(endpoints.origin, AUTH_PATHS.registration, {
|
|
175
185
|
method: "POST",
|
|
176
186
|
signal,
|
|
@@ -181,7 +191,7 @@ export async function register(endpoints, redirectUri, signal) {
|
|
|
181
191
|
grant_types: ["authorization_code", "refresh_token"],
|
|
182
192
|
response_types: ["code"],
|
|
183
193
|
token_endpoint_auth_method: "none",
|
|
184
|
-
scope:
|
|
194
|
+
scope: scopes.join(" "),
|
|
185
195
|
},
|
|
186
196
|
});
|
|
187
197
|
const failure = failureOf(response);
|
|
@@ -235,13 +245,18 @@ export function newPkce() {
|
|
|
235
245
|
// also approve the first ingest key for a new setup family. The service
|
|
236
246
|
// returns that receipt next to the code; it is redeemed separately with its
|
|
237
247
|
// own PKCE verifier.
|
|
238
|
-
|
|
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
|
+
}) {
|
|
239
254
|
const url = new URL(endpoints.authorization);
|
|
240
255
|
url.search = new URLSearchParams({
|
|
241
256
|
response_type: "code",
|
|
242
257
|
client_id: clientId,
|
|
243
258
|
redirect_uri: redirectUri,
|
|
244
|
-
scope:
|
|
259
|
+
scope: scopes.join(" "),
|
|
245
260
|
state,
|
|
246
261
|
code_challenge: challenge,
|
|
247
262
|
code_challenge_method: "S256",
|
|
@@ -251,6 +266,7 @@ export function authorizationUrl(endpoints, { clientId, redirectUri, state, chal
|
|
|
251
266
|
ingest_setup_family_id: ingestSetup.familyId,
|
|
252
267
|
ingest_setup_code_challenge: ingestSetup.challenge,
|
|
253
268
|
}),
|
|
269
|
+
...(workspaceHint === null ? {} : { workspace_hint: workspaceHint }),
|
|
254
270
|
}).toString();
|
|
255
271
|
if (!signup) return url.href;
|
|
256
272
|
const entry = new URL(`${endpoints.origin}${AUTH_PATHS.signup}`);
|
|
@@ -260,7 +276,7 @@ export function authorizationUrl(endpoints, { clientId, redirectUri, state, chal
|
|
|
260
276
|
|
|
261
277
|
// Exchanges an authorization code. Codes are single use, so there is never
|
|
262
278
|
// a retry.
|
|
263
|
-
export async function exchangeCode(endpoints, { clientId, redirectUri, code, verifier }, signal) {
|
|
279
|
+
export async function exchangeCode(endpoints, { clientId, redirectUri, code, verifier, scopes = [METADATA_SCOPE] }, signal) {
|
|
264
280
|
const response = await send(endpoints.origin, AUTH_PATHS.token, {
|
|
265
281
|
method: "POST",
|
|
266
282
|
signal,
|
|
@@ -278,13 +294,13 @@ export async function exchangeCode(endpoints, { clientId, redirectUri, code, ver
|
|
|
278
294
|
if (failure !== null) return { ok: false, ...failure };
|
|
279
295
|
if (response.status === 400 || response.status === 401) return stop("authorization_failed", "code_rejected");
|
|
280
296
|
if (response.status !== 200) return stop("unsupported", "unexpected_status");
|
|
281
|
-
return validateGrant(parseJsonObject(response), endpoints, clientId, { requireRefresh: true });
|
|
297
|
+
return validateGrant(parseJsonObject(response), endpoints, clientId, { requireRefresh: true, scopes });
|
|
282
298
|
}
|
|
283
299
|
|
|
284
300
|
// Refreshes a grant once. The result says whether the refresh token may have
|
|
285
301
|
// been consumed without the CLI learning its replacement (ambiguous), so the
|
|
286
302
|
// caller never retries with it.
|
|
287
|
-
export async function refreshGrant(endpoints, { clientId, refreshToken }, signal) {
|
|
303
|
+
export async function refreshGrant(endpoints, { clientId, refreshToken, scopes = [METADATA_SCOPE] }, signal) {
|
|
288
304
|
const response = await send(endpoints.origin, AUTH_PATHS.token, {
|
|
289
305
|
method: "POST",
|
|
290
306
|
signal,
|
|
@@ -294,7 +310,7 @@ export async function refreshGrant(endpoints, { clientId, refreshToken }, signal
|
|
|
294
310
|
refresh_token: refreshToken,
|
|
295
311
|
client_id: clientId,
|
|
296
312
|
resource: endpoints.resource,
|
|
297
|
-
scope:
|
|
313
|
+
scope: scopes.join(" "),
|
|
298
314
|
},
|
|
299
315
|
});
|
|
300
316
|
if (response.kind === "error" && !response.sent) {
|
|
@@ -312,16 +328,17 @@ export async function refreshGrant(endpoints, { clientId, refreshToken }, signal
|
|
|
312
328
|
if (response.kind !== "response" || response.status !== 200 || response.tooLarge) {
|
|
313
329
|
return { ok: false, outcome: "login_required", reason: "refresh_interrupted", ambiguous: true };
|
|
314
330
|
}
|
|
315
|
-
const result = validateGrant(parseJsonObject(response), endpoints, clientId, { requireRefresh: false });
|
|
331
|
+
const result = validateGrant(parseJsonObject(response), endpoints, clientId, { requireRefresh: false, scopes });
|
|
316
332
|
// A 200 that cannot be used may still have rotated the refresh token.
|
|
317
333
|
return result.ok ? result : { ...result, ambiguous: true };
|
|
318
334
|
}
|
|
319
335
|
|
|
320
|
-
// Accepts only a Bearer grant for exactly the
|
|
321
|
-
// non-empty tokens and an expiry. The
|
|
322
|
-
// issuer, resource and client and one
|
|
323
|
-
// what the server said, not a
|
|
324
|
-
|
|
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] }) {
|
|
325
342
|
if (body === null) return stop("verification_failed", "token_response_invalid");
|
|
326
343
|
if (typeof body.token_type !== "string" || body.token_type.toLowerCase() !== "bearer") {
|
|
327
344
|
return stop("verification_failed", "token_type_invalid");
|
|
@@ -340,9 +357,7 @@ export function validateGrant(body, endpoints, clientId, { requireRefresh }) {
|
|
|
340
357
|
if (!Number.isInteger(body.expires_in) || body.expires_in < 1 || body.expires_in > MAX_EXPIRES_IN) {
|
|
341
358
|
return stop("verification_failed", "token_response_invalid");
|
|
342
359
|
}
|
|
343
|
-
if (
|
|
344
|
-
return stop("verification_failed", "scope_mismatch");
|
|
345
|
-
}
|
|
360
|
+
if (!sameScopes(body.scope, scopes)) return stop("verification_failed", "scope_mismatch");
|
|
346
361
|
|
|
347
362
|
const claims = decodeClaims(body.access_token);
|
|
348
363
|
if (claims === null) return stop("verification_failed", "token_claims_invalid");
|
|
@@ -350,7 +365,7 @@ export function validateGrant(body, endpoints, clientId, { requireRefresh }) {
|
|
|
350
365
|
const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
351
366
|
if (!audience.includes(endpoints.resource)) return stop("verification_failed", "resource_mismatch");
|
|
352
367
|
if (claims.client_id !== clientId) return stop("verification_failed", "client_mismatch");
|
|
353
|
-
if (claims.scope !== undefined && claims.scope
|
|
368
|
+
if (claims.scope !== undefined && !sameScopes(claims.scope, scopes)) {
|
|
354
369
|
return stop("verification_failed", "scope_mismatch");
|
|
355
370
|
}
|
|
356
371
|
const tenant = normalizeUuid(claims[TENANT_CLAIM]);
|
|
@@ -371,6 +386,13 @@ export function validateGrant(body, endpoints, clientId, { requireRefresh }) {
|
|
|
371
386
|
};
|
|
372
387
|
}
|
|
373
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
|
+
|
|
374
396
|
function decodeClaims(token) {
|
|
375
397
|
const parts = token.split(".");
|
|
376
398
|
if (parts.length !== 3 || !/^[A-Za-z0-9_-]+$/.test(parts[1])) return null;
|
|
@@ -384,27 +406,43 @@ function decodeClaims(token) {
|
|
|
384
406
|
}
|
|
385
407
|
}
|
|
386
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
|
+
|
|
387
420
|
// Asks the service, with the access token, which workspace and profile the
|
|
388
421
|
// grant is for and what it may do. The answers are server-authoritative:
|
|
389
422
|
// GET /v1/agent/workspace: workspace.id, provenance.workspace_id and the
|
|
390
423
|
// expected workspace must agree, provenance.deployment_profile must be
|
|
391
|
-
// the profile seen before sign in, access.scopes must be
|
|
392
|
-
//
|
|
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
|
|
393
426
|
// enabled for the workspace (content.captured) is not a reason to refuse.
|
|
394
427
|
// GET /v1/agent/capabilities: provenance.workspace_id and both
|
|
395
|
-
// provenance.deployment_profile and deployment_profile must match
|
|
396
|
-
// every capability that reads content or replays,
|
|
397
|
-
// provider, must be unavailable
|
|
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.
|
|
398
431
|
// Nothing else is read: no telemetry, content or provider requests.
|
|
399
|
-
// Returns { ok: true, workspaceId,
|
|
400
|
-
//
|
|
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).
|
|
401
435
|
// workspaceId is the workspace the credential must be for. Only an agent key
|
|
402
436
|
// with nothing to pin it to passes anyWorkspace: true with workspaceId null,
|
|
403
437
|
// and then the workspace the service reports is checked for consistency and
|
|
404
438
|
// returned as the verified one.
|
|
405
|
-
// access
|
|
406
|
-
//
|
|
407
|
-
//
|
|
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).
|
|
408
446
|
export async function verifyContext(origin, accessToken, { profile, workspaceId, anyWorkspace = false, access = "metadata" }, signal) {
|
|
409
447
|
const workspace = await bearerJson(origin, AUTH_PATHS.workspace, accessToken, WORKSPACE_BYTES, signal, "workspace");
|
|
410
448
|
if (!workspace.ok) return workspace;
|
|
@@ -414,7 +452,7 @@ export async function verifyContext(origin, accessToken, { profile, workspaceId,
|
|
|
414
452
|
}
|
|
415
453
|
const checked = access === "experiment_start"
|
|
416
454
|
? checkStartWorkspace(workspace.body, { profile, workspaceId })
|
|
417
|
-
: checkWorkspace(workspace.body, { profile, workspaceId });
|
|
455
|
+
: checkWorkspace(workspace.body, { profile, workspaceId, debug: access === "read" });
|
|
418
456
|
if (!checked.ok) return checked;
|
|
419
457
|
|
|
420
458
|
const capabilities = await bearerJson(
|
|
@@ -428,14 +466,15 @@ export async function verifyContext(origin, accessToken, { profile, workspaceId,
|
|
|
428
466
|
if (!capabilities.ok) return capabilities;
|
|
429
467
|
const allowed = access === "experiment_start"
|
|
430
468
|
? checkStartCapabilities(capabilities.body, { profile, workspaceId })
|
|
431
|
-
: checkCapabilities(capabilities.body, { profile, workspaceId });
|
|
469
|
+
: checkCapabilities(capabilities.body, { profile, workspaceId, access: checked.access });
|
|
432
470
|
if (!allowed.ok) return allowed;
|
|
433
471
|
// The verified documents stay in memory for read commands, which print
|
|
434
472
|
// only fields they validate themselves.
|
|
435
473
|
return {
|
|
436
474
|
ok: true,
|
|
437
475
|
workspaceId,
|
|
438
|
-
|
|
476
|
+
access: checked.access,
|
|
477
|
+
scopes: checked.scopes,
|
|
439
478
|
documents: { workspace: workspace.body, capabilities: capabilities.body },
|
|
440
479
|
};
|
|
441
480
|
}
|
|
@@ -443,7 +482,8 @@ export async function verifyContext(origin, accessToken, { profile, workspaceId,
|
|
|
443
482
|
// Both documents carry the service's contract version string in
|
|
444
483
|
// schema_version. Any other value, including a number, is a contract this
|
|
445
484
|
// CLI does not know.
|
|
446
|
-
|
|
485
|
+
// debug: true accepts Debug with workflow permissions as well as Metadata.
|
|
486
|
+
export function checkWorkspace(body, { profile, workspaceId, debug = false }) {
|
|
447
487
|
const id = normalizeUuid(body.workspace?.id);
|
|
448
488
|
if (body.schema_version !== AGENT_CONTRACT_VERSION || id === null || !isObject(body.provenance)) {
|
|
449
489
|
return stop("verification_failed", "workspace_response_invalid");
|
|
@@ -453,29 +493,27 @@ export function checkWorkspace(body, { profile, workspaceId }) {
|
|
|
453
493
|
}
|
|
454
494
|
if (body.provenance.deployment_profile !== profile) return stop("verification_failed", "profile_mismatch");
|
|
455
495
|
const scopes = body.access?.scopes;
|
|
456
|
-
|
|
496
|
+
const access = credentialAccess(scopes);
|
|
497
|
+
if (access === null || (access === "debug" && !debug)) {
|
|
457
498
|
return stop("verification_failed", "scope_mismatch");
|
|
458
499
|
}
|
|
459
500
|
if (!isObject(body.content) || body.content.included !== false) {
|
|
460
501
|
return stop("verification_failed", "content_access_granted");
|
|
461
502
|
}
|
|
462
|
-
return { ok: true };
|
|
503
|
+
return { ok: true, access, scopes: [...scopes].sort() };
|
|
463
504
|
}
|
|
464
505
|
|
|
465
506
|
// A start key's workspace document: the same identity checks as
|
|
466
|
-
// checkWorkspace, and
|
|
507
|
+
// checkWorkspace, and Debug with the start grant (other workflow permissions
|
|
508
|
+
// may come with it; Replay may not).
|
|
467
509
|
export function checkStartWorkspace(body, { profile, workspaceId }) {
|
|
468
510
|
const identity = checkWorkspace({ ...body, access: { scopes: [METADATA_SCOPE] } }, { profile, workspaceId });
|
|
469
511
|
if (!identity.ok) return identity;
|
|
470
512
|
const scopes = body.access?.scopes;
|
|
471
|
-
if (
|
|
472
|
-
!Array.isArray(scopes) ||
|
|
473
|
-
scopes.length !== EXPERIMENT_START_KEY_SCOPES.length ||
|
|
474
|
-
!EXPERIMENT_START_KEY_SCOPES.every((scope) => scopes.includes(scope))
|
|
475
|
-
) {
|
|
513
|
+
if (credentialAccess(scopes) !== "debug" || !EXPERIMENT_START_KEY_SCOPES.every((scope) => scopes.includes(scope))) {
|
|
476
514
|
return stop("permission_denied", "experiment_start_not_granted");
|
|
477
515
|
}
|
|
478
|
-
return { ok: true, scopes: [...scopes].sort() };
|
|
516
|
+
return { ok: true, access: "debug", scopes: [...scopes].sort() };
|
|
479
517
|
}
|
|
480
518
|
|
|
481
519
|
// A start key's capabilities document: the same identity checks as
|
|
@@ -497,7 +535,9 @@ export function checkStartCapabilities(body, { profile, workspaceId }) {
|
|
|
497
535
|
return { ok: true };
|
|
498
536
|
}
|
|
499
537
|
|
|
500
|
-
|
|
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" }) {
|
|
501
541
|
if (body.schema_version !== AGENT_CONTRACT_VERSION || !isObject(body.provenance) || !isObject(body.agent)) {
|
|
502
542
|
return stop("verification_failed", "capabilities_response_invalid");
|
|
503
543
|
}
|
|
@@ -509,6 +549,10 @@ export function checkCapabilities(body, { profile, workspaceId }) {
|
|
|
509
549
|
}
|
|
510
550
|
for (const entry of Object.values(body.agent)) {
|
|
511
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;
|
|
512
556
|
const sensitive =
|
|
513
557
|
entry.content !== false ||
|
|
514
558
|
entry.privacy_class !== "metadata" ||
|
package/src/auth-session.js
CHANGED
|
@@ -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,
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 "
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
}
|