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/account.js +48 -0
- package/bin/cli.js +73 -14
- package/bin/connect/configs.js +20 -6
- package/bin/desktop.js +557 -79
- package/bin/eval.js +328 -50
- package/bin/eval_entry.js +9 -2
- package/bin/eval_generation_contract.py +72 -0
- package/bin/eval_generation_debug.py +279 -0
- package/bin/eval_generation_status.py +284 -0
- package/bin/eval_harness.py +198 -4
- package/bin/eval_start.js +203 -0
- package/bin/eval_target.js +20 -1
- package/bin/eval_templates.js +447 -21
- package/bin/hook.js +21 -1
- package/bin/integrations.js +20 -4
- package/bin/probe.js +323 -0
- package/bin/runner.js +23 -0
- package/bin/shared.js +331 -14
- package/package.json +2 -2
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.
|