metergraph-cli 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +78 -14
  2. package/assets/skill/SKILL.md +124 -31
  3. package/assets/skill/manifest.json +3 -3
  4. package/assets/skills/manifest.json +31 -31
  5. package/assets/skills/metergraph-analyze/SKILL.md +161 -58
  6. package/assets/skills/metergraph-candidates/SKILL.md +50 -8
  7. package/assets/skills/metergraph-evals/SKILL.md +83 -16
  8. package/assets/skills/metergraph-investigate/SKILL.md +90 -34
  9. package/assets/skills/metergraph-iterate/SKILL.md +82 -12
  10. package/assets/skills/metergraph-local-bundle/SKILL.md +146 -128
  11. package/assets/skills/metergraph-model-swap/SKILL.md +142 -20
  12. package/assets/skills/metergraph-onboarding/SKILL.md +93 -49
  13. package/assets/skills/metergraph-report/SKILL.md +60 -15
  14. package/assets/skills/metergraph-workloads/SKILL.md +97 -13
  15. package/native/README.md +85 -0
  16. package/native/anchored-fs.c +243 -0
  17. package/native/bin/anchored-fs-darwin-arm64 +0 -0
  18. package/native/bin/anchored-fs-darwin-x64 +0 -0
  19. package/native/bin/anchored-fs-linux-arm64 +0 -0
  20. package/native/bin/anchored-fs-linux-x64 +0 -0
  21. package/native/build.mjs +55 -0
  22. package/native/manifest.json +9 -0
  23. package/native/targets.mjs +24 -0
  24. package/package.json +10 -3
  25. package/src/action-help.js +130 -0
  26. package/src/agent-key.js +21 -14
  27. package/src/anchored-fs.js +163 -0
  28. package/src/args.js +125 -17
  29. package/src/auth-binding.js +12 -7
  30. package/src/auth-callback.js +10 -2
  31. package/src/auth-login.js +111 -28
  32. package/src/auth-oauth.js +89 -45
  33. package/src/auth-session.js +19 -12
  34. package/src/auth-store.js +28 -7
  35. package/src/cli.js +52 -8
  36. package/src/constants.js +37 -9
  37. package/src/experiments-output.js +7 -6
  38. package/src/experiments.js +26 -17
  39. package/src/output.js +274 -79
  40. package/src/read-contract.js +42 -8
  41. package/src/read-output.js +66 -13
  42. package/src/read.js +90 -15
  43. package/src/repository-identity.js +2 -2
  44. package/src/setup-env-git.js +1 -1
  45. package/src/setup-env.js +16 -9
  46. package/src/setup-interactive.js +19 -4
  47. package/src/setup-refusal.js +2 -2
  48. package/src/setup-state.js +9 -4
  49. package/src/setup.js +15 -3
  50. package/src/skill-bundle.js +2 -2
  51. package/src/skill-trusted.js +56 -0
  52. package/src/skill.js +28 -9
  53. package/src/style.js +33 -7
  54. package/src/tools-output.js +73 -0
  55. package/src/tools.js +249 -0
  56. package/src/trace-contract.js +1 -1
  57. package/src/transport.js +21 -0
  58. package/src/verify-output.js +2 -2
  59. package/src/verify.js +5 -2
