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/pricing.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { send } from "./transport.js";
|
|
2
|
+
import { parseJsonObject } from "./http.js";
|
|
3
|
+
import { validDate } from "./pricing-args.js";
|
|
4
|
+
import { runReferencePricing, RATE_KEYS } from "./pricing-reference.js";
|
|
5
|
+
|
|
6
|
+
const fail = (
|
|
7
|
+
outcome,
|
|
8
|
+
reason,
|
|
9
|
+
message = "Pricing could not be read. Check the reason and retry with supported inputs.",
|
|
10
|
+
) => ({ outcome, reason, message });
|
|
11
|
+
const text = (v) =>
|
|
12
|
+
typeof v === "string" &&
|
|
13
|
+
v.length > 0 &&
|
|
14
|
+
v.length <= 500 &&
|
|
15
|
+
!/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/u.test(v);
|
|
16
|
+
const currency = (v) => typeof v === "string" && /^[A-Z]{3}$/.test(v);
|
|
17
|
+
const decimal = (v) => typeof v === "string" && /^(0|[1-9]\d{0,14})(\.\d{1,16})?$/.test(v);
|
|
18
|
+
const instant = (v) =>
|
|
19
|
+
typeof v === "string" &&
|
|
20
|
+
/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,6})?(?:Z|\+00:00)$/.test(v) &&
|
|
21
|
+
validDate(v.slice(0, 10)) &&
|
|
22
|
+
Number.isFinite(Date.parse(v));
|
|
23
|
+
const date = (v) => validDate(v) || instant(v);
|
|
24
|
+
const strings = (v) => Array.isArray(v) && v.length <= 10000 && v.every(text);
|
|
25
|
+
const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
26
|
+
const source = (v) => {
|
|
27
|
+
if (!text(v)) return false;
|
|
28
|
+
try {
|
|
29
|
+
const u = new URL(v);
|
|
30
|
+
return u.protocol === "https:" && !u.username && !u.password;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const units = (v) => {
|
|
36
|
+
const [a, b = ""] = v.split(".");
|
|
37
|
+
return BigInt(a) * 10n ** 16n + BigInt(b.padEnd(16, "0"));
|
|
38
|
+
};
|
|
39
|
+
function price(p) {
|
|
40
|
+
return (
|
|
41
|
+
object(p) &&
|
|
42
|
+
["id", "model_id", "channel", "region"].every((k) => text(p[k])) &&
|
|
43
|
+
currency(p.currency) &&
|
|
44
|
+
date(p.effective_from) &&
|
|
45
|
+
(p.effective_to === null || (date(p.effective_to) && Date.parse(p.effective_to) > Date.parse(p.effective_from))) &&
|
|
46
|
+
source(p.source_url) &&
|
|
47
|
+
object(p.rules) &&
|
|
48
|
+
RATE_KEYS.every((k) => p[k] === null || decimal(p[k]))
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
export function validateModels(d) {
|
|
52
|
+
return (
|
|
53
|
+
object(d) &&
|
|
54
|
+
instant(d.as_of) &&
|
|
55
|
+
object(d.catalog) &&
|
|
56
|
+
text(d.catalog.version) &&
|
|
57
|
+
currency(d.catalog.currency) &&
|
|
58
|
+
validDate(d.catalog.pricing_verified_at) &&
|
|
59
|
+
["formats", "providers", "channels", "regions", "service_tiers"].every((k) => strings(d[k])) &&
|
|
60
|
+
Array.isArray(d.models) &&
|
|
61
|
+
d.models.length <= 10000 &&
|
|
62
|
+
new Set(d.models.map((m) => m?.model_id)).size === d.models.length &&
|
|
63
|
+
d.models.every(
|
|
64
|
+
(m) =>
|
|
65
|
+
object(m) &&
|
|
66
|
+
text(m.model_id) &&
|
|
67
|
+
text(m.publisher) &&
|
|
68
|
+
Array.isArray(m.aliases) &&
|
|
69
|
+
m.aliases.every((a) => object(a) && ["provider", "model", "channel"].every((k) => text(a[k]))) &&
|
|
70
|
+
Array.isArray(m.prices) &&
|
|
71
|
+
m.prices.every(
|
|
72
|
+
(p) =>
|
|
73
|
+
object(p) &&
|
|
74
|
+
text(p.channel) &&
|
|
75
|
+
text(p.region) &&
|
|
76
|
+
currency(p.currency) &&
|
|
77
|
+
date(p.effective_from) &&
|
|
78
|
+
strings(p.dimensions) &&
|
|
79
|
+
strings(p.service_tiers),
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
export function validatePublicResult(d, action, q, now = Date.now()) {
|
|
85
|
+
if (!object(d) || !Array.isArray(d.results) || d.results.length !== 1) return false;
|
|
86
|
+
const r = d.results[0];
|
|
87
|
+
if (
|
|
88
|
+
!object(r) ||
|
|
89
|
+
!["priced", "partial", "unpriced"].includes(r.status) ||
|
|
90
|
+
!instant(r.effective_at) ||
|
|
91
|
+
!strings(r.missing_dimensions) ||
|
|
92
|
+
new Set(r.missing_dimensions).size !== r.missing_dimensions.length
|
|
93
|
+
)
|
|
94
|
+
return false;
|
|
95
|
+
if (
|
|
96
|
+
q.at ? Date.parse(r.effective_at) !== Date.parse(q.at) : Math.abs(Date.parse(r.effective_at) - now) > 15 * 60 * 1000
|
|
97
|
+
)
|
|
98
|
+
return false;
|
|
99
|
+
if (r.status === "priced" && r.missing_dimensions.length) return false;
|
|
100
|
+
if (r.status === "unpriced") {
|
|
101
|
+
if (r.model_id !== null || r.price !== null || r.currency !== null) return false;
|
|
102
|
+
} else if (
|
|
103
|
+
!price(r.price) ||
|
|
104
|
+
r.model_id !== r.price.model_id ||
|
|
105
|
+
r.currency !== r.price.currency ||
|
|
106
|
+
(q.channel && r.price.channel !== q.channel) ||
|
|
107
|
+
(q.region && ![q.region, "global", "*"].includes(r.price.region)) ||
|
|
108
|
+
Date.parse(r.price.effective_from) > Date.parse(r.effective_at) ||
|
|
109
|
+
(r.price.effective_to !== null && Date.parse(r.price.effective_to) <= Date.parse(r.effective_at))
|
|
110
|
+
)
|
|
111
|
+
return false;
|
|
112
|
+
if (action === "cost" || action === "calculate") {
|
|
113
|
+
if (r.status === "unpriced") return r.cost === null && r.components === null;
|
|
114
|
+
if (
|
|
115
|
+
!decimal(r.cost) ||
|
|
116
|
+
!object(r.components) ||
|
|
117
|
+
Object.keys(r.components).sort().join() !== "cache_read,cache_write,input,output,request" ||
|
|
118
|
+
!Object.values(r.components).every(decimal) ||
|
|
119
|
+
Object.values(r.components).reduce((n, v) => n + units(v), 0n) !== units(r.cost)
|
|
120
|
+
)
|
|
121
|
+
return false;
|
|
122
|
+
if (["input_tokens", "output_tokens"].some((k) => !Object.hasOwn(q, k) && !r.missing_dimensions.includes(k)))
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
export function pricingPayload(o) {
|
|
128
|
+
const q = o.query;
|
|
129
|
+
const context = Object.fromEntries(
|
|
130
|
+
["provider", "channel", "region"].filter((k) => q[k] !== undefined).map((k) => [k, q[k]]),
|
|
131
|
+
);
|
|
132
|
+
if (q.at) context.at = `${q.at}T00:00:00Z`;
|
|
133
|
+
if (q.batch) context.service_tier = "batch";
|
|
134
|
+
if (o.action === "lookup") return { model: q.model, context };
|
|
135
|
+
const usage = {};
|
|
136
|
+
const put = (key, value) => {
|
|
137
|
+
if (value !== undefined) usage[key] = value;
|
|
138
|
+
};
|
|
139
|
+
let trace;
|
|
140
|
+
if (o.traceFormat === "anthropic.messages") {
|
|
141
|
+
put("input_tokens", q.input_tokens);
|
|
142
|
+
put("output_tokens", q.output_tokens);
|
|
143
|
+
put("cache_read_input_tokens", q.cache_read_tokens);
|
|
144
|
+
put("cache_creation_input_tokens", q.cache_write_tokens);
|
|
145
|
+
if (q.cache_write_5m_tokens !== undefined || q.cache_write_1h_tokens !== undefined)
|
|
146
|
+
usage.cache_creation = {
|
|
147
|
+
...(q.cache_write_5m_tokens === undefined ? {} : { ephemeral_5m_input_tokens: q.cache_write_5m_tokens }),
|
|
148
|
+
...(q.cache_write_1h_tokens === undefined ? {} : { ephemeral_1h_input_tokens: q.cache_write_1h_tokens }),
|
|
149
|
+
};
|
|
150
|
+
trace = { model: q.model, usage };
|
|
151
|
+
} else if (o.traceFormat.startsWith("openai.")) {
|
|
152
|
+
const chat = o.traceFormat === "openai.chat_completions";
|
|
153
|
+
put(chat ? "prompt_tokens" : "input_tokens", q.input_tokens);
|
|
154
|
+
put(chat ? "completion_tokens" : "output_tokens", q.output_tokens);
|
|
155
|
+
if (q.cache_read_tokens !== undefined)
|
|
156
|
+
usage[chat ? "prompt_tokens_details" : "input_tokens_details"] = { cached_tokens: q.cache_read_tokens };
|
|
157
|
+
trace = { model: q.model, usage };
|
|
158
|
+
} else {
|
|
159
|
+
usage["gen_ai.request.model"] = q.model;
|
|
160
|
+
put("gen_ai.usage.input_tokens", q.input_tokens);
|
|
161
|
+
put("gen_ai.usage.output_tokens", q.output_tokens);
|
|
162
|
+
put("gen_ai.usage.cache_read.input_tokens", q.cache_read_tokens);
|
|
163
|
+
trace = { attributes: usage };
|
|
164
|
+
}
|
|
165
|
+
return { format: o.traceFormat, trace, context };
|
|
166
|
+
}
|
|
167
|
+
export async function runPricing(o) {
|
|
168
|
+
if (o.api === "reference") return runReferencePricing(o);
|
|
169
|
+
if (o.action === "history")
|
|
170
|
+
return fail(
|
|
171
|
+
"unsupported",
|
|
172
|
+
"pricing_history_unavailable",
|
|
173
|
+
"The public API documents no history endpoint. Historical charts remain available with --api reference and a loopback --url; no public history request was made.",
|
|
174
|
+
);
|
|
175
|
+
if (o.action === "market") return runPublicMarket(o);
|
|
176
|
+
const path =
|
|
177
|
+
o.action === "models"
|
|
178
|
+
? "/v1/pricing/models"
|
|
179
|
+
: o.action === "lookup"
|
|
180
|
+
? "/v1/pricing/lookup"
|
|
181
|
+
: "/v1/pricing/calculate";
|
|
182
|
+
const res = await send(o.origin, path, {
|
|
183
|
+
method: o.action === "models" ? "GET" : "POST",
|
|
184
|
+
json: o.action === "models" ? null : pricingPayload(o),
|
|
185
|
+
maxBytes: 1024 * 1024,
|
|
186
|
+
signal: AbortSignal.timeout(o.timeoutMs),
|
|
187
|
+
useGlobalAgent: true,
|
|
188
|
+
});
|
|
189
|
+
const failure = responseFailure(res);
|
|
190
|
+
if (failure) return failure;
|
|
191
|
+
const d = parseJsonObject(res);
|
|
192
|
+
if (o.action === "models") {
|
|
193
|
+
if (!validateModels(d)) return fail("verification_failed", "pricing_contract_mismatch");
|
|
194
|
+
const q = o.query;
|
|
195
|
+
const matches = d.models.filter(
|
|
196
|
+
(m) =>
|
|
197
|
+
(!q.model || m.model_id === q.model || m.aliases.some((a) => a.model === q.model)) &&
|
|
198
|
+
(!q.provider || m.aliases.some((a) => a.provider === q.provider && (!q.channel || a.channel === q.channel))) &&
|
|
199
|
+
(!q.channel || m.prices.some((p) => p.channel === q.channel && (!q.region || p.region === q.region))) &&
|
|
200
|
+
(!q.region || m.prices.some((p) => p.region === q.region && (!q.channel || p.channel === q.channel))),
|
|
201
|
+
);
|
|
202
|
+
return {
|
|
203
|
+
outcome: "ok",
|
|
204
|
+
data: {
|
|
205
|
+
api: "public",
|
|
206
|
+
action: o.action,
|
|
207
|
+
request: q,
|
|
208
|
+
catalog: d.catalog,
|
|
209
|
+
as_of: d.as_of,
|
|
210
|
+
formats: d.formats,
|
|
211
|
+
providers: d.providers,
|
|
212
|
+
channels: d.channels,
|
|
213
|
+
regions: d.regions,
|
|
214
|
+
service_tiers: d.service_tiers,
|
|
215
|
+
result: {
|
|
216
|
+
models: matches.slice(0, q.limit),
|
|
217
|
+
matched: matches.length,
|
|
218
|
+
total_models: d.models.length,
|
|
219
|
+
truncated: matches.length > q.limit,
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (!validatePublicResult(d, o.action, o.query)) return fail("verification_failed", "pricing_contract_mismatch");
|
|
225
|
+
const r = d.results[0];
|
|
226
|
+
return {
|
|
227
|
+
outcome: "ok",
|
|
228
|
+
data: {
|
|
229
|
+
api: "public",
|
|
230
|
+
action: o.action,
|
|
231
|
+
request: o.query,
|
|
232
|
+
...(o.action === "lookup" ? {} : { trace_format: o.traceFormat }),
|
|
233
|
+
catalog: null,
|
|
234
|
+
result: r,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function responseFailure(res) {
|
|
240
|
+
if (res.kind === "error") return fail("connection_failed", res.reason);
|
|
241
|
+
if (res.status >= 300 && res.status < 400) return fail("redirect_rejected", "pricing_redirect_rejected");
|
|
242
|
+
if (res.status === 429) return fail("rate_limited", "pricing_rate_limited");
|
|
243
|
+
if (res.status >= 500) return fail("unhealthy", "pricing_service_unhealthy");
|
|
244
|
+
if ([401, 403, 404, 405].includes(res.status)) return fail("unsupported", "public_pricing_unavailable");
|
|
245
|
+
if (res.tooLarge) return fail("unsupported", "pricing_response_too_large");
|
|
246
|
+
if ([400, 422].includes(res.status)) return fail("invalid_input", "pricing_request_rejected");
|
|
247
|
+
if (res.status !== 200) return fail("unsupported", "pricing_response_unsupported");
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function runPublicMarket(o) {
|
|
252
|
+
if (o.query.at)
|
|
253
|
+
return fail(
|
|
254
|
+
"unsupported",
|
|
255
|
+
"market_date_unsupported",
|
|
256
|
+
"The public market board uses current discovery and lookups. Use lookup --at for one historical price; historical board coverage is unavailable.",
|
|
257
|
+
);
|
|
258
|
+
const signal = AbortSignal.timeout(o.timeoutMs);
|
|
259
|
+
const options = { signal, maxBytes: 1024 * 1024, useGlobalAgent: true };
|
|
260
|
+
const discovery = await send(o.origin, "/v1/pricing/models", options);
|
|
261
|
+
const error = responseFailure(discovery);
|
|
262
|
+
if (error) return error;
|
|
263
|
+
const d = parseJsonObject(discovery);
|
|
264
|
+
if (!validateModels(d)) return fail("verification_failed", "pricing_contract_mismatch");
|
|
265
|
+
const q = o.query;
|
|
266
|
+
const matches = d.models.filter((m) => m.prices.some((p) => p.channel === q.channel && p.region === q.region));
|
|
267
|
+
const selected = matches.slice(0, q.limit),
|
|
268
|
+
items = new Array(selected.length);
|
|
269
|
+
let next = 0,
|
|
270
|
+
failure = null;
|
|
271
|
+
const worker = async () => {
|
|
272
|
+
while (!failure && next < selected.length) {
|
|
273
|
+
const index = next++,
|
|
274
|
+
model = selected[index].model_id;
|
|
275
|
+
const request = { model, channel: q.channel, region: q.region };
|
|
276
|
+
const res = await send(o.origin, "/v1/pricing/lookup", {
|
|
277
|
+
...options,
|
|
278
|
+
method: "POST",
|
|
279
|
+
json: { model, context: { channel: q.channel, region: q.region } },
|
|
280
|
+
});
|
|
281
|
+
const error = responseFailure(res);
|
|
282
|
+
if (error) {
|
|
283
|
+
failure = error;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
const result = parseJsonObject(res);
|
|
287
|
+
if (!validatePublicResult(result, "lookup", request)) {
|
|
288
|
+
failure = fail("verification_failed", "pricing_contract_mismatch");
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
items[index] = { requested_model: model, ...result.results[0] };
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
await Promise.all(Array.from({ length: Math.min(3, selected.length) }, worker));
|
|
295
|
+
if (failure) return failure;
|
|
296
|
+
return {
|
|
297
|
+
outcome: "ok",
|
|
298
|
+
data: {
|
|
299
|
+
api: "public",
|
|
300
|
+
action: "market",
|
|
301
|
+
request: q,
|
|
302
|
+
catalog: d.catalog,
|
|
303
|
+
as_of: d.as_of,
|
|
304
|
+
result: { items, matched: matches.length, truncated: matches.length > items.length, history_available: false },
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
package/src/read-contract.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizeUuid } from "./auth-oauth.js";
|
|
1
|
+
import { credentialAccess, normalizeUuid } from "./auth-oauth.js";
|
|
2
2
|
import {
|
|
3
3
|
AGENT_CONTRACT_VERSION,
|
|
4
4
|
MAX_CURSOR_LENGTH,
|
|
@@ -16,8 +16,9 @@ import { traceLink } from "./trace-contract.js";
|
|
|
16
16
|
// { ok: true, value } or { ok: false, outcome, reason }
|
|
17
17
|
// with fixed tokens. notices is a Set that collects fixed notice tokens.
|
|
18
18
|
|
|
19
|
-
// Every capability name the contract defines,
|
|
20
|
-
//
|
|
19
|
+
// Every capability name the contract defines. Content, replay and start
|
|
20
|
+
// entries are never available to a Metadata grant; a Debug credential with
|
|
21
|
+
// workflow permissions may be offered content and starts.
|
|
21
22
|
export const CAPABILITY_NAMES = Object.freeze([
|
|
22
23
|
"workspace_context",
|
|
23
24
|
"capability_discovery",
|
|
@@ -30,11 +31,16 @@ export const CAPABILITY_NAMES = Object.freeze([
|
|
|
30
31
|
"reports",
|
|
31
32
|
"report_detail",
|
|
32
33
|
"report_evidence",
|
|
34
|
+
"experiments",
|
|
35
|
+
"experiment_estimate",
|
|
36
|
+
"experiment",
|
|
37
|
+
"experiment_cases",
|
|
38
|
+
"experiment_start",
|
|
33
39
|
"trace_content",
|
|
34
40
|
"trace_replay",
|
|
35
41
|
]);
|
|
36
42
|
const PRIVACY_CLASSES = Object.freeze(["metadata", "content", "replay"]);
|
|
37
|
-
const SCOPES = Object.freeze(["agent:metadata", "agent:read", "agent:replay"]);
|
|
43
|
+
const SCOPES = Object.freeze(["agent:metadata", "agent:read", "agent:replay", "agent:analysis-run"]);
|
|
38
44
|
|
|
39
45
|
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,127}$/;
|
|
40
46
|
const IDENT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
@@ -1106,6 +1112,33 @@ export function combinePages(pages) {
|
|
|
1106
1112
|
};
|
|
1107
1113
|
}
|
|
1108
1114
|
|
|
1115
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
|
1116
|
+
const MAX_WORKSPACE_NAME = 200;
|
|
1117
|
+
|
|
1118
|
+
// A workspace document's slug and name, each kept only when it is safe to
|
|
1119
|
+
// print (a well-formed slug; a name with no control or bidirectional
|
|
1120
|
+
// characters), else null. This is how output names a workspace: never by ID.
|
|
1121
|
+
export function workspaceLabel(workspace) {
|
|
1122
|
+
const slug = typeof workspace?.slug === "string" && SLUG.test(workspace.slug) ? workspace.slug : null;
|
|
1123
|
+
const raw = workspace?.name;
|
|
1124
|
+
const name = typeof raw === "string" && raw.trim() !== "" && raw.length <= MAX_WORKSPACE_NAME && !hasUnsafeText(raw)
|
|
1125
|
+
? raw : null;
|
|
1126
|
+
return { slug, name };
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Whether a workspace document is the one a person named with --workspace.
|
|
1130
|
+
// Slugs are unique, names are not, and anyone who owns a workspace chooses
|
|
1131
|
+
// its name, including a workspace a person was invited to. So the match is
|
|
1132
|
+
// strict, for sign in and for every credential pin: text shaped like a slug
|
|
1133
|
+
// matches only the slug, and other text only the exact name, ignoring case.
|
|
1134
|
+
export function workspacePinned(workspace, text) {
|
|
1135
|
+
const { slug, name } = workspaceLabel(workspace);
|
|
1136
|
+
const wanted = text.trim().toLowerCase();
|
|
1137
|
+
if (SLUG.test(wanted)) return slug === wanted;
|
|
1138
|
+
return name !== null && name.trim().toLowerCase() === wanted;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
|
|
1109
1142
|
// GET /v1/agent/workspace, already checked by checkWorkspace during the
|
|
1110
1143
|
// session. Returns the approved workspace fields only.
|
|
1111
1144
|
export function workspaceContext(body, ctx, notices) {
|
|
@@ -1123,7 +1156,8 @@ export function workspaceContext(body, ctx, notices) {
|
|
|
1123
1156
|
throw new Invalid("verification_failed", "content_access_granted");
|
|
1124
1157
|
}
|
|
1125
1158
|
const scopes = body.access?.scopes;
|
|
1126
|
-
|
|
1159
|
+
const access = credentialAccess(scopes);
|
|
1160
|
+
if (access === null || access !== (ctx.access ?? "metadata")) {
|
|
1127
1161
|
throw new Invalid("verification_failed", "scope_mismatch");
|
|
1128
1162
|
}
|
|
1129
1163
|
return {
|
|
@@ -1136,12 +1170,15 @@ export function workspaceContext(body, ctx, notices) {
|
|
|
1136
1170
|
},
|
|
1137
1171
|
retention: { metadata_days: body.retention.metadata_days },
|
|
1138
1172
|
content: { captured: body.content.captured, included: false },
|
|
1139
|
-
access: { scopes: [
|
|
1173
|
+
access: { scopes: [...scopes].sort() },
|
|
1140
1174
|
};
|
|
1141
1175
|
});
|
|
1142
1176
|
}
|
|
1143
1177
|
|
|
1144
|
-
|
|
1178
|
+
// access is the credential's: a Metadata credential is never offered a
|
|
1179
|
+
// sensitive capability; a Debug credential may be, and read commands still
|
|
1180
|
+
// never use one.
|
|
1181
|
+
function capabilityEntry(entry, reason, access = "metadata") {
|
|
1145
1182
|
if (
|
|
1146
1183
|
!isObject(entry) ||
|
|
1147
1184
|
typeof entry.available !== "boolean" ||
|
|
@@ -1161,8 +1198,10 @@ function capabilityEntry(entry, reason) {
|
|
|
1161
1198
|
entry.privacy_class === "metadata" &&
|
|
1162
1199
|
entry.required_scope === METADATA_SCOPE;
|
|
1163
1200
|
// checkCapabilities already refuses this; it is checked again here so a
|
|
1164
|
-
// summary can never show a sensitive capability as available.
|
|
1165
|
-
if (!metadataOnly && entry.available
|
|
1201
|
+
// summary can never show a sensitive capability as available to Metadata.
|
|
1202
|
+
if (!metadataOnly && entry.available && access === "metadata") {
|
|
1203
|
+
throw new Invalid("verification_failed", "content_access_granted");
|
|
1204
|
+
}
|
|
1166
1205
|
return {
|
|
1167
1206
|
available: entry.available,
|
|
1168
1207
|
content: entry.content,
|
|
@@ -1189,7 +1228,7 @@ export function capabilitySummary(body, ctx, notices) {
|
|
|
1189
1228
|
if (!isObject(body.agent)) throw new Invalid("verification_failed", reason);
|
|
1190
1229
|
const agent = {};
|
|
1191
1230
|
for (const name of CAPABILITY_NAMES) {
|
|
1192
|
-
agent[name] = Object.hasOwn(body.agent, name) ? capabilityEntry(body.agent[name], reason) : null;
|
|
1231
|
+
agent[name] = Object.hasOwn(body.agent, name) ? capabilityEntry(body.agent[name], reason, ctx.access) : null;
|
|
1193
1232
|
}
|
|
1194
1233
|
if (Object.keys(body.agent).some((name) => !CAPABILITY_NAMES.includes(name))) {
|
|
1195
1234
|
notices.add("unrecognized_capabilities_ignored");
|
|
@@ -1547,3 +1586,18 @@ function checkFilters(body, rows, request) {
|
|
|
1547
1586
|
}
|
|
1548
1587
|
}
|
|
1549
1588
|
}
|
|
1589
|
+
|
|
1590
|
+
// Shared with the experiments validators (experiments-contract.js), which
|
|
1591
|
+
// hold their documents to the same rules.
|
|
1592
|
+
export {
|
|
1593
|
+
Invalid,
|
|
1594
|
+
displayText,
|
|
1595
|
+
isAmount,
|
|
1596
|
+
isCount,
|
|
1597
|
+
isObject,
|
|
1598
|
+
isTimestamp,
|
|
1599
|
+
isToken,
|
|
1600
|
+
provenance,
|
|
1601
|
+
rowObject,
|
|
1602
|
+
run,
|
|
1603
|
+
};
|
package/src/read-output.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { WORKFLOW_PERMISSIONS } from "./constants.js";
|
|
1
2
|
import { authMessage } from "./output.js";
|
|
2
|
-
import { PLAIN, fields, headline, paragraph, render, steps, toneFor } from "./style.js";
|
|
3
|
+
import { PLAIN, fields, headline, paragraph, render, steps, toneFor, workspaceShown } from "./style.js";
|
|
3
4
|
|
|
4
5
|
// Fixed text for read command reasons and notices. Reasons shared with sign
|
|
5
6
|
// in and doctor fall back to their wording. Nothing here contains a value from
|
|
@@ -7,18 +8,18 @@ import { PLAIN, fields, headline, paragraph, render, steps, toneFor } from "./st
|
|
|
7
8
|
|
|
8
9
|
const READ_MESSAGES = {
|
|
9
10
|
not_signed_in:
|
|
10
|
-
'This project is not signed in and no
|
|
11
|
-
"or, where browser sign in cannot finish, give the CLI a coding-agent key with Metadata
|
|
12
|
-
"METERGRAPH_AGENT_TOKEN.",
|
|
11
|
+
'This project is not signed in and no coding-agent key was given. Run "metergraph login --runtime local" first, ' +
|
|
12
|
+
"or, where browser sign in cannot finish, give the CLI a coding-agent key with the Metadata or Debug role " +
|
|
13
|
+
"in METERGRAPH_AGENT_TOKEN.",
|
|
13
14
|
agent_key_rejected:
|
|
14
|
-
"The service refused the
|
|
15
|
+
"The service refused the coding-agent key: it is unknown, expired or revoked. Nothing was read.",
|
|
15
16
|
agent_key_insufficient_scope:
|
|
16
17
|
"The key is valid but lacks Metadata access to this workspace, for example because it is an ingest key. " +
|
|
17
18
|
"No broader access was requested. Nothing was read.",
|
|
18
19
|
agent_token_malformed:
|
|
19
20
|
"METERGRAPH_AGENT_TOKEN does not hold exactly one key with no label, spaces or extra lines. Its value is not " +
|
|
20
21
|
"shown. Nothing was sent.",
|
|
21
|
-
agent_token_missing: "No
|
|
22
|
+
agent_token_missing: "No coding-agent key was found. Nothing was sent.",
|
|
22
23
|
metergraph_url_invalid:
|
|
23
24
|
"METERGRAPH_URL is not a bare https origin, or an http origin on localhost. Its value is not shown. Nothing was sent.",
|
|
24
25
|
bound_to_other_origin:
|
|
@@ -28,13 +29,14 @@ const READ_MESSAGES = {
|
|
|
28
29
|
"This project's binding names a non-hosted origin, and a key is never sent to an origin taken only from a " +
|
|
29
30
|
"project file. Set METERGRAPH_URL or pass --url with the same origin to confirm it. Nothing was sent.",
|
|
30
31
|
scope_mismatch:
|
|
31
|
-
"The credential
|
|
32
|
+
"The credential has Replay access or a scope this CLI does not use, so it was not used. Use a credential with " +
|
|
33
|
+
"the Metadata or Debug role.",
|
|
32
34
|
content_access_granted:
|
|
33
|
-
"The
|
|
35
|
+
"The service offered content, replay or provider access to a Metadata credential, so it was not used.",
|
|
34
36
|
platform_credential_handoff:
|
|
35
37
|
"--agent-token-file cannot be checked for privacy on this platform. Use METERGRAPH_AGENT_TOKEN instead.",
|
|
36
38
|
capability_unavailable:
|
|
37
|
-
"The service does not offer this read to
|
|
39
|
+
"The service does not offer this read to this credential. No data was read and no broader access was requested.",
|
|
38
40
|
exceeds_service_bounds:
|
|
39
41
|
"The requested --days or --limit is larger than the service allows. It was not reduced automatically. No data was read.",
|
|
40
42
|
insufficient_scope: "The service refused this read because the grant lacks a required scope. No broader scope was requested.",
|
|
@@ -279,7 +281,7 @@ export function readText(result, message, style = PLAIN) {
|
|
|
279
281
|
statusRows(data, rows);
|
|
280
282
|
lines.push(...fields(style, rows));
|
|
281
283
|
} else {
|
|
282
|
-
rows.push(["Workspace", data.workspace
|
|
284
|
+
rows.push(["Workspace", workspaceShown(data.workspace)]);
|
|
283
285
|
if (result.ok) {
|
|
284
286
|
const body = [];
|
|
285
287
|
resultLines(result.command, data.result, body);
|
|
@@ -325,16 +327,30 @@ export function readText(result, message, style = PLAIN) {
|
|
|
325
327
|
["Connection guide", handoff ? next.url : null],
|
|
326
328
|
]));
|
|
327
329
|
}
|
|
330
|
+
const access = result.command === "status" && result.ok ? data.access : null;
|
|
331
|
+
const step = access && !allPermissions(access) && access.permissions_offered !== false
|
|
332
|
+
? permissionsStep(data.deployment_profile)
|
|
333
|
+
: null;
|
|
334
|
+
if (step !== null) lines.push("", ...steps(style, [["", `${PERMISSIONS_NEEDED} ${step}`]]));
|
|
328
335
|
return render(lines);
|
|
329
336
|
}
|
|
330
337
|
|
|
338
|
+
// The verified workspace by name and slug, and whether it is the bound one.
|
|
339
|
+
function statusWorkspace(workspace) {
|
|
340
|
+
const shown = workspaceShown(workspace);
|
|
341
|
+
if (workspace.actual === null) return workspace.intended === null ? "none" : "bound, not verified";
|
|
342
|
+
const name = shown ?? "verified";
|
|
343
|
+
if (workspace.match === false) return `${name}, which is not the workspace this project is bound to`;
|
|
344
|
+
return workspace.match === true ? `${name}, the bound workspace` : name;
|
|
345
|
+
}
|
|
346
|
+
|
|
331
347
|
function statusRows(data, rows) {
|
|
332
348
|
rows.push(
|
|
333
349
|
["Configured", data.configured ? "yes" : "no"],
|
|
334
|
-
["Credential", { oauth_grant: "saved sign in", agent_key: "
|
|
350
|
+
["Credential", { oauth_grant: "saved sign in", agent_key: "coding-agent key" }[data.credential] ?? "none"],
|
|
335
351
|
["Reachable", yesNo(data.reachable)],
|
|
336
352
|
["Authenticated", data.authenticated ? "yes, verified by the service" : "no"],
|
|
337
|
-
["Workspace",
|
|
353
|
+
["Workspace", statusWorkspace(data.workspace)],
|
|
338
354
|
["Deployment profile", `bound ${show(data.deployment_profile)}, reported by the service: ${
|
|
339
355
|
data.deployment_profile_verified === true
|
|
340
356
|
? "same"
|
|
@@ -348,12 +364,49 @@ function statusRows(data, rows) {
|
|
|
348
364
|
const flag = (name) => yesNo(data.capabilities[name]);
|
|
349
365
|
rows.push(
|
|
350
366
|
["Metadata reads", `usage ${flag("usage")}, routes ${flag("routes")}, traces ${flag("trace_metadata")}`],
|
|
351
|
-
["Content
|
|
367
|
+
["Content", data.content_access
|
|
368
|
+
? "available to this credential; the read commands never request it"
|
|
369
|
+
: "not available to this credential"],
|
|
352
370
|
);
|
|
353
371
|
}
|
|
372
|
+
if (data.access) rows.push(["Role", ROLE_LABELS[data.access.role] ?? "none"], ["Permissions", permissionsText(data.access)]);
|
|
354
373
|
rows.push(["Application traffic verified", "no"]);
|
|
355
374
|
}
|
|
356
375
|
|
|
376
|
+
// The role and permission labels the Keys and consent pages use.
|
|
377
|
+
const ROLE_LABELS = { metadata: "Metadata", debug: "Debug" };
|
|
378
|
+
|
|
379
|
+
const PERMISSIONS_NEEDED =
|
|
380
|
+
"Writing and testing evaluations and starting analyses need the Debug role with Allow evaluation writes, " +
|
|
381
|
+
"Allow evaluation previews and Allow analysis starts.";
|
|
382
|
+
|
|
383
|
+
const allPermissions = (access) =>
|
|
384
|
+
access.role === "debug" && WORKFLOW_PERMISSIONS.every(({ field }) => access.permissions[field] === true);
|
|
385
|
+
|
|
386
|
+
function permissionsText(access) {
|
|
387
|
+
if (access.permissions_offered === false) return "not offered by this deployment";
|
|
388
|
+
const granted = WORKFLOW_PERMISSIONS.filter(({ field }) => access.permissions[field] === true);
|
|
389
|
+
return granted.length > 0 ? granted.map(({ label }) => label).join(", ") : "none";
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// How a person gives this agent Debug with all three permissions, by
|
|
393
|
+
// deployment. Fixed text: no value from the service is printed.
|
|
394
|
+
export function permissionsStep(profile) {
|
|
395
|
+
if (profile === "local") {
|
|
396
|
+
return "From the bundle directory, a workspace owner runs ./bin/agent-key create --role debug " +
|
|
397
|
+
"--allow-evaluation-writes --allow-evaluation-previews --allow-analysis-starts " +
|
|
398
|
+
"--write-to ~/.config/metergraph/agent.env, then loads that file in the shell that starts the agent.";
|
|
399
|
+
}
|
|
400
|
+
if (profile === "managed") {
|
|
401
|
+
return 'Run "metergraph login --runtime local --role debug --allow-evaluation-writes ' +
|
|
402
|
+
'--allow-evaluation-previews --allow-analysis-starts"; a workspace owner approves it in the browser. ' +
|
|
403
|
+
'Or a workspace owner creates a coding-agent key on the Keys page, choosing "Debug with all three ' +
|
|
404
|
+
'permissions" under Access, saved as METERGRAPH_AGENT_TOKEN in a private file outside the project. ' +
|
|
405
|
+
"Do not paste a key into chat.";
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
357
410
|
function resultLines(command, report, lines) {
|
|
358
411
|
if (command === "context") {
|
|
359
412
|
const { workspace } = report;
|