premanmcp 1.1.11 → 1.1.13

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/bin/probe.js ADDED
@@ -0,0 +1,323 @@
1
+ /**
2
+ * `preman probe` — fire a drawn population at the developer's own build.
3
+ *
4
+ * The local half of User simulation, and it exists because of topology rather
5
+ * than permission. The hosted swarm runs in a PreMan worker, so
6
+ * `http://127.0.0.1:3000` evaluated there names a port on *that worker* — the
7
+ * hosted path aimed at a laptop would send requests to the wrong computer, and
8
+ * `remote_target.py` refuses loopback outright for the same reason. No grant
9
+ * fixes that. The traffic has to start on the machine the software is on, which
10
+ * is this one.
11
+ *
12
+ * Three steps, and the middle one is the only part that happens here:
13
+ *
14
+ * 1. The backend draws the population and hands back a plan — a flat list of
15
+ * calls, each carrying the visit, cohort and action it belongs to.
16
+ * 2. This command makes those calls against the local build and times them.
17
+ * 3. The backend turns what came back into the same findings artifact the
18
+ * hosted worker writes, so a local run reads like a hosted one and lands in
19
+ * the same Findings tab.
20
+ *
21
+ * What this command deliberately does not decide:
22
+ *
23
+ * **What to send.** Read-only filtering happens server-side, in
24
+ * `local_runs.plan_calls`, so a write never reaches the plan. The check below
25
+ * is a second one rather than the only one — belt and braces on the promise the
26
+ * whole subsystem rests on, and cheap enough to be worth having on the machine
27
+ * where the requests actually leave.
28
+ *
29
+ * **What a call was.** Only a status, a latency and an error travel back.
30
+ * Method, path, cohort and action are re-derived on the server from the seeded
31
+ * draw, so nothing here can relabel a failure onto a cohort that did not have
32
+ * it — which matters because the per-cohort grouping is what the report claims.
33
+ */
34
+
35
+ import { resolveLocalTarget } from "./detect.js";
36
+ import { formatDashboardLink } from "./link.js";
37
+ import {
38
+ backendUrl,
39
+ callBackendJson,
40
+ cliInvocation,
41
+ describeFailure,
42
+ makeArgs,
43
+ nowMs,
44
+ resolveApiKey,
45
+ } from "./shared.js";
46
+
47
+ export const PROBE_HELP = `
48
+ Probe options:
49
+ --visits <n> How many simulated users to draw. Defaults to 100
50
+ --pack <key> Which population to draw from. Defaults to the first
51
+ --seed <n> Reuse a draw, so two runs are comparable. Defaults to 0
52
+ --port <port> Add an explicit candidate port for the local target
53
+ --intent <text> Why you are running this, kept verbatim on the report
54
+ --timeout <seconds> Overall wall-clock budget. Defaults to 300
55
+ --json Print the machine-readable result
56
+ `;
57
+
58
+ const DEFAULT_VISITS = 100;
59
+ const DEFAULT_TIMEOUT_SECONDS = 300;
60
+ const REQUEST_TIMEOUT_MS = 15_000;
61
+ /** Matches the hosted executor's semaphore. A laptop is not a cluster. */
62
+ const CONCURRENCY = 6;
63
+ const INVENTORY_PAGE_SIZE = 200;
64
+ const INVENTORY_MAX_ENDPOINTS = 2000;
65
+
66
+ /** The only verbs that leave, checked again here. Mirrors READ_ONLY_METHODS. */
67
+ const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
68
+
69
+ async function safeCall(args, method, routePath, options) {
70
+ try {
71
+ return await callBackendJson(args, method, routePath, options);
72
+ } catch (error) {
73
+ return {
74
+ ok: false,
75
+ status_code: 0,
76
+ detail: String(error?.message || error),
77
+ transport_error: true,
78
+ };
79
+ }
80
+ }
81
+
82
+ /**
83
+ * The saved endpoint inventory, which is what proves a candidate port is
84
+ * serving *this* repository rather than whatever else is listening.
85
+ *
86
+ * `resolveLocalTarget` needs it for that check, so a probe cannot be pointed
87
+ * at a stray dev server by accident.
88
+ */
89
+ async function fetchInventory(args, token) {
90
+ const endpoints = [];
91
+ for (let offset = 0; offset < INVENTORY_MAX_ENDPOINTS; offset += INVENTORY_PAGE_SIZE) {
92
+ const result = await safeCall(args, "GET", "/cli/endpoints", {
93
+ token,
94
+ query: { limit: INVENTORY_PAGE_SIZE, offset },
95
+ });
96
+ if (!result.ok) {
97
+ return { endpoints, error: describeFailure(result, `status ${result.status_code}`) };
98
+ }
99
+ const payload = result.result ?? result.data ?? result;
100
+ const page = payload?.endpoints || (Array.isArray(payload) ? payload : []) || [];
101
+ const rows = Array.isArray(page) ? page : [];
102
+ endpoints.push(...rows);
103
+ const pagination = payload?.pagination || payload?.result?.pagination;
104
+ if (!pagination?.has_more || rows.length === 0) break;
105
+ }
106
+ return { endpoints };
107
+ }
108
+
109
+ function joinUrl(base, routePath) {
110
+ if (/^https?:\/\//i.test(routePath)) return routePath;
111
+ return `${String(base).replace(/\/+$/, "")}/${String(routePath).replace(/^\/+/, "")}`;
112
+ }
113
+
114
+ /** One call against the local build, as the report's three fields. */
115
+ async function fireOne(origin, call) {
116
+ const method = String(call.method || "GET").toUpperCase();
117
+ if (!READ_METHODS.has(method)) {
118
+ // Should be unreachable: the server filters these out of the plan. If it
119
+ // ever happens, refusing locally is the behaviour that keeps the read-only
120
+ // promise true rather than merely intended.
121
+ return { index: call.index, status: null, latency_ms: null, error: "refused: not a read" };
122
+ }
123
+
124
+ const started = nowMs();
125
+ const controller = new AbortController();
126
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
127
+ try {
128
+ const response = await fetch(joinUrl(origin, call.path), {
129
+ method,
130
+ // Named distinctly from `PreMan-API-Tester`, matching SWARM_USER_AGENT.
131
+ // A customer excluding swarm traffic from their own analysis needs to
132
+ // exclude a swarm and not every PreMan caller.
133
+ headers: { "User-Agent": "PreMan-User-Simulation" },
134
+ redirect: "manual",
135
+ signal: controller.signal,
136
+ });
137
+ return {
138
+ index: call.index,
139
+ status: response.status,
140
+ latency_ms: Math.round(nowMs() - started),
141
+ error: "",
142
+ };
143
+ } catch (error) {
144
+ return {
145
+ index: call.index,
146
+ status: null,
147
+ latency_ms: Math.round(nowMs() - started),
148
+ error: String(error?.message || error).slice(0, 200),
149
+ };
150
+ } finally {
151
+ clearTimeout(timer);
152
+ }
153
+ }
154
+
155
+ /** A fixed-width worker pool, so a laptop is not asked to open 5,000 sockets. */
156
+ async function runPool(items, worker, limit) {
157
+ const results = [];
158
+ let cursor = 0;
159
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
160
+ while (cursor < items.length) {
161
+ const index = cursor++;
162
+ results[index] = await worker(items[index]);
163
+ }
164
+ });
165
+ await Promise.all(runners);
166
+ return results;
167
+ }
168
+
169
+ export async function probeCommand(commandArgs = []) {
170
+ const args = makeArgs(commandArgs);
171
+ const asJson = args.has("--json");
172
+ const budgetMs =
173
+ Math.max(5, Number.parseInt(args.value("--timeout", String(DEFAULT_TIMEOUT_SECONDS)), 10) ||
174
+ DEFAULT_TIMEOUT_SECONDS) * 1000;
175
+ const deadline = Date.now() + budgetMs;
176
+
177
+ const finish = (outcome) => {
178
+ if (asJson) {
179
+ process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
180
+ } else if (outcome.message) {
181
+ process.stdout.write(`${outcome.message}\n`);
182
+ }
183
+ if (outcome.exitCode) process.exitCode = outcome.exitCode;
184
+ return outcome;
185
+ };
186
+
187
+ const token = resolveApiKey(args);
188
+ if (!token) {
189
+ return finish({
190
+ state: "skipped",
191
+ reason: "no_credentials",
192
+ message: `[preman] no API key. Run \`${cliInvocation()} login\`.`,
193
+ exitCode: 1,
194
+ });
195
+ }
196
+
197
+ // The inventory first, because it is what makes "is this my app" answerable.
198
+ // A probe aimed at a port that merely responds is a probe reporting on
199
+ // somebody else's dev server.
200
+ const inventory = await fetchInventory(args, token);
201
+ if (inventory.error) {
202
+ return finish({
203
+ state: "skipped",
204
+ reason: "inventory_unavailable",
205
+ message: `[preman] could not read your endpoint inventory: ${inventory.error}`,
206
+ exitCode: 1,
207
+ });
208
+ }
209
+
210
+ const extraPorts = [];
211
+ const port = Number.parseInt(args.value("--port", ""), 10);
212
+ if (Number.isFinite(port) && port > 0) extraPorts.push(port);
213
+
214
+ const local = await resolveLocalTarget(inventory.endpoints, { extraPorts });
215
+ if (!local.target) {
216
+ return finish({
217
+ state: "skipped",
218
+ reason: "no_local_target",
219
+ message:
220
+ `[preman] could not find your app running locally: ${local.reason}. ` +
221
+ `Start it, then run \`${cliInvocation()} probe --port <port>\`.`,
222
+ exitCode: 1,
223
+ });
224
+ }
225
+
226
+ const target = local.target;
227
+ const origin = String(target.url || "").replace(/\/+$/, "");
228
+
229
+ if (!asJson) {
230
+ process.stdout.write(
231
+ `[preman] target ${origin} (${target.confidence} match, ` +
232
+ `${target.matched}/${target.expected} routes via ${target.via})\n`
233
+ );
234
+ }
235
+
236
+ const opened = await safeCall(args, "POST", "/simulation/local-runs", {
237
+ token,
238
+ body: {
239
+ origin,
240
+ visits: Math.max(
241
+ 1,
242
+ Number.parseInt(args.value("--visits", String(DEFAULT_VISITS)), 10) || DEFAULT_VISITS
243
+ ),
244
+ seed: Number.parseInt(args.value("--seed", "0"), 10) || 0,
245
+ intent: args.value("--intent", ""),
246
+ ...(args.value("--pack", "") ? { pack_key: args.value("--pack", "") } : {}),
247
+ },
248
+ });
249
+ if (!opened.ok) {
250
+ return finish({
251
+ state: "failed",
252
+ reason: "could_not_open_run",
253
+ message: `[preman] ${backendUrl(args)} refused the probe: ${describeFailure(
254
+ opened,
255
+ `status ${opened.status_code}`
256
+ )}`,
257
+ exitCode: 1,
258
+ });
259
+ }
260
+
261
+ const payload = opened.result ?? opened.data ?? opened;
262
+ const runId = String(payload.run_id || "");
263
+ const calls = Array.isArray(payload.calls) ? payload.calls : [];
264
+ const budget = payload.budget || {};
265
+
266
+ if (!asJson) {
267
+ process.stdout.write(
268
+ `[preman] probing ${origin} with ${budget.visits || 0} simulated users ` +
269
+ `(${calls.length} read-only requests)\n`
270
+ );
271
+ }
272
+
273
+ // Sliced to the deadline rather than abandoned at it. A run that stopped
274
+ // early still has findings worth writing, and the report says what was sent
275
+ // against what was planned -- so a partial probe reads as partial instead of
276
+ // as a smaller probe that went fine.
277
+ const results = [];
278
+ let stopped = "";
279
+ await runPool(
280
+ calls,
281
+ async (call) => {
282
+ if (Date.now() > deadline) {
283
+ stopped = stopped || "out_of_time";
284
+ return;
285
+ }
286
+ results.push(await fireOne(origin, call));
287
+ },
288
+ CONCURRENCY
289
+ );
290
+
291
+ const reported = await safeCall(args, "POST", `/simulation/local-runs/${encodeURIComponent(runId)}/report`, {
292
+ token,
293
+ body: { results, aborted_reason: stopped },
294
+ });
295
+ if (!reported.ok) {
296
+ return finish({
297
+ state: "failed",
298
+ reason: "could_not_report",
299
+ run_id: runId,
300
+ message: `[preman] fired ${results.length} requests but could not file the findings: ${describeFailure(
301
+ reported,
302
+ `status ${reported.status_code}`
303
+ )}`,
304
+ exitCode: 1,
305
+ });
306
+ }
307
+
308
+ const outcome = reported.result ?? reported.data ?? reported;
309
+ const link = formatDashboardLink({ args, label: "findings" });
310
+ return finish({
311
+ state: "completed",
312
+ run_id: runId,
313
+ origin,
314
+ verdict: outcome.verdict,
315
+ requests_sent: outcome.requests_sent,
316
+ requests_skipped: outcome.requests_skipped,
317
+ message:
318
+ `[preman] ${origin} ${outcome.verdict} — ${outcome.requests_sent} requests sent, ` +
319
+ `${outcome.requests_skipped} not attempted (writes and templated paths)` +
320
+ (link ? `\n${link}` : ""),
321
+ exitCode: 0,
322
+ });
323
+ }
package/bin/runner.js CHANGED
@@ -1136,6 +1136,29 @@ export async function runnerCommand(commandArgs = [], { resolveAgent = null } =
1136
1136
  const sub = commandArgs.find((value) => !value.startsWith("-")) || "status";
1137
1137
  let args = makeArgs(commandArgs);
1138
1138
 
1139
+ // `runner work` is what `eval run` always was: this machine offering itself
1140
+ // for queued work. It lives here now because that is the half of the product
1141
+ // this file is, and because the old spelling sat next to `preman eval` --
1142
+ // which a person types to measure their own agent -- reading as a synonym
1143
+ // for the opposite role. Both spellings keep working; only one says what it
1144
+ // does.
1145
+ if (sub === "work") {
1146
+ const { evalCommand } = await import("./eval.js");
1147
+ return evalCommand(
1148
+ ["run", ...commandArgs.filter((value) => value !== "work")],
1149
+ {
1150
+ makeArgs,
1151
+ registerRunner,
1152
+ readRunnerState,
1153
+ saveRunnerState,
1154
+ deviceId,
1155
+ runnerLoop,
1156
+ pairingIsLive,
1157
+ resolveAgent,
1158
+ }
1159
+ );
1160
+ }
1161
+
1139
1162
  // Pairing needs an agent id, and the only place that can be asked for one is
1140
1163
  // here: `--background` hands the work to a detached child with no terminal, so
1141
1164
  // a question deferred to it is a question nobody ever sees.