@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 +14 -1
- package/dist/cli/browser-secret-entry.js +50 -0
- package/dist/cli/browser-session-grant.js +7 -6
- package/dist/cli/config-file.js +21 -0
- package/dist/cli/index.js +13 -42
- package/dist/cli/loopback-server.js +21 -0
- package/dist/mcp/server.js +12 -10
- package/dist/mcp/stdio.js +13 -3
- package/dist/shared/api-client.js +38 -2
- package/dist/shared/tools.js +6 -2
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
+
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
16
|
+
message: PLATFORM_KEY_REFUSAL,
|
|
11
17
|
errorType: "platform_key_rejected",
|
|
12
18
|
status: 403,
|
|
13
|
-
requestId:
|
|
14
|
-
apiVersion:
|
|
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
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/shared/tools.js
CHANGED
|
@@ -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; });
|