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.
- package/README.md +121 -13
- package/assets/skill/SKILL.md +124 -31
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +31 -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-report/SKILL.md +60 -15
- package/assets/skills/metergraph-workloads/SKILL.md +97 -13
- package/bin/metergraph.js +1 -1
- 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 +22 -12
- package/src/anchored-fs.js +163 -0
- package/src/args.js +335 -31
- 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 +133 -36
- package/src/auth-session.js +19 -12
- package/src/auth-store.js +28 -7
- package/src/cli.js +147 -15
- package/src/constants.js +70 -1
- package/src/experiments-contract.js +462 -0
- package/src/experiments-output.js +190 -0
- package/src/experiments.js +406 -0
- package/src/output.js +374 -68
- package/src/pricing-args.js +163 -0
- package/src/pricing-output.js +206 -0
- package/src/pricing-reference.js +195 -0
- package/src/pricing.js +307 -0
- package/src/read-contract.js +64 -10
- package/src/read-output.js +66 -13
- package/src/read.js +105 -16
- package/src/repository-identity.js +2 -2
- package/src/setup-deployment.js +7 -0
- package/src/setup-env-git.js +1 -1
- package/src/setup-env.js +16 -9
- package/src/setup-interactive.js +356 -0
- 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 +26 -3
- package/src/verify-output.js +2 -2
- 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 {
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
315
|
-
// non-empty tokens and an expiry. The
|
|
316
|
-
// issuer, resource and client and one
|
|
317
|
-
// what the server said, not a
|
|
318
|
-
|
|
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 (
|
|
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
|
|
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
|
|
386
|
-
//
|
|
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
|
|
390
|
-
// every capability that reads content or replays,
|
|
391
|
-
// 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.
|
|
392
431
|
// Nothing else is read: no telemetry, content or provider requests.
|
|
393
|
-
// Returns { ok: true, workspaceId,
|
|
394
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" ||
|
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
|
}
|