@weirgate/cli 0.3.1 → 0.4.1

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 CHANGED
@@ -32,10 +32,23 @@ in output. Exit codes are listed in the guide.
32
32
 
33
33
  `weirgate models list` shows model prices and provider policy (`--upstream`,
34
34
  `--modality`), and `weirgate audit list --all` reads the whole audit log (up to 10,000
35
- events; `--limit` and `--cursor` page by hand).
35
+ events; `--limit` and `--cursor` page by hand). `weirgate usage events` and `weirgate
36
+ usage users` page the usage export and the per-user, per-rail rollup.
37
+
38
+ `weirgate config export --output json > config.json` (or `--output yaml`), edit the file,
39
+ then `weirgate config propose --file config.json`: propose reads the exported file as is.
36
40
 
37
41
  ## Changes
38
42
 
43
+ - **0.4.1**: on macOS, `weirgate auth login` stores the key in the Keychain through
44
+ `security -i` on stdin, so the key no longer appears on the `security` command line,
45
+ where other local users and process listings could read it.
46
+ - **0.4.0**: `weirgate auth login` and the local secret-entry page exit as soon as the
47
+ browser is done (they used to wait on the browser's open connections). `config propose`
48
+ and `config validate` accept a `config export` file as is. `weirgate audit list
49
+ --include-platform-access` shows Weirgate's own access rows, which are hidden by default. New `weirgate usage
50
+ events` and `weirgate usage users`, and MCP tools `weirgate_export_usage_events` and
51
+ `weirgate_list_user_usage`.
39
52
  - **0.3.1**: the CLI and `weirgate-mcp` refuse the platform super-admin key on every
40
53
  command (exit code 4, `platform_key_rejected`) and ask for a tenant-scoped key, instead
41
54
  of acting on the first tenant the platform key can see.
@@ -0,0 +1,50 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createServer } from "node:http";
3
+ import { API_VERSION, WeirgateApiError } from "../shared/api-client.js";
4
+ import { closeLoopbackServer, LOOPBACK_HEADERS } from "./loopback-server.js";
5
+ /**
6
+ * One-time localhost page where a human enters a scoped key or provider key, so the value
7
+ * never appears as an argument, in shell history, or in the model's context. The server
8
+ * shuts down, connections included, once the value is posted (#268).
9
+ */
10
+ export function browserSecretEntry(dependencies = {}) {
11
+ const openExternal = dependencies.openExternal ?? openInBrowser;
12
+ const notify = dependencies.notify ?? ((message) => process.stderr.write(message));
13
+ return new Promise((resolveSecret, reject) => {
14
+ const server = createServer((request, response) => {
15
+ if (request.method === "GET") {
16
+ response.writeHead(200, { ...LOOPBACK_HEADERS, "content-type": "text/html; charset=utf-8" });
17
+ response.end("<!doctype html><title>Weirgate local secret entry</title><form method=post><label>Scoped key or provider key <input name=key type=password autofocus required></label><button>Continue</button></form><p>The value is sent only to this localhost process.</p>");
18
+ return;
19
+ }
20
+ let body = "";
21
+ request.on("data", (chunk) => { body += String(chunk); });
22
+ request.on("end", () => {
23
+ const key = new URLSearchParams(body).get("key") ?? "";
24
+ response.writeHead(key ? 200 : 400, { ...LOOPBACK_HEADERS, "content-type": "text/plain" });
25
+ response.end(key ? "Captured locally. You may close this tab." : "Missing value.");
26
+ closeLoopbackServer(server, response);
27
+ key ? resolveSecret(key) : reject(invalidInvocation("No secret entered."));
28
+ });
29
+ });
30
+ server.listen(0, "127.0.0.1", () => {
31
+ const address = server.address();
32
+ if (!address || typeof address === "string") {
33
+ closeLoopbackServer(server);
34
+ reject(invalidInvocation("Could not start local secret entry."));
35
+ return;
36
+ }
37
+ const url = `http://127.0.0.1:${address.port}`;
38
+ openExternal(url);
39
+ notify(`Opened one-time localhost secret entry: ${url}\n`);
40
+ });
41
+ });
42
+ }
43
+ function openInBrowser(url) {
44
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
45
+ const args = process.platform === "win32" ? ["/c", "start", url] : [url];
46
+ spawn(opener, args, { detached: true, stdio: "ignore" }).unref();
47
+ }
48
+ function invalidInvocation(message) {
49
+ return new WeirgateApiError({ message, errorType: "invalid_invocation", status: 400, requestId: "local", apiVersion: API_VERSION });
50
+ }
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { createServer } from "node:http";
4
4
  import { API_VERSION, WeirgateApiError } from "../shared/api-client.js";