@@ -0,0 +1,163 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { createHash } from "node:crypto";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ // A process-local directory capability service. The shipped helper owns all
9
+ // descriptors; only its inherited anonymous pipes carry requests and contents.
10
+ // An absent or unverified backend is an error, never a pathname-write fallback.
11
+ const MAX = 2 * 1024 * 1024;
12
+ const base = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../native");
13
+ const errnoNames = new Map(Object.entries(os.constants.errno).map(([name, number]) => [number, name]));
14
+ let service = null;
15
+ let rollbackDepth = 0;
16
+ const aliases = new Map(process.platform === "darwin" ? [["/var", "/private/var"], ["/tmp", "/private/tmp"]] : []);
17
+ function failure(code) { const error = new Error(`Secure filesystem operation failed (${code}).`); error.code = code; return error; }
18
+ function verifiedHelper() {
19
+ const name = `anchored-fs-${process.platform}-${process.arch}`;
20
+ let file;
21
+ try {
22
+ const manifest = JSON.parse(fs.readFileSync(path.join(base, "manifest.json"), "utf8"));
23
+ file = path.join(base, "bin", name);
24
+ const bytes = fs.readFileSync(file);
25
+ if (manifest.schema !== 1 || typeof manifest.artifacts[name] !== "string" ||
26
+ createHash("sha256").update(bytes).digest("hex") !== manifest.artifacts[name]) throw failure("ENOTSUP");
27
+ } catch { throw failure("ENOTSUP"); }
28
+ return file;
29
+ }
30
+ // Test and diagnostic capability discovery verifies the same packaged bytes as
31
+ // start(). It never enables a platform merely because its name is recognised.
32
+ export function hasVerifiedHelper() {
33
+ try { verifiedHelper(); return true; } catch { return false; }
34
+ }
35
+ function start() {
36
+ if (service !== null) return service;
37
+ const file = verifiedHelper();
38
+ const child = spawn(file, [], { stdio: ["pipe", "pipe", "ignore"], windowsHide: true, env: {} });
39
+ child.on("error", () => { /* synchronous pipe operations report backend loss */ });
40
+ const input = child.stdin?._handle;
41
+ const output = child.stdout?._handle;
42
+ if (!input || !output || typeof input.setBlocking !== "function" || typeof output.setBlocking !== "function") {
43
+ child.kill("SIGKILL"); throw failure("ENOTSUP");
44
+ }
45
+ input.setBlocking(true); output.setBlocking(true);
46
+ service = { child, input: input.fd, output: output.fd };
47
+ child.unref(); child.stdin.unref(); child.stdout.unref();
48
+ return service;
49
+ }
50
+ function writeAll(fd, bytes) { let offset = 0; while (offset < bytes.length) { const count = fs.writeSync(fd, bytes, offset, bytes.length - offset); if (count === 0) throw failure("EIO"); offset += count; } }
51
+ function readAll(fd, bytes) { let offset = 0; while (offset < bytes.length) { const count = fs.readSync(fd, bytes, offset, bytes.length - offset, null); if (count === 0) throw failure("EIO"); offset += count; } }
52
+ function request(operation, args = [], payload = Buffer.alloc(0)) {
53
+ const encoded = args.map(value => Buffer.from(String(value)));
54
+ if (encoded.some(value => value.includes(0))) throw failure("EINVAL");
55
+ const size = 2 + encoded.reduce((n, value) => n + 4 + value.length, 0) + payload.length;
56
+ if (size > MAX) throw failure("EFBIG");
57
+ const body = Buffer.alloc(4 + size); body.writeUInt32BE(size); body[4] = operation; body[5] = args.length;
58
+ let offset = 6;
59
+ for (const value of encoded) { body.writeUInt32BE(value.length, offset); offset += 4; value.copy(body, offset); offset += value.length; }
60
+ payload.copy(body, offset);
61
+ const pipe = start(); writeAll(pipe.input, body);
62
+ const header = Buffer.alloc(8); readAll(pipe.output, header);
63
+ const error = header.readUInt32BE(); const length = header.readUInt32BE(4);
64
+ if (length > MAX) throw failure("EIO");
65
+ const result = Buffer.alloc(length); readAll(pipe.output, result);
66
+ if (error) throw failure(errnoNames.get(error) ?? "EIO");
67
+ return result;
68
+ }
69
+ function canonical(value) {
70
+ if (typeof value !== "string" || value.includes("\0")) throw failure("EINVAL");
71
+ let absolute = path.resolve(value);
72
+ const choices = [...aliases].sort((a,b) => b[0].length - a[0].length);
73
+ for (const [alias, target] of choices) if (absolute === alias || absolute.startsWith(`${alias}/`)) { absolute = target + absolute.slice(alias.length); break; }
74
+ return absolute;
75
+ }
76
+ function descriptor(value) { if (!Number.isSafeInteger(value) || value > -1001) throw failure("EBADF"); return -value - 1001; }
77
+ function statResult(bytes, options) {
78
+ if (bytes.length !== 88) throw failure("EIO");
79
+ const keys = ["dev", "ino", "mode", "nlink", "uid", "gid", "size"];
80
+ const result = {};
81
+ keys.forEach((key, index) => { const n = bytes.readBigUInt64BE(index*8); result[key] = options?.bigint ? n : Number(n); });
82
+ for (const [name, index] of [["mtime", 7], ["ctime", 9]]) {
83
+ const seconds = bytes.readBigInt64BE(index * 8);
84
+ const nanoseconds = bytes.readBigUInt64BE((index + 1) * 8);
85
+ if (nanoseconds >= 1000000000n) throw failure("EIO");
86
+ const ns = seconds * 1000000000n + nanoseconds;
87
+ result[`${name}Ms`] = options?.bigint ? ns / 1000000n : Number(seconds) * 1000 + Number(nanoseconds) / 1000000;
88
+ if (options?.bigint) result[`${name}Ns`] = ns;
89
+ result[name] = new Date(Number(ns / 1000000n));
90
+ }
91
+ const mode = Number(result.mode) & 0o170000;
92
+ return Object.assign(result, { isFile: () => mode === 0o100000, isDirectory: () => mode === 0o040000,
93
+ isSymbolicLink: () => mode === 0o120000, isFIFO: () => mode === 0o010000,
94
+ isSocket: () => mode === 0o140000, isBlockDevice: () => mode === 0o060000, isCharacterDevice: () => mode === 0o020000 });
95
+ }
96
+ export function validatePath(value) { request(13, [canonical(value)]); return true; }
97
+ export function withRollback(callback) {
98
+ if (rollbackDepth++ === 0) request(14, [1]);
99
+ try { return callback(); } finally { if (--rollbackDepth === 0) request(14, [0]); }
100
+ }
101
+ export function closeAnchoredFs() {
102
+ if (service !== null) { service.child.stdin.destroy(); service.child.stdout.destroy(); service.child.kill("SIGKILL"); service = null; }
103
+ }
104
+ const anchored = {
105
+ constants: fs.constants,
106
+ lstatSync(value, options) { return statResult(request(1, [canonical(value)]), options); },
107
+ statSync(value, options) { const result = this.lstatSync(value, options); if (result.isSymbolicLink()) throw failure("ELOOP"); return result; },
108
+ realpathSync(value) {
109
+ // Root selection is read-only. Pin the resolved identity immediately; every
110
+ // subsequent operation uses the helper's retained directory descriptors.
111
+ const absolute = path.resolve(value); const real = fs.realpathSync(absolute);
112
+ const prior = aliases.get(absolute);
113
+ if (prior !== undefined && prior !== real) throw failure("ESTALE");
114
+ // Freeze both the selected spelling and the capability spelling. A later
115
+ // root resolution cannot rebind an existing capability to another tree.
116
+ const pinned = aliases.get(real);
117
+ if (pinned !== undefined && pinned !== real) throw failure("ESTALE");
118
+ validatePath(real); aliases.set(real, real); aliases.set(absolute, real); return real;
119
+ },
120
+ existsSync(value) { try { this.lstatSync(value); return true; } catch (error) { if (["ENOENT", "ENOTDIR"].includes(error.code)) return false; throw error; } },
121
+ openSync(value, flags, mode = 0o666) {
122
+ let kind;
123
+ const extras = (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0);
124
+ const stripped = typeof flags === "number" ? flags & ~extras : flags;
125
+ if (flags === "r" || stripped === fs.constants.O_RDONLY) kind = 0;
126
+ else if (flags === "r+" || stripped === fs.constants.O_RDWR) kind = 1;
127
+ else if (flags === "wx" || flags === "wx+" || stripped === (fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL)) kind = 2;
128
+ else throw failure("ENOTSUP");
129
+ return -1001 - request(2, [canonical(value), kind, mode]).readUInt32BE();
130
+ },
131
+ fstatSync(fd, options) { return statResult(request(3, [descriptor(fd)]), options); },
132
+ readFileSync(value, options) {
133
+ const own = typeof value !== "number"; const fd = own ? this.openSync(value, "r") : value;
134
+ try { const bytes = request(4, [descriptor(fd)]); const encoding = typeof options === "string" ? options : options?.encoding; return encoding ? bytes.toString(encoding) : bytes; }
135
+ finally { if (own) this.closeSync(fd); }
136
+ },
137
+ readSync(fd, buffer, offset, length, position) {
138
+ const bytes = request(16, [descriptor(fd), length, position ?? 0]); bytes.copy(buffer, offset); return bytes.length;
139
+ },
140
+ writeFileSync(fd, value, options) {
141
+ if (typeof fd !== "number") throw failure("ENOTSUP");
142
+ const encoding = typeof options === "string" ? options : options?.encoding;
143
+ request(5, [descriptor(fd)], Buffer.isBuffer(value) ? value : Buffer.from(value, encoding));
144
+ },
145
+ fchmodSync(fd, mode) { request(6, [descriptor(fd), mode]); },
146
+ chmodSync(value, mode) { const fd = this.openSync(value, "r"); try { this.fchmodSync(fd, mode); } finally { this.closeSync(fd); } },
147
+ fsyncSync(fd) { request(7, [descriptor(fd)]); },
148
+ closeSync(fd) { request(8, [descriptor(fd)]); },
149
+ mkdirSync(value, options = {}) {
150
+ const absolute = canonical(value); const mode = typeof options === "number" ? options : options.mode ?? 0o777;
151
+ try { request(9, [absolute, mode]); }
152
+ catch (error) {
153
+ if (!options.recursive) throw error;
154
+ if (error.code === "ENOENT") { this.mkdirSync(path.dirname(absolute), {recursive:true, mode}); request(9, [absolute, mode]); }
155
+ else if (error.code !== "EEXIST" || !this.statSync(absolute).isDirectory()) throw error;
156
+ }
157
+ },
158
+ renameSync(from, to) { request(10, [canonical(from), canonical(to)]); },
159
+ unlinkSync(value) { request(11, [canonical(value)]); },
160
+ rmdirSync(value) { request(12, [canonical(value)]); },
161
+ linkSync(from, to) { request(15, [canonical(from), canonical(to)]); },
162
+ };
163
+ export default anchored;
package/src/args.js CHANGED
@@ -39,13 +39,24 @@ import {
39
39
  USAGE_DIMENSIONS,
40
40
  USAGE_MAX_FILTER_VALUES,
41
41
  USAGE_SORTS,
42
+ WORKFLOW_PERMISSIONS,
42
43
  } from "./constants.js";
