@weirgate/cli 0.3.0 → 0.4.0

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.0**: `weirgate auth login` and the local secret-entry page exit as soon as the
44
+ browser is done (they used to wait on the browser's open connections). `config propose`
45
+ and `config validate` accept a `config export` file as is. `weirgate audit list
46
+ --include-platform-access` shows Weirgate's own access rows, which are hidden by default. New `weirgate usage
47
+ events` and `weirgate usage users`, and MCP tools `weirgate_export_usage_events` and
48
+ `weirgate_list_user_usage`.
49
+ - **0.3.1**: the CLI and `weirgate-mcp` refuse the platform super-admin key on every
50
+ command (exit code 4, `platform_key_rejected`) and ask for a tenant-scoped key, instead
51
+ of acting on the first tenant the platform key can see.
39
52
  - **0.3.0**: `weirgate models list` and the MCP tool `weirgate_list_models` (model prices
40
53
  and provider policy, with the reason a model isn't allowed); `weirgate audit list`
41
54
  pages with `--limit`, `--cursor`, and `--all`.
@@ -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,10 +100,8 @@ 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
105
  const stored = spawnSync("security", ["add-generic-password", "-U", "-s", "weirgate-cli", "-a", "default", "-w", key], { stdio: "ignore" });
107
106
  if (stored.status !== 0)
108
107
  throw invocation("Unable to store key in macOS Keychain; use WEIRGATE_API_KEY instead.");
@@ -144,11 +143,13 @@ async function commandToTool(client, noun, verb, flags) {
144
143
  "proposals:cancel": ["weirgate_cancel_proposal", { proposal_id: requiredFlag(flags, "proposal") }],
145
144
  "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
145
  "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") }],
146
+ "usage:events": ["weirgate_export_usage_events", { ...common, since: requiredFlag(flags, "since"), until: requiredFlag(flags, "until"), cursor: stringFlag(flags, "cursor"), limit: numberFlag(flags, "limit") }],
147
+ "usage:users": ["weirgate_list_user_usage", { ...common, since: requiredFlag(flags, "since"), until: requiredFlag(flags, "until"), cursor: stringFlag(flags, "cursor"), limit: numberFlag(flags, "limit") }],
147
148
  "usage:failures": ["weirgate_list_failures", { ...common, since: stringFlag(flags, "since"), until: stringFlag(flags, "until"), cursor: stringFlag(flags, "cursor"), limit: numberFlag(flags, "limit") }],
148
149
  "pricing-insights:get": ["weirgate_get_pricing_insights", { ...common, since: requiredFlag(flags, "since"), until: requiredFlag(flags, "until") }],
149
150
  "users:timeline": ["weirgate_get_user_timeline", { ...common, external_id: requiredFlag(flags, "user") }],
150
151
  "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") }],
152
+ "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
153
  "models:list": ["weirgate_list_models", { upstream: stringFlag(flags, "upstream"), modality: stringFlag(flags, "modality") }],
153
154
  "provider-keys:list": ["weirgate_list_provider_key_slots", common],
154
155
  "provider-keys:test": ["weirgate_test_provider_key", { ...common, provider: requiredFlag(flags, "provider") }],
@@ -179,12 +180,12 @@ async function commandToTool(client, noun, verb, flags) {
179
180
  tableView = modelsTable;
180
181
  if (noun === "audit" && flags.all) {
181
182
  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 });
183
+ 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
184
  }
184
185
  if (entry[0] === "__auth_presets")
185
186
  return client.request("/v1/admin/auth-presets");
186
187
  if (entry[0] === "__config_propose")
187
- return client.request("/v1/admin/config/proposals", { method: "POST", body: entry[1] });
188
+ return client.request("/v1/admin/config/proposals", { method: "POST", body: configDocument(entry[1]) });
188
189
  if (entry[0].startsWith("__tiers")) {
189
190
  const result = await invokeTool(client, "weirgate_get_app", common);
190
191
  const appData = result.data.app;
@@ -217,37 +218,7 @@ async function readSecret(flags) {
217
218
  }
218
219
  if (flags.browser === false)
219
220
  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
- });
221
+ return browserSecretEntry();
251
222
  }
252
223
  async function installMcp(flags) {
253
224
  const target = stringFlag(flags, "client") ?? "codex";
@@ -287,7 +258,7 @@ async function init(client, flags) {
287
258
  }
288
259
  }
289
260
  async function validateConfig(flags) {
290
- const data = await dataFlag(flags);
261
+ const data = configDocument(await dataFlag(flags));
291
262
  const valid = typeof data.tenant_id === "string" && Array.isArray(data.apps);
292
263
  if (!valid)
293
264
  throw new WeirgateApiError({ message: "Config must contain tenant_id and apps[]", errorType: "validation_error", status: 400, requestId: "local", apiVersion: API_VERSION });
@@ -354,7 +325,7 @@ function redact(value) { if (Array.isArray(value))
354
325
  return value.map(redact); if (!value || typeof value !== "object")
355
326
  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
327
  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`;
328
+ 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
329
  }
359
330
  function completion(shell) {
360
331
  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.0",
3
+ "version": "0.4.0",
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",