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