43
44
  import { parseOrigin } from "./origin.js";
44
45
  import { groupedRequest, hasUnsafeText, isCursor, isSafeFilter, readQuery } from "./read-contract.js";
46
+ import { TOOL_ARGS_MAX_BYTES, TOOL_NAME } from "./tools.js";
45
47
 
46
48
  export const READ_COMMANDS = Object.freeze(["status", "context", "capabilities", "usage", "routes", "traces"]);
47
- const COMMANDS = new Set(["pricing", "doctor", "help", "skill", "skills", "login", "logout", "setup", "verify", "experiments", ...READ_COMMANDS]);
48
- const HELP_TOPICS = new Set(["pricing", "doctor", "skill", "skills", "login", "logout", "setup", "verify", "experiments", ...READ_COMMANDS]);
49
+ const COMMANDS = new Set(["pricing", "doctor", "help", "skill", "skills", "login", "logout", "setup", "verify", "experiments", "tools", ...READ_COMMANDS]);
50
+ const HELP_TOPICS = new Set(["agents", "exit-codes", "pricing", "doctor", "skill", "skills", "login", "logout", "setup", "verify", "experiments", "tools", ...READ_COMMANDS]);
51
+ export const TOOLS_ACTIONS = Object.freeze(["list", "call"]);
52
+ // The roles login asks for: Metadata, or Debug, to which --allow-* adds the
53
+ // workflow permissions a workspace owner approves. Replay is never asked for.
54
+ export const LOGIN_ROLES = Object.freeze(["metadata", "debug"]);
55
+ // Each --allow-* switch and the scope it asks for, by the names bin/agent-key
56
+ // and the Keys page use.
57
+ const LOGIN_PERMISSIONS = Object.freeze(
58
+ WORKFLOW_PERMISSIONS.map(({ field, scope }) => [`--${field.replaceAll("_", "-")}`, scope]),
59
+ );
49
60
  export const EXPERIMENT_ACTIONS = Object.freeze(["list", "estimate", "start", "get"]);