5
+ import { closeLoopbackServer, LOOPBACK_HEADERS } from "./loopback-server.js";
5
6
  const SUCCESS_PATH = "/cli/access-granted";
6
7
  const FAILURE_PATH = "/cli/access-failed";
7
8
  export async function browserSessionGrant(input, dependencies = {}) {
@@ -28,17 +29,17 @@ export async function browserSessionGrant(input, dependencies = {}) {
28
29
  callbackInProgress = true;
29
30
  void exchangeSessionGrantCode(apiUrl, code, fetchImpl).then((grant) => {
30
31
  redirect(response, new URL(SUCCESS_PATH, dashboardUrl));
31
- finish(() => resolveGrant(grant));
32
+ finish(() => resolveGrant(grant), response);
32
33
  }).catch((error) => {
33
34
  redirect(response, new URL(FAILURE_PATH, dashboardUrl));
34
- finish(() => rejectGrant(error));
35
+ finish(() => rejectGrant(error), response);
35
36
  });
36
37
  });
37
- const finish = (settle) => {
38
+ const finish = (settle, last) => {
38
39
  if (settled)
39
40
  return;
40
41
  settled = true;
41
- server.close();
42
+ closeLoopbackServer(server, last);
42
43
  settle();
43
44
  };
44
45
  server.once("error", (error) => finish(() => rejectGrant(error)));
@@ -95,7 +96,7 @@ function requireHttpsUrls(input) {
95
96
  }
96
97
  function redirect(response, destination) {
97
98
  response.writeHead(303, {
98
- "cache-control": "no-store",
99
+ ...LOOPBACK_HEADERS,
99
100
  location: destination.toString(),
100
101
  "referrer-policy": "no-referrer",
101
102
  });
@@ -103,7 +104,7 @@ function redirect(response, destination) {
103
104
  }
104
105
  function respondText(response, status, message) {
105
106
  response.writeHead(status, {
106
- "cache-control": "no-store",
107
+ ...LOOPBACK_HEADERS,
107
108
  "content-type": "text/plain; charset=utf-8",
108
109
  "referrer-policy": "no-referrer",
109
110
  });
@@ -0,0 +1,21 @@
1
+ import { API_VERSION, record, WeirgateApiError } from "../shared/api-client.js";
2
+ /**
3
+ * The tenant config inside a `--file`/`--data` document for `config propose` and `config
4
+ * validate`. `weirgate config export --output json|yaml` prints the CLI's usual success
5
+ * envelope, `{ok, data, request_id, api_version}`; a document of that shape is unwrapped to
6
+ * its `data`, so an export round-trips to propose unchanged (#268). A bare config passes
7
+ * through. A config has no `ok` or `api_version` field, so the two shapes can't be confused.
8
+ */
9
+ export function configDocument(value) {
10
+ if (!("ok" in value && "api_version" in value))
11
+ return value;
12
+ if (value.ok !== true)
13
+ throw invalid("The file is a failed CLI result ({\"ok\": false, …}), not a config.");
14
+ const data = record(value.data);
15
+ if (Object.keys(data).length === 0)
16
+ throw invalid("The CLI result in the file has no config in data.");
17
+ return data;
18
+ }
19
+ function invalid(message) {
20
+ return new WeirgateApiError({ message, errorType: "invalid_invocation", status: 400, requestId: "local", apiVersion: API_VERSION });
21
+ }
package/dist/cli/index.js CHANGED
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { createServer } from "node:http";
3
2
  import { readFile, writeFile, mkdir } from "node:fs/promises";
4
3
  import { homedir } from "node:os";
5
4
  import { dirname, resolve } from "node:path";
6
- import { spawn, spawnSync } from "node:child_process";
5
+ import { spawnSync } from "node:child_process";
7
6
  import { stdin, stdout } from "node:process";
8
7
  import YAML from "yaml";
9
8
  import { AgentApiClient, API_VERSION, DEFAULT_API_URL, pathPart, WeirgateApiError } from "../shared/api-client.js";
10
9
  import { invokeTool } from "../shared/tools.js";
10
+ import { browserSecretEntry } from "./browser-secret-entry.js";
11
11
  import { browserSessionGrant } from "./browser-session-grant.js";
12
+ import { configDocument } from "./config-file.js";
12
13
  import { exitCodeFor } from "./exit-codes.js";
13
14
  import { AUDIT_ALL_CAP, listAllAuditEvents, modelsTable } from "./listing.js";
14
15
  let deferringRequiredFlags = false;
@@ -81,7 +82,7 @@ async function clientFromEnvironment(flags) {
81
82
  const key = process.env.WEIRGATE_API_KEY ?? keychainRead();
82
83
  if (!key)
83
84
  throw invocation("No key reference resolved. Set WEIRGATE_API_KEY or run `weirgate auth login`.");
84
- return new AgentApiClient({ apiKey: key, baseUrl: stringFlag(flags, "api-url") ?? process.env.WEIRGATE_API_URL ?? DEFAULT_API_URL });
85
+ return new AgentApiClient({ apiKey: key, baseUrl: stringFlag(flags, "api-url") ?? process.env.WEIRGATE_API_URL ?? DEFAULT_API_URL, requireTenantKey: true });
85
86
  }
86
87
  async function authCommand(verb, flags) {
87
88
  if (verb === "logout") {
@@ -99,18 +100,29 @@ async function authCommand(verb, flags) {
99
100
  dashboardUrl: stringFlag(flags, "dashboard-url") ?? "https://dashboard.weirgate.com",
100
101
  scope: stringFlag(flags, "scope") ?? "propose",
101
102
  }));
102
- const client = new AgentApiClient({ apiKey: key, baseUrl: apiUrl });
103
+ const client = new AgentApiClient({ apiKey: key, baseUrl: apiUrl, requireTenantKey: true });
103
104
  const identity = await client.whoami();
104
- if (identity.data.actor.type === "platform_super_admin")
105
- throw invocation("Platform super-admin credentials are rejected.");
106
- const stored = spawnSync("security", ["add-generic-password", "-U", "-s", "weirgate-cli", "-a", "default", "-w", key], { stdio: "ignore" });
107
- if (stored.status !== 0)
108
- throw invocation("Unable to store key in macOS Keychain; use WEIRGATE_API_KEY instead.");
105
+ keychainStore(key);
109
106
  return identity;
110
107
  }
111
108
  const client = await clientFromEnvironment(flags);
112
109
  return client.whoami();
113
110
  }
111
+ /**
112
+ * Stores the key without putting it in argv (visible to `ps` and exec telemetry):
113
+ * `security -i` reads the command from stdin. The key must be a plain `wgk_` key
114
+ * (AgentApiClient already enforces this), so it cannot break out of the one command line.
115
+ */
116
+ function keychainStore(key) {
117
+ if (!/^wgk_[a-f0-9]{64}$/.test(key))
118
+ throw invocation("Unable to store key in macOS Keychain; use WEIRGATE_API_KEY instead.");
119
+ const stored = spawnSync("/usr/bin/security", ["-i"], {
120
+ input: `add-generic-password -U -s weirgate-cli -a default -w ${key}\n`,
121
+ stdio: ["pipe", "ignore", "ignore"],
122
+ });
123
+ if (stored.status !== 0)
124
+ throw invocation("Unable to store key in macOS Keychain; use WEIRGATE_API_KEY instead.");
125
+ }
114
126
  function keychainRead() {
115
127
  const result = spawnSync("security", ["find-generic-password", "-s", "weirgate-cli", "-a", "default", "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
116
128
  return result.status === 0 ? result.stdout.trim() : undefined;
@@ -144,11 +156,13 @@ async function commandToTool(client, noun, verb, flags) {
144
156
  "proposals:cancel": ["weirgate_cancel_proposal", { proposal_id: requiredFlag(flags, "proposal") }],
145
157
  "proposals:apply": ["weirgate_apply_proposal", { proposal_id: requiredFlag(flags, "proposal"), expected_base_revision: numberFlag(flags, "expected-base-revision"), idempotency_key: requiredFlag(flags, "idempotency-key") }],
146
158
  "usage:query": ["weirgate_query_usage", { ...common, since: requiredFlag(flags, "since"), until: requiredFlag(flags, "until"), group_by: stringFlag(flags, "group-by"), bucket: stringFlag(flags, "bucket"), limit: numberFlag(flags, "limit") }],
159
+ "usage:events": ["weirgate_export_usage_events", { ...common, since: requiredFlag(flags, "since"), until: requiredFlag(flags, "until"), cursor: stringFlag(flags, "cursor"), limit: numberFlag(flags, "limit") }],
160
+ "usage:users": ["weirgate_list_user_usage", { ...common, since: requiredFlag(flags, "since"), until: requiredFlag(flags, "until"), cursor: stringFlag(flags, "cursor"), limit: numberFlag(flags, "limit") }],
147
161
  "usage:failures": ["weirgate_list_failures", { ...common, since: stringFlag(flags, "since"), until: stringFlag(flags, "until"), cursor: stringFlag(flags, "cursor"), limit: numberFlag(flags, "limit") }],
148
162
  "pricing-insights:get": ["weirgate_get_pricing_insights", { ...common, since: requiredFlag(flags, "since"), until: requiredFlag(flags, "until") }],
149
163
  "users:timeline": ["weirgate_get_user_timeline", { ...common, external_id: requiredFlag(flags, "user") }],
150
164
  "events:list": ["weirgate_list_events", common],
151
- "audit:list": ["weirgate_list_audit_events", { ...common, since: stringFlag(flags, "since"), until: stringFlag(flags, "until"), action: stringFlag(flags, "action"), limit: numberFlag(flags, "limit"), cursor: stringFlag(flags, "cursor") }],
165
+ "audit:list": ["weirgate_list_audit_events", { ...common, since: stringFlag(flags, "since"), until: stringFlag(flags, "until"), action: stringFlag(flags, "action"), limit: numberFlag(flags, "limit"), cursor: stringFlag(flags, "cursor"), include_platform_access: flags["include-platform-access"] === true ? true : undefined }],
152
166
  "models:list": ["weirgate_list_models", { upstream: stringFlag(flags, "upstream"), modality: stringFlag(flags, "modality") }],
153
167
  "provider-keys:list": ["weirgate_list_provider_key_slots", common],
154
168
  "provider-keys:test": ["weirgate_test_provider_key", { ...common, provider: requiredFlag(flags, "provider") }],
@@ -179,12 +193,12 @@ async function commandToTool(client, noun, verb, flags) {
179
193
  tableView = modelsTable;
180
194
  if (noun === "audit" && flags.all) {
181
195
  const input = entry[1];
182
- return listAllAuditEvents(client, { app_id: input.app_id, action: input.action, since: input.since, until: input.until }, { cursor: input.cursor, pageSize: input.limit });
196
+ return listAllAuditEvents(client, { app_id: input.app_id, action: input.action, since: input.since, until: input.until, include_platform_access: input.include_platform_access === true ? "true" : undefined }, { cursor: input.cursor, pageSize: input.limit });
183
197
  }
184
198
  if (entry[0] === "__auth_presets")
185
199
  return client.request("/v1/admin/auth-presets");
186
200
  if (entry[0] === "__config_propose")
187
- return client.request("/v1/admin/config/proposals", { method: "POST", body: entry[1] });
201
+ return client.request("/v1/admin/config/proposals", { method: "POST", body: configDocument(entry[1]) });
188
202
  if (entry[0].startsWith("__tiers")) {
189
203
  const result = await invokeTool(client, "weirgate_get_app", common);
190
204
  const appData = result.data.app;
@@ -217,37 +231,7 @@ async function readSecret(flags) {
217
231
  }
218
232
  if (flags.browser === false)
219
233
  throw invocation("Choose --browser or --stdin. Secret values are never positional arguments.");
220
- return browserSecretCeremony();
221
- }
222
- async function browserSecretCeremony() {
223
- return new Promise((resolveSecret, reject) => {
224
- const server = createServer((request, response) => {
225
- if (request.method === "GET") {
226
- response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
227
- response.end("<!doctype html><title>Weirgate local secret entry</title><form method=post><label>Scoped key or provider key <input name=key type=password autofocus required></label><button>Continue</button></form><p>The value is sent only to this localhost process.</p>");
228
- return;
229
- }
230
- let body = "";
231
- request.on("data", (chunk) => { body += String(chunk); });
232
- request.on("end", () => {
233
- const key = new URLSearchParams(body).get("key") ?? "";
234
- response.writeHead(key ? 200 : 400, { "content-type": "text/plain", "cache-control": "no-store" });
235
- response.end(key ? "Captured locally. You may close this tab." : "Missing value.");
236
- server.close();
237
- key ? resolveSecret(key) : reject(invocation("No secret entered."));
238
- });
239
- });
240
- server.listen(0, "127.0.0.1", () => {
241
- const address = server.address();
242
- if (!address || typeof address === "string")
243
- return reject(invocation("Could not start local secret entry."));
244
- const url = `http://127.0.0.1:${address.port}`;
245
- const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
246
- const args = process.platform === "win32" ? ["/c", "start", url] : [url];
247
- spawn(opener, args, { detached: true, stdio: "ignore" }).unref();
248
- process.stderr.write(`Opened one-time localhost secret entry: ${url}\n`);
249
- });
250
- });
234
+ return browserSecretEntry();
251
235
  }
252
236
  async function installMcp(flags) {
253
237
  const target = stringFlag(flags, "client") ?? "codex";
@@ -287,7 +271,7 @@ async function init(client, flags) {
287
271
  }
288
272
  }
289
273
  async function validateConfig(flags) {
290
- const data = await dataFlag(flags);
274
+ const data = configDocument(await dataFlag(flags));
291
275
  const valid = typeof data.tenant_id === "string" && Array.isArray(data.apps);
292
276
  if (!valid)
293
277
  throw new WeirgateApiError({ message: "Config must contain tenant_id and apps[]", errorType: "validation_error", status: 400, requestId: "local", apiVersion: API_VERSION });
@@ -354,7 +338,7 @@ function redact(value) { if (Array.isArray(value))
354
338
  return value.map(redact); if (!value || typeof value !== "object")
355
339
  return value; return Object.fromEntries(Object.entries(value).map(([key, item]) => [/^(key|secret|token|password|credential|authorization)$/i.test(key) ? key : key, /^(key|secret|token|password|credential|authorization)$/i.test(key) ? "[redacted]" : redact(item)])); }
356
340
  function help() {
357
- return `weirgate <noun> <verb> [flags]\n\nAuth: auth login|status|logout, mcp install, init\nApps: apps list|get|propose, config export|validate|propose|apply, auth-presets list\nConfig: features/tiers/rules/overrides/proposals, doctor, sandbox\nOps: usage, pricing-insights, users, user-tiers, events, audit, provider-keys, grants, docs\nModels: models list [--upstream openrouter|openai|anthropic|google|xai] [--modality chat|embedding|image|audio]\nAudit: audit list [--limit n] [--cursor c] [--all] (--all follows next_cursor, up to ${AUDIT_ALL_CAP} events)\nOutput: --output json|table|yaml; --json; mutations use --idempotency-key\nAuth login: browser consent by default; --stdin accepts a pre-minted key\nSecrets: provider-keys put|rotate defaults to --browser; --stdin rejects pipes without --allow-pipe\n`;
341
+ return `weirgate <noun> <verb> [flags]\n\nAuth: auth login|status|logout, mcp install, init\nApps: apps list|get|propose, config export|validate|propose|apply, auth-presets list\nConfig: features/tiers/rules/overrides/proposals, doctor, sandbox\nOps: usage, pricing-insights, users, user-tiers, events, audit, provider-keys, grants, docs\nModels: models list [--upstream openrouter|openai|anthropic|google|xai] [--modality chat|embedding|image|audio]\nUsage: usage query|failures|events|users (events: one row per settled request; users: per user and rail; --since/--until, --cursor, --limit)\nAudit: audit list [--limit n] [--cursor c] [--all] [--include-platform-access] (--all follows next_cursor, up to ${AUDIT_ALL_CAP} events)\nOutput: --output json|table|yaml; --json; mutations use --idempotency-key\nAuth login: browser consent by default; --stdin accepts a pre-minted key\nSecrets: provider-keys put|rotate defaults to --browser; --stdin rejects pipes without --allow-pipe\n`;
358
342
  }
359
343
  function completion(shell) {
360
344
  const commands = "auth mcp init apps config auth-presets models features tiers rules overrides proposals usage pricing-insights users user-tiers events audit provider-keys grants doctor sandbox docs completion";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Response headers for the CLI's one-shot loopback pages. `Connection: close` makes Node end
3
+ * each socket after its response, so the browser can't keep the connection alive (#268).
4
+ */
5
+ export const LOOPBACK_HEADERS = { "cache-control": "no-store", connection: "close" };
6
+ /**
7
+ * Stops a one-shot loopback server so the CLI process can exit (#268). `server.close()` alone
8
+ * waits for every open socket, and a browser keeps its connection (and any spare connection it
9
+ * opened ahead of time) alive. After `last` (the final response) is sent, this also destroys
10
+ * every remaining connection; waiting for it first keeps the final response from being cut off.
11
+ */
12
+ export function closeLoopbackServer(server, last) {
13
+ server.close();
14
+ const destroyRemaining = () => server.closeAllConnections();
15
+ if (!last || last.writableFinished)
16
+ destroyRemaining();
17
+ else {
18
+ last.once("finish", destroyRemaining);
19
+ last.once("close", destroyRemaining);
20
+ }
21
+ }
@@ -1,23 +1,25 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
3
- import { AgentApiClient, API_VERSION, WeirgateApiError } from "../shared/api-client.js";
3
+ import { AgentApiClient, API_VERSION, PLATFORM_KEY_REFUSAL, refusePlatformIdentity, WeirgateApiError } from "../shared/api-client.js";
4
4
  import { allowedTools, invokeTool, toolGroupAllowed, TOOL_DEFINITIONS } from "../shared/tools.js";
5
5
  export async function authenticateAgent(apiKey, baseUrl) {
6
- const client = new AgentApiClient({ apiKey, baseUrl });
6
+ const client = new AgentApiClient({ apiKey, baseUrl, requireTenantKey: true });
7
7
  const result = await client.whoami();
8
- if (result.data.actor.type === "platform_super_admin") {
8
+ refusePlatformIdentity(result);
9
+ return { client, identity: result.data, identityRequestId: result.request_id };
10
+ }
11
+ const rank = { read: 0, propose: 1, apply: 2 };
12
+ export function createMcpServer(auth) {
13
+ // Agent tools act on one tenant; never serve them to the platform key (#232).
14
+ if (auth.identity.actor.type === "platform_super_admin") {
9
15
  throw new WeirgateApiError({
10
- message: "The platform super-admin credential is never accepted by agent surfaces",
16
+ message: PLATFORM_KEY_REFUSAL,
11
17
  errorType: "platform_key_rejected",
12
18
  status: 403,
13
- requestId: result.request_id,
14
- apiVersion: result.api_version,
19
+ requestId: auth.identityRequestId,
20
+ apiVersion: API_VERSION,
15
21
  });
16
22
  }
17
- return { client, identity: result.data, identityRequestId: result.request_id };
18
- }
19
- const rank = { read: 0, propose: 1, apply: 2 };
20
- export function createMcpServer(auth) {
21
23
  const server = new Server({ name: "weirgate", version: API_VERSION }, {
22
24
  capabilities: { tools: { listChanged: false } },
23
25
  instructions: "Operate Weirgate only through the 2026-07-18 public API. Propose before apply. Provider keys are entered outside the model-facing MCP surface.",
package/dist/mcp/stdio.js CHANGED
@@ -1,9 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { WeirgateApiError } from "../shared/api-client.js";
3
4
  import { authenticateAgent, createMcpServer } from "./server.js";
4
5
  const apiKey = process.env.WEIRGATE_API_KEY;
5
6
  if (!apiKey)
6
7
  throw new Error("WEIRGATE_API_KEY must reference a minted scoped wgk_ key");
7
- const auth = await authenticateAgent(apiKey, process.env.WEIRGATE_API_URL);
8
- const server = createMcpServer(auth);
9
- await server.connect(new StdioServerTransport());
8
+ try {
9
+ const auth = await authenticateAgent(apiKey, process.env.WEIRGATE_API_URL);
10
+ const server = createMcpServer(auth);
11
+ await server.connect(new StdioServerTransport());
12
+ }
13
+ catch (error) {
14
+ if (!(error instanceof WeirgateApiError))
15
+ throw error;
16
+ // A refused key (platform key, bad shape, 401) is an expected stop, not a crash.
17
+ process.stderr.write(`weirgate-mcp: ${error.errorType}: ${error.message}\n`);
18
+ process.exit(1);
19
+ }
@@ -17,13 +17,34 @@ export class WeirgateApiError extends Error {
17
17
  this.detail = input.detail;
18
18
  }
19
19
  }
20
+ /**
21
+ * The refusal every agent surface (CLI, local stdio MCP, hosted MCP) gives a platform
22
+ * super-admin key. Agent tools act on one tenant; with the platform key they used to act on
23
+ * whichever tenant `/config` listed first (#232).
24
+ */
25
+ export const PLATFORM_KEY_REFUSAL = "The platform super-admin key is not accepted by Weirgate agent tools. Use a tenant-scoped key: mint a wgk_ key for one tenant (dashboard or POST /v1/admin/keys) and use that instead.";
26
+ /** Throws `platform_key_rejected` (403) when `whoami` identifies the platform super-admin key. */
27
+ export function refusePlatformIdentity(result) {
28
+ if (result.data.actor.type !== "platform_super_admin")
29
+ return;
30
+ throw new WeirgateApiError({
31
+ message: PLATFORM_KEY_REFUSAL,
32
+ errorType: "platform_key_rejected",
33
+ status: 403,
34
+ requestId: result.request_id,
35
+ apiVersion: result.api_version,
36
+ });
37
+ }
20
38
  export class AgentApiClient {
21
39
  baseUrl;
22
40
  apiKey;
41
+ /** When set, the first call checks `whoami` and refuses a platform key before any other request. */
42
+ requireTenantKey;
43
+ tenantKeyCheck;
23
44
  constructor(input) {
24
45
  if (!/^wgk_[a-f0-9]{64}$/.test(input.apiKey)) {
25
46
  throw new WeirgateApiError({
26
- message: "A minted wgk_ management key is required",
47
+ message: "A minted wgk_ management key is required. The platform super-admin key is not accepted by Weirgate agent tools; use a tenant-scoped key.",
27
48
  errorType: "invalid_api_key",
28
49
  status: 401,
29
50
  requestId: "local",
@@ -31,6 +52,7 @@ export class AgentApiClient {
31
52
  });
32
53
  }
33
54
  this.apiKey = input.apiKey;
55
+ this.requireTenantKey = input.requireTenantKey ?? false;
34
56
  this.baseUrl = (input.baseUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
35
57
  if (new URL(this.baseUrl).protocol !== "https:") {
36
58
  throw new WeirgateApiError({
@@ -43,6 +65,11 @@ export class AgentApiClient {
43
65
  }
44
66
  }
45
67
  async request(path, options = {}) {
68
+ if (this.requireTenantKey)
69
+ await this.whoami();
70
+ return this.send(path, options);
71
+ }
72
+ async send(path, options = {}) {
46
73
  const url = new URL(path, `${this.baseUrl}/`);
47
74
  for (const [key, value] of Object.entries(options.query ?? {})) {
48
75
  if (value !== undefined)
@@ -90,8 +117,17 @@ export class AgentApiClient {
90
117
  }
91
118
  return { data: payload, request_id: requestId, api_version: API_VERSION };
92
119
  }
120
+ /** With `requireTenantKey`, the identity is fetched once and a platform key is refused. */
93
121
  whoami() {
94
- return this.request("/v1/admin/whoami");
122
+ if (!this.requireTenantKey)
123
+ return this.send("/v1/admin/whoami");
124
+ this.tenantKeyCheck ??= this.send("/v1/admin/whoami").then((result) => {
125
+ refusePlatformIdentity(result);
126
+ return result;
127
+ });
128
+ // A failed check (network, 401) is retried on the next call rather than cached.
129
+ this.tenantKeyCheck.catch(() => { this.tenantKeyCheck = undefined; });
130
+ return this.tenantKeyCheck;
95
131
  }
96
132
  }
97
133
  export function pathPart(value) {
@@ -26,12 +26,14 @@ export const TOOL_DEFINITIONS = [
26
26
  { name: "weirgate_get_integration_snippet", title: "Get integration snippet", description: "Generate an HTTP/curl integration snippet.", scope: "read", group: "setup", inputSchema: objectSchema({ ...app, mode: { enum: ["http", "curl"] }, feature_id: str() }, appRequired) },
27
27
  { name: "weirgate_run_mock_call", title: "Run mock call", description: "Run a deterministic sandbox fixture; never invokes live inference.", scope: "apply", group: "sandbox", inputSchema: objectSchema({ ...app, fixture: str(), session_name: str(), idempotency_key: str() }, ["app_id", "fixture"]) },
28
28
  { name: "weirgate_query_usage", title: "Query usage", description: "Query windowed usage totals, groups, latency, refusals, and an optional day/hour series.", scope: "read", group: "usage", inputSchema: objectSchema({ ...app, since: str(), until: str(), group_by: { enum: ["feature", "user", "model", "funding_rail", "refusal_code"] }, bucket: { enum: ["day", "hour"] }, limit: integer() }, ["app_id", "since", "until"]) },
29
+ { name: "weirgate_export_usage_events", title: "Export usage events", description: "Export settled requests in [since, until), one row each, oldest first, for billing and reconciliation: request_id, upstream_generation_id (OpenRouter gen-… matches its Logs export), feature, user, funding_rail, upstream_provider, model, tokens, units_debited, provider_cost_usd (developer rail), user_cost_usd (user_key, best effort), latency. At most 1,000 rows per page; pass pagination.next_cursor back as cursor with the same since and until. End the window at least five minutes in the past for a complete export. Never add provider_cost_usd, user_cost_usd, and units together: they are different parties' money.", scope: "read", group: "usage", inputSchema: objectSchema({ ...app, since: str("Window start (inclusive), ISO 8601"), until: str("Window end (exclusive), ISO 8601"), cursor: str("next_cursor of the previous page"), limit: { type: "integer", minimum: 1, maximum: 1000 } }, ["app_id", "since", "until"]) },
30
+ { name: "weirgate_list_user_usage", title: "List usage by user and rail", description: "Sum settled usage per end user and funding rail over whole UTC days [since, until) (both UTC midnights): requests, prompt and completion tokens, units, provider_cost_usd, user_cost_usd, and cost_unreported_requests. Rows are ordered by user_id, then rail, at most 1,000 per page; pass pagination.next_cursor back as cursor with the same window. The sums equal the usage events export for the same window.", scope: "read", group: "usage", inputSchema: objectSchema({ ...app, since: str("First UTC midnight (inclusive), ISO 8601"), until: str("End UTC midnight (exclusive), ISO 8601"), cursor: str("next_cursor of the previous page"), limit: { type: "integer", minimum: 1, maximum: 1000 } }, ["app_id", "since", "until"]) },
29
31
  { name: "weirgate_list_failures", title: "List failures", description: "List recent refused and failed metered requests, newest first, with request ids for request traces.", scope: "read", group: "usage", inputSchema: objectSchema({ ...app, since: str(), until: str(), cursor: str(), limit: integer() }, appRequired) },
30
32
  { name: "weirgate_get_pricing_insights", title: "Get pricing insights", description: "Read closed-window pricing evidence.", scope: "read", group: "usage", inputSchema: objectSchema({ ...app, since: str(), until: str() }, ["app_id", "since", "until"]) },
31
33
  { name: "weirgate_get_user_timeline", title: "Get user timeline", description: "Read one user's tenant-scoped operational timeline.", scope: "read", group: "usage", inputSchema: objectSchema({ ...app, external_id: str() }, ["app_id", "external_id"]) },
32
34
  { name: "weirgate_list_events", title: "List events", description: "List webhook delivery events for an app.", scope: "read", group: "webhooks", inputSchema: objectSchema({ ...app, status: str(), cursor: str(), limit: integer() }, appRequired) },
33
35
  { name: "weirgate_get_provider_health", title: "Get provider health", description: "Get configured provider health from non-secret slot metadata.", scope: "read", group: "provider_keys", inputSchema: objectSchema(app, appRequired) },
34
- { name: "weirgate_list_audit_events", title: "List audit events", description: "List tenant-scoped management audit events.", scope: "read", group: "audit", inputSchema: objectSchema({ ...app, action: str(), since: str(), until: str(), cursor: str(), limit: integer() }) },
36
+ { name: "weirgate_list_audit_events", title: "List audit events", description: "List tenant-scoped management audit events. Weirgate's own access rows (platform_admin.request) are left out unless include_platform_access is true.", scope: "read", group: "audit", inputSchema: objectSchema({ ...app, action: str(), since: str(), until: str(), cursor: str(), limit: integer(), include_platform_access: { type: "boolean" } }) },
35
37
  { name: "weirgate_export_config", title: "Export config", description: "Export the visible tenant configuration.", scope: "read", group: "setup", inputSchema: objectSchema() },
36
38
  { name: "weirgate_propose_app", title: "Propose app", description: "Create a durable proposal to add or replace an app.", scope: "propose", group: "setup", inputSchema: objectSchema({ app: document() }, ["app"]) },
37
39
  { name: "weirgate_propose_auth", title: "Propose auth", description: "Create a durable proposal to change app authentication.", scope: "propose", group: "setup", inputSchema: objectSchema({ ...app, auth: document() }, ["app_id", "auth"]) },
@@ -121,6 +123,8 @@ export async function invokeTool(client, name, rawInput) {
121
123
  return client.request(`/v1/admin/apps/${a()}/sandbox/sessions/${pathPart(session.data.id)}/runs`, { method: "POST", body: { fixture: input.fixture } });
122
124
  }
123
125
  case "weirgate_query_usage": return client.request(`/v1/admin/apps/${a()}/usage`, { query: q(input, ["since", "until", "group_by", "bucket", "limit"]) });
126
+ case "weirgate_export_usage_events": return client.request(`/v1/admin/apps/${a()}/usage/events`, { query: q(input, ["since", "until", "cursor", "limit"]) });
127
+ case "weirgate_list_user_usage": return client.request(`/v1/admin/apps/${a()}/usage/users`, { query: q(input, ["since", "until", "cursor", "limit"]) });
124
128
  case "weirgate_list_failures": return client.request(`/v1/admin/apps/${a()}/failures`, { query: q(input, ["since", "until", "cursor", "limit"]) });
125
129
  case "weirgate_get_pricing_insights": return client.request(`/v1/admin/apps/${a()}/pricing-insights`, { query: q(input, ["since", "until"]) });
126
130
  case "weirgate_get_user_timeline": return client.request(`/v1/admin/apps/${a()}/users/${pathPart(input.external_id)}`);
@@ -131,7 +135,7 @@ export async function invokeTool(client, name, rawInput) {
131
135
  const target = findApp(config.data, String(input.app_id));
132
136
  return { ...config, data: { providers: slots.data, effective_feature_policy: Object.fromEntries(Object.entries(record(target.features)).map(([id, feature]) => [id, record(feature).provider_policy])) } };
133
137
  }
134
- case "weirgate_list_audit_events": return client.request("/v1/admin/audit", { query: q(input, ["app_id", "action", "since", "until", "cursor", "limit"]) });
138
+ case "weirgate_list_audit_events": return client.request("/v1/admin/audit", { query: { ...q(input, ["app_id", "action", "since", "until", "cursor", "limit"]), include_platform_access: input.include_platform_access === true ? "true" : undefined } });
135
139
  case "weirgate_export_config": return client.request("/v1/admin/config");
136
140
  case "weirgate_propose_app": return mutateConfig(client, (tenant) => { const list = tenant.apps ?? []; const next = record(input.app); tenant.apps = [...list.filter((x) => x.app_id !== next.app_id), next]; });
137
141
  case "weirgate_propose_auth": return mutateConfig(client, (tenant) => { findApp(tenant, String(input.app_id)).auth = input.auth; });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weirgate/cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Weirgate CLI and local stdio MCP server: scoped, proposal-first management of Weirgate apps.",
5
5
  "license": "MIT",
6
6
  "author": "Astraen Labs LLC",