50
61
  const SKILL_ACTIONS = new Set(["install", "update"]);
51
62
  const SKILLS_ACTIONS = new Set(["install", "update", "list"]);
@@ -59,7 +70,7 @@ const OPTIONS = {
59
70
  doctor: new Set(["--url", "--timeout-ms"]),
60
71
  skill: new Set(["--client", "--runtime", "--project"]),
61
72
  skills: new Set(["--client", "--runtime", "--project"]),
62
- login: new Set(["--runtime", "--url", "--workspace", "--project", "--config-dir", "--timeout-ms"]),
73
+ login: new Set(["--runtime", "--url", "--workspace", "--role", "--project", "--config-dir", "--timeout-ms"]),
63
74
  setup: new Set(["--runtime", "--url", "--workspace", "--project", "--config-dir", "--env-file", "--client", "--timeout-ms", "--deployment", "--agent-token-file", "--repository"]),
64
75
  logout: new Set(["--project", "--config-dir"]),
65
76
  status: new Set([...READ_BASE, ...READ_KEY]),
@@ -77,6 +88,7 @@ const OPTIONS = {
77
88
  experiments: new Set([
78
89
  ...READ_BASE, ...READ_KEY, "--workload", "--limit", "--treatment", "--last-n", "--sample-model", "--track", "--id",
79
90
  ]),
91
+ tools: new Set([...READ_BASE, ...READ_KEY, "--name", "--args", "--args-file"]),
80
92
  };
81
93
  // Which experiments options each action takes, beyond READ_BASE and READ_KEY.
82
94
  const EXPERIMENT_OPTIONS = {
@@ -129,10 +141,11 @@ const USAGE_GROUPED = [
129
141
  // Options that take no value.
130
142
  const FLAGS = {
131
143
  pricing: new Set(["--batch"]),
132
- login: new Set(["--signup", "--no-browser", "--reconnect"]),
144
+ login: new Set(["--signup", "--no-browser", "--reconnect", ...LOGIN_PERMISSIONS.map(([flag]) => flag)]),
133
145
  setup: new Set(["--no-browser", "--repair", "--signup", "--reconnect", "--skip-skill", "--confirm-prerequisites", "--no-repository"]),
134
146
  verify: new Set(["--open", "--no-browser"]),
135
147
  usage: new Set(["--today"]),
148
+ tools: new Set(["--yes"]),
136
149
  };
137
150
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
138
151
 
@@ -156,13 +169,24 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
156
169
  export function parseArgs(argv) {
157
170
  const json = argv.includes("--json");
158
171
 
159
- if (argv.includes("--help") || argv.includes("-h")) {
160
- const topic = argv.find((arg) => HELP_TOPICS.has(arg)) ?? null;
161
- return { ok: true, command: "help", topic, json };
172
+ const helpCommand = argv.find(arg => arg !== "--json") === "help";
173
+ if (argv.includes("--help") || argv.includes("-h") || helpCommand) {
174
+ const parts = argv.filter(arg => !["--json", "--help", "-h", "--advanced"].includes(arg));
175
+ if (parts[0] === "help") parts.shift();
176
+ const topic = HELP_TOPICS.has(parts[0]) ? parts[0] : null;
177
+ const actions = { pricing: PRICING_ACTIONS, experiments: EXPERIMENT_ACTIONS,
178
+ skill: [...SKILL_ACTIONS], skills: [...SKILLS_ACTIONS] };
179
+ const action = actions[topic]?.includes(parts[1]) ? parts[1] : null;
180
+ if (helpCommand && (parts.length > (action ? 2 : topic ? 1 : 0))) {
181
+ return { ok: false, command: "help", json, code: "unknown_help_topic", outcome: "invalid_input",
182
+ message: 'Unknown help topic. Run "metergraph --help" for commands.' };
183
+ }
184
+ return { ok: true, command: "help", topic, ...(action ? { action } : {}), ...(argv.includes("--advanced") ? { advanced: true } : {}), json };
162
185
  }
163
186
 
164
187
  let command = null;
165
188
  let action = null;
189
+ let toolName = null;
166
190
  let topic = null;
167
191
  let version = false;
168
192
  const values = {};
@@ -170,7 +194,7 @@ export function parseArgs(argv) {
170
194
 
171
195
  const fail = (code, message, outcome = "invalid_input") => ({
172
196
  ok: false,
173
- command: (command === "skill" || command === "skills" || command === "experiments") && action !== null
197
+ command: (command === "skill" || command === "skills" || command === "experiments" || command === "tools") && action !== null
174
198
  ? `${command} ${action}`
175
199
  : command ?? (version ? "version" : null),
176
200
  json,
@@ -213,6 +237,14 @@ export function parseArgs(argv) {
213
237
  action = arg;
214
238
  continue;
215
239
  }
240
+ if (command === "tools" && action === null && TOOLS_ACTIONS.includes(arg)) {
241
+ action = arg;
242
+ continue;
243
+ }
244
+ if (command === "tools" && action === "call" && toolName === null && !arg.startsWith("-")) {
245
+ toolName = arg;
246
+ continue;
247
+ }
216
248
 
217
249
  if (READ_COMMANDS.includes(command)) {
218
250
  const equals = arg.indexOf("=");
@@ -280,6 +312,9 @@ export function parseArgs(argv) {
280
312
  `Unknown experiments subcommand at argument ${position}. Use list, estimate, start or get.`,
281
313
  );
282
314
  }
315
+ if (command === "tools" && action === null && !arg.startsWith("-")) {
316
+ return fail("unknown_subcommand", `Unknown tools subcommand at argument ${position}. Use list or call.`);
317
+ }
283
318
  return fail(
284
319
  "unknown_argument",
285
320
  `Unrecognized argument at position ${position}. Run "metergraph --help" for usage.`,
@@ -297,6 +332,7 @@ export function parseArgs(argv) {
297
332
  if (command === "setup") return parseSetup(values, flags, json, fail);
298
333
  if (command === "verify") return parseVerify(values, flags, json, fail);
299
334
  if (command === "experiments") return parseExperiments(action, values, json, fail);
335
+ if (command === "tools") return parseTools(action, toolName, values, flags, json, fail);
300
336
  if (READ_COMMANDS.includes(command)) return parseRead(command, values, flags, json, fail);
301
337
  if (command === "logout") {
302
338
  const paths = parsePaths(values, fail);
@@ -322,16 +358,29 @@ export function parseArgs(argv) {
322
358
 
323
359
  // --url, --workspace and --agent-token-file, shared by the read commands and
324
360
  // experiments: { ok: true, origin, workspace, agentTokenFile } or a failure.
361
+ // --workspace names a workspace by its slug (on its Settings page, and in
362
+ // "metergraph context"), by its name, or by its ID. Returns { workspace,
363
+ // workspaceText }: workspace is an ID, workspaceText a slug or name, which is
364
+ // matched against the workspace the service reports. null for a bad value.
365
+ function workspaceOption(raw) {
366
+ if (raw === undefined) return { workspace: null, workspaceText: null };
367
+ if (UUID.test(raw)) return { workspace: raw.toLowerCase(), workspaceText: null };
368
+ const text = raw.trim();
369
+ if (text.length === 0 || text.length > MAX_WORKSPACE_TEXT || hasUnsafeText(text)) return null;
370
+ return { workspace: null, workspaceText: text };
371
+ }
372
+ const MAX_WORKSPACE_TEXT = 200;
373
+ const INVALID_WORKSPACE =
374
+ "--workspace takes the workspace's slug, shown on its Settings page and by \"metergraph context\", or its name.";
375
+
325
376
  function parseKeyTarget(values, fail) {
326
377
  let origin = null;
327
378
  if (values["--url"] !== undefined) {
328
379
  origin = parseOrigin(values["--url"]);
329
380
  if (origin === null) return fail("invalid_url", INVALID_URL);
330
381
  }
331
- const rawWorkspace = values["--workspace"];
332
- if (rawWorkspace !== undefined && !UUID.test(rawWorkspace)) {
333
- return fail("invalid_workspace", "--workspace must be a workspace ID in UUID form.");
334
- }
382
+ const named = workspaceOption(values["--workspace"]);
383
+ if (named === null) return fail("invalid_workspace", INVALID_WORKSPACE);
335
384
  const agentTokenFile = values["--agent-token-file"] ?? null;
336
385
  if (agentTokenFile !== null && (agentTokenFile === "" || agentTokenFile.includes("\0"))) {
337
386
  return fail("invalid_agent_token_file", "--agent-token-file must name a private absolute file.");
@@ -339,11 +388,61 @@ function parseKeyTarget(values, fail) {
339
388
  return {
340
389
  ok: true,
341
390
  origin,
342
- workspace: rawWorkspace === undefined ? null : rawWorkspace.toLowerCase(),
391
+ ...named,
343
392
  agentTokenFile,
344
393
  };
345
394
  }
346
395
 
396
+ // tools list [--name TOOL] | call TOOL [--args JSON | --args-file PATH] [--yes].
397
+ // The credential options are the read commands'. Arguments are checked for
398
+ // shape only; the service validates them against the tool's schema.
399
+ function parseTools(action, toolName, values, flags, json, fail) {
400
+ if (action === null) {
401
+ return fail("missing_subcommand", 'tools requires a subcommand: list or call. Run "metergraph help tools" for usage.');
402
+ }
403
+ const allowed = action === "list" ? ["--name"] : ["--args", "--args-file"];
404
+ for (const name of Object.keys(values)) {
405
+ if (!READ_BASE.includes(name) && !READ_KEY.includes(name) && !allowed.includes(name)) {
406
+ return fail("unknown_argument", `tools ${action} does not take ${name}. Run "metergraph help tools" for usage.`);
407
+ }
408
+ }
409
+ if (action === "list" && flags.has("--yes")) {
410
+ return fail("unknown_argument", 'tools list does not take --yes. Run "metergraph help tools" for usage.');
411
+ }
412
+ const name = action === "list" ? values["--name"] ?? null : toolName;
413
+ if (action === "call" && name === null) {
414
+ return fail("missing_tool", 'tools call needs a tool name, such as metergraph_list_classified_workloads. Run "metergraph tools list" to see them.');
415
+ }
416
+ if (name !== null && !TOOL_NAME.test(name)) {
417
+ return fail("invalid_tool", "A tool name is metergraph_ followed by lower case letters, digits and underscores.");
418
+ }
419
+ if (values["--args"] !== undefined && values["--args-file"] !== undefined) {
420
+ return fail("conflicting_options", "Give --args or --args-file, not both.");
421
+ }
422
+ if (values["--args"] !== undefined && Buffer.byteLength(values["--args"]) > TOOL_ARGS_MAX_BYTES) {
423
+ return fail("args_too_large", "--args is larger than 1 MiB. Use --args-file.");
424
+ }
425
+ if (values["--args-file"] !== undefined && (values["--args-file"] === "" || values["--args-file"].includes("\0"))) {
426
+ return fail("invalid_args_file", "--args-file must name a file.");
427
+ }
428
+ const paths = parsePaths(values, fail);
429
+ if (!paths.ok) return paths;
430
+ const rawTimeout = values["--timeout-ms"];
431
+ const timeoutMs = rawTimeout === undefined
432
+ ? READ_DEFAULT_TIMEOUT_MS
433
+ : parseTimeout(rawTimeout, READ_MIN_TIMEOUT_MS, READ_MAX_TIMEOUT_MS);
434
+ if (timeoutMs === null) {
435
+ return fail("invalid_timeout", `--timeout-ms must be a whole number from ${READ_MIN_TIMEOUT_MS} to ${READ_MAX_TIMEOUT_MS}.`);
436
+ }
437
+ const target = parseKeyTarget(values, fail);
438
+ if (!target.ok) return target;
439
+ delete target.ok;
440
+ return {
441
+ ok: true, command: "tools", action, ...target, project: paths.project, configDir: paths.configDir, timeoutMs, json,
442
+ tool: { name, args: values["--args"] ?? null, argsFile: values["--args-file"] ?? null, yes: flags.has("--yes") },
443
+ };
444
+ }
445
+
347
446
  // experiments list | estimate | start | get. estimate and start take the same
348
447
  // request, so an agent starts exactly what it priced: the workload, 1 to 3
349
448
  // --treatment PROVIDER/MODEL[,label=..][,temperature=..][,max_tokens=..]
@@ -596,9 +695,16 @@ function parseLogin(values, flags, json, fail) {
596
695
  }
597
696
  const origin = parseUrlOption(values);
598
697
  if (origin === null) return fail("invalid_url", INVALID_URL);
599
- const rawWorkspace = values["--workspace"];
600
- if (rawWorkspace !== undefined && !UUID.test(rawWorkspace)) {
601
- return fail("invalid_workspace", "--workspace must be a workspace ID in UUID form.");
698
+ const named = workspaceOption(values["--workspace"]);
699
+ if (named === null) return fail("invalid_workspace", INVALID_WORKSPACE);
700
+ const role = values["--role"] ?? "metadata";
701
+ if (!LOGIN_ROLES.includes(role)) return fail("invalid_role", "--role must be metadata or debug.");
702
+ const permissions = LOGIN_PERMISSIONS.filter(([flag]) => flags.has(flag)).map(([, scope]) => scope);
703
+ if (permissions.length > 0 && role !== "debug") {
704
+ return fail(
705
+ "permission_needs_debug",
706
+ "--allow-evaluation-writes, --allow-evaluation-previews and --allow-analysis-starts need --role debug.",
707
+ );
602
708
  }
603
709
  const paths = parsePaths(values, fail);
604
710
  if (!paths.ok) return paths;
@@ -618,7 +724,9 @@ function parseLogin(values, flags, json, fail) {
618
724
  command: "login",
619
725
  runtime,
620
726
  origin,
621
- workspace: rawWorkspace === undefined ? null : rawWorkspace.toLowerCase(),
727
+ ...named,
728
+ role,
729
+ permissions,
622
730
  project: paths.project,
623
731
  configDir: paths.configDir,
624
732
  timeoutMs,
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import fs from "node:fs";
2
+ import fs, { withRollback } from "./anchored-fs.js";
3
3
  import path from "node:path";
4
4
 
5
5
  import { Stop, isSlot } from "./auth-store.js";
@@ -8,8 +8,9 @@ import { parseOrigin } from "./origin.js";
8
8
 
9
9
  // The project binding, .metergraph/project.json, names which origin,
10
10
  // workspace and profile a project is signed in to and which credential slot
11
- // in the user's private store holds the grant. It holds no credential, user
12
- // name or absolute path, so it is safe to commit. Other files in .metergraph,
11
+ // in the user's private store holds the grant. The private grant also pins the
12
+ // canonical checkout, so copying this file never adopts or revokes that grant.
13
+ // It holds no credential, user name or absolute path. Other files in .metergraph,
13
14
  // such as skill receipts, are never touched.
14
15
 
15
16
  export const BINDING_PATH = ".metergraph/project.json";
@@ -25,7 +26,8 @@ export function resolveProject(project) {
25
26
  try {
26
27
  const real = fs.realpathSync(path.resolve(project ?? process.cwd()));
27
28
  if (fs.statSync(real).isDirectory()) return real;
28
- } catch {
29
+ } catch (error) {
30
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
29
31
  // Fall through to the fixed error.
30
32
  }
31
33
  throw new Stop("invalid_input", "invalid_project");
@@ -44,6 +46,7 @@ export function readBinding(root) {
44
46
  return { binding, content };
45
47
  } catch (error) {
46
48
  if (error instanceof Stop) throw error;
49
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
47
50
  throw new Stop("filesystem_error", "read_failed");
48
51
  }
49
52
  }
@@ -82,12 +85,13 @@ export function writeBinding(root, binding, previous) {
82
85
  } catch (error) {
83
86
  if (created) {
84
87
  try {
85
- fs.rmdirSync(dir);
88
+ withRollback(() => fs.rmdirSync(dir));
86
89
  } catch {
87
90
  // Not empty. An empty directory is harmless.
88
91
  }
89
92
  }
90
93
  if (error instanceof Stop) throw error;
94
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
91
95
  throw new Stop("filesystem_error", "binding_write_failed");
92
96
  }
93
97
  }
@@ -102,6 +106,7 @@ export function removeBinding(root, previous) {
102
106
  });
103
107
  } catch (error) {
104
108
  if (error instanceof Stop) throw error;
109
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
105
110
  throw new Stop("filesystem_error", "binding_write_failed");
106
111
  }
107
112
  }
@@ -118,7 +123,7 @@ function withProjectLock(dir, work) {
118
123
  return work();
119
124
  } finally {
120
125
  try {
121
- fs.unlinkSync(lock);
126
+ withRollback(() => fs.unlinkSync(lock));
122
127
  } catch {
123
128
  // Reported by the next run as a held lock.
124
129
  }
@@ -145,7 +150,7 @@ function replace(dir, content) {
145
150
  fs.renameSync(temp, dest);
146
151
  } catch (error) {
147
152
  try {
148
- fs.unlinkSync(temp);
153
+ withRollback(() => fs.unlinkSync(temp));
149
154
  } catch {
150
155
  // Already renamed or never created.
151
156
  }
@@ -19,6 +19,9 @@ const ALLOWED_PARAMS = new Set(["code", "state", "error", "error_description", "
19
19
  // The setup receipt a combined sign-in approval returns next to the code.
20
20
  // Accepted only by a wait that asked for it.
21
21
  export const SETUP_CODE_PARAM = "ingest_setup_code";
22
+ // The fixed error_description values a consent page sends when the workspace
23
+ // the CLI named matches none, or more than one, of the person's workspaces.
24
+ const HINT_REFUSALS = ["workspace_hint_ambiguous", "workspace_hint_unknown"];
22
25
 
23
26
  const PAGE_HEADERS = {
24
27
  "content-type": "text/html; charset=utf-8",
@@ -39,7 +42,11 @@ const PAGE_HEADERS = {
39
42
  // { kind: "code", code[, setupCode] } setupCode, the ingest_setup_code
40
43
  // value, only when wait() was given
41
44
  // acceptSetupCode and it was sent
42
- // { kind: "denied" } the user or server declined
45
+ // { kind: "denied", hint } the user or server declined; hint
46
+ // is workspace_hint_ambiguous or
47
+ // workspace_hint_unknown when the
48
+ // consent page refused the workspace
49
+ // the CLI named, else null
43
50
  // { kind: "error", reason } authorization_error,
44
51
  // callback_issuer_mismatch or
45
52
  // callback_invalid
@@ -163,8 +170,9 @@ function inspect(request, { host, state, issuer, requireIss, acceptSetupCode })
163
170
  if (seen.has("code") || seen.has(SETUP_CODE_PARAM)) {
164
171
  return { status: 400, result: { kind: "error", reason: "callback_invalid" } };
165
172
  }
173
+ const description = params.get("error_description");
166
174
  return params.get("error") === "access_denied"
167
- ? { status: 200, result: { kind: "denied" } }
175
+ ? { status: 200, result: { kind: "denied", hint: HINT_REFUSALS.includes(description) ? description : null } }
168
176
  : { status: 200, result: { kind: "error", reason: "authorization_error" } };
169
177
  }
170
178
  const code = params.get("code");