premanmcp 1.1.7 → 1.1.9

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.
@@ -33,12 +33,27 @@
33
33
  */
34
34
 
35
35
  import { createServer } from "node:http";
36
+ import { existsSync, readFileSync } from "node:fs";
37
+ import path from "node:path";
36
38
 
37
39
  import { askAgent } from "./agent_dialects.js";
38
- import { REPO_CONFIG, savedAgent } from "./target.js";
40
+ import { REPO_CONFIG, savedAgent, savedEntry } from "./target.js";
41
+
42
+ /**
43
+ * The placeholder the dashboard puts in the copyable command.
44
+ *
45
+ * Pasted unedited it has to mean "I did not answer this", not "look for a file
46
+ * called FILE" -- the command is built to be run verbatim, and refusing it
47
+ * would make the suggested line the one line that cannot work. Matched exactly
48
+ * rather than by shape, because a mistyped real path is a mistake worth
49
+ * reporting and must not be quietly turned into a search.
50
+ *
51
+ * Kept in step with `routes/eval_sessions.py::_command`, which writes it.
52
+ */
53
+ const ENTRY_PLACEHOLDER = "FILE:FUNCTION";
39
54
  import { createHash } from "node:crypto";
40
55
 
41
- import { backendUrl, resolveApiKey, storedKeyOrigin } from "./shared.js";
56
+ import { resolveApiKey, storedKeyOrigin } from "./shared.js";
42
57
 
43
58
  /**
44
59
  * The credential to present to the agent under test.
@@ -81,74 +96,23 @@ function sameDeployment(a, b) {
81
96
 
82
97
  export class TargetError extends Error {}
83
98
 
84
- /** Ports a locally-running PreMan backend is plausibly on, in order of likelihood. */
85
- const LOCAL_PORTS = [8000, 8001, 8010, 8011, 8002, 8080];
86
-
87
- const PROBE_TIMEOUT_MS = 1_500;
88
-
89
- /** The route that makes a service *the agent* rather than merely a PreMan service. */
90
- const AGENT_ROUTE = "/workbench/conversations/{conversation_id}/messages";
91
-
92
- async function get(url, { timeout = PROBE_TIMEOUT_MS } = {}) {
93
- const controller = new AbortController();
94
- const timer = setTimeout(() => controller.abort(), timeout);
95
- try {
96
- return await fetch(url, { signal: controller.signal });
97
- } finally {
98
- clearTimeout(timer);
99
- }
100
- }
101
-
102
- /**
103
- * Is the agent we know how to drive answering here?
104
- *
105
- * Two questions, and asking only the first is what makes discovery pick the
106
- * wrong thing. `/health` says a PreMan-shaped service is on this port; it does
107
- * not say the service has a chat agent. A developer's machine can easily have an
108
- * old build, a cut-down demo API, or a differently-scoped deployment answering
109
- * `/health` with the right name — and pointing an eval at one produces a run
110
- * where every case fails with a 404 that looks like the agent misbehaving.
111
- *
112
- * So the second question is asked of the service's own OpenAPI document: does it
113
- * publish the route the adapter is about to POST to. That is a capability check
114
- * rather than a version check, which means it keeps working when the route moves
115
- * behind a different service and stops working the moment it is renamed — both
116
- * of which are the answers you want.
117
- */
118
- async function probeAgent(url) {
119
- try {
120
- const health = await get(`${url}/health`);
121
- if (!health.ok) return { url, ok: false, why: `health answered ${health.status}` };
122
- const body = await health.json().catch(() => ({}));
123
- const service = String(body?.service || "");
124
- if (!/preman/i.test(service)) {
125
- return { url, ok: false, why: `something else is on this port (${service || "no service name"})` };
126
- }
127
-
128
- const spec = await get(`${url}/openapi.json`, { timeout: 6_000 });
129
- if (!spec.ok) return { url, ok: false, why: `a PreMan service, but it publishes no API document` };
130
- const document = await spec.json().catch(() => ({}));
131
- if (!document?.paths?.[AGENT_ROUTE]) {
132
- return { url, ok: false, why: `a PreMan service, but it has no chat agent on it` };
133
- }
134
- return { url, ok: true, service };
135
- } catch (error) {
136
- return { url, ok: false, why: error.name === "AbortError" ? "no answer" : error.message };
137
- }
138
- }
139
-
140
99
  /**
141
100
  * Work out which agent this eval is about.
142
101
  *
143
- * The order is deliberate and it is "most explicit first". A `--target` the
144
- * customer typed beats anything discovered, because they have already answered
145
- * the question and a tool that overrode them would be unusable in the one
146
- * environment they configured it for. Then a backend on this machine, because
147
- * somebody running an eval from a project directory with a dev server up almost
148
- * certainly means that one. Then the configured backend, which is the honest
149
- * fallback and is also the answer for anyone who is not a PreMan developer.
102
+ * The order is "most explicit first", and every step of it names something
103
+ * rather than guessing at it. There used to be a step that swept localhost for
104
+ * a PreMan `/health`, and it is gone: it could only ever match PreMan's own
105
+ * backend -- a customer's agent on port 3000 was rejected as "something else is
106
+ * on this port" -- so in any repository but this one it was not discovery, it
107
+ * was a way to silently evaluate the wrong process and report the score as if
108
+ * it were the customer's. Evaluating PreMan's own agent is still possible, but
109
+ * it has to be asked for now.
110
+ *
111
+ * What replaces it is the entry point somebody approved: the file in this
112
+ * repository that runs its agent, called in-process. That is the thing an eval
113
+ * was always about, and it cannot be confused with a neighbouring service.
150
114
  */
151
- export async function discoverTarget(args, { log = () => {} } = {}) {
115
+ export async function discoverTarget(args, { log = () => {}, allowSearch = true } = {}) {
152
116
  const explicit = String(args.value("--target", "") || "").trim();
153
117
  if (explicit) {
154
118
  return {
@@ -160,64 +124,138 @@ export async function discoverTarget(args, { log = () => {} } = {}) {
160
124
  };
161
125
  }
162
126
 
163
- // What the repository says its agent is, saved by `preman target detect`. It
164
- // ranks above discovery for the same reason `--target` does: somebody already
165
- // answered this question, and probing past their answer would measure a
166
- // different process than the one they pointed at. Below `--target` because a
167
- // flag is about this invocation and the file is about the project.
168
- const saved = savedAgent();
169
- if (saved) {
127
+ // PreMan's own chat agent, which used to be found by sweeping and is now
128
+ // named. Not probed, for the same reason `--target` is not: an address the
129
+ // caller typed is an answer, and checking it would only turn a clear failure
130
+ // at the first turn into a different message here.
131
+ const premanAgent = String(args.value("--preman-agent", "") || "").trim();
132
+ if (premanAgent) {
170
133
  return {
171
- kind: "external",
172
- agent: saved,
173
- url: `${saved.url}${saved.path}`,
174
- label: `your agent (${saved.url}${saved.path})`,
175
- how: `${REPO_CONFIG} names it, and it speaks ${saved.dialect}`,
134
+ kind: "preman-chat",
135
+ baseUrl: premanAgent,
136
+ // Held here and never sent to PreMan's control plane. The adapter is the
137
+ // only thing that presents it, which is why the adapter runs locally.
138
+ token: agentToken(args, premanAgent, log),
139
+ label: `PreMan agent (${premanAgent})`,
140
+ how: "you named it with --preman-agent",
176
141
  probed: [],
177
142
  };
178
143
  }
179
144
 
180
- const probed = [];
181
- for (const port of LOCAL_PORTS) {
182
- const result = await probeAgent(`http://127.0.0.1:${port}`);
183
- probed.push(result);
184
- if (result.ok) {
185
- log(`Found a PreMan backend on port ${port}.`);
186
- return {
187
- kind: "preman-chat",
188
- baseUrl: `http://127.0.0.1:${port}`,
189
- // The agent's own credential, held here and never sent to PreMan's
190
- // control plane. The adapter is the only thing that ever presents it,
191
- // which is the whole reason the adapter runs on this machine.
192
- token: agentToken(args, `http://127.0.0.1:${port}`, log),
193
- label: `PreMan agent (local, port ${port})`,
194
- how: `probed localhost and got a PreMan /health on port ${port}`,
195
- probed,
196
- };
197
- }
145
+ const projectPath = String(args.value("--path", "") || process.cwd());
146
+
147
+ // An entry point named for this invocation. Still generates and still asks:
148
+ // what `--entry` skips is the search, not the review.
149
+ const named = String(args.value("--entry", process.env.PREMAN_EVAL_ENTRY || "") || "").trim();
150
+ if (named && named !== ENTRY_PLACEHOLDER) {
151
+ const { entryFromNamedSymbol } = await import("./eval_entry.js");
152
+ return entryFromNamedSymbol(named, { projectPath, args, log });
153
+ }
154
+ if (named === ENTRY_PLACEHOLDER) {
155
+ log(
156
+ `--entry still has ${ENTRY_PLACEHOLDER} in it, so PreMan will look for the entry point itself.`
157
+ );
158
+ }
159
+
160
+ const saved = savedEntry(projectPath);
161
+ if (saved) {
162
+ const verified = verifyEntry(saved);
163
+ if (verified) return verified;
164
+ log(
165
+ `The approved entry point ${saved.file} has changed since it was approved, ` +
166
+ `so it is being re-checked rather than trusted.`
167
+ );
198
168
  }
199
169
 
200
- const configured = backendUrl(args);
201
- const remote = await probeAgent(configured);
202
- probed.push(remote);
203
- if (remote.ok) {
204
- log(`No local backend; using the one this CLI is configured against.`);
170
+ // What the repository says its agent is over HTTP, saved by
171
+ // `preman target detect`. Below the code entry point because a repository
172
+ // carrying both has deliberately run the search, and calling the agent is a
173
+ // more faithful measurement than calling a server that happens to be up.
174
+ const savedHttp = savedAgent(projectPath);
175
+ if (savedHttp) {
205
176
  return {
206
- kind: "preman-chat",
207
- baseUrl: configured,
208
- token: agentToken(args, configured, log),
209
- label: `PreMan agent (${configured})`,
210
- how: `nothing local answered, so this is the backend the CLI is configured against`,
211
- probed,
177
+ kind: "external",
178
+ agent: savedHttp,
179
+ url: `${savedHttp.url}${savedHttp.path}`,
180
+ label: `your agent (${savedHttp.url}${savedHttp.path})`,
181
+ how: `${REPO_CONFIG} names it, and it speaks ${savedHttp.dialect}`,
182
+ probed: [],
212
183
  };
213
184
  }
214
185
 
215
- throw new TargetError(
216
- `Could not find an agent to evaluate. Tried ${probed.length} address(es): ` +
217
- probed.map((p) => `${p.url} (${p.why})`).join(", ") +
218
- `. Point at one directly with --target <url>, where that URL accepts ` +
219
- `POST {"message","history"} and answers {"response"}.`
220
- );
186
+ // Writing a file into somebody's repository and running model-written code
187
+ // against their agent is not a thing to do because a job frame arrived. A
188
+ // daemon, a CI job and `--headless` all land here and stop.
189
+ if (!allowSearch || !process.stdout.isTTY || args.has("--headless") || args.has("--no-wait")) {
190
+ throw new TargetError(
191
+ `No approved entry point for this project, and nothing can be set up without ` +
192
+ `somebody to review it. Run \`preman eval target\` here once, approve the entry ` +
193
+ `point it finds, and this will work unattended afterwards. Or name one directly ` +
194
+ `with --entry <file>:<symbol>, or point at a running agent with --target <url>.`
195
+ );
196
+ }
197
+
198
+ const { searchForEntry } = await import("./eval_entry.js");
199
+ return searchForEntry({ projectPath, args, log });
200
+ }
201
+
202
+ /**
203
+ * The approved shim, if it is still the file that was approved.
204
+ *
205
+ * The digest is the whole point of recording one. An approval is of a specific
206
+ * text, and a shim edited afterwards -- by a person, a merge, or a tool -- has
207
+ * not been reviewed. Returning null sends the caller back through the gate
208
+ * rather than failing, because a hand-edited shim is usually an improvement
209
+ * somebody made on purpose and re-approving it takes one keystroke.
210
+ */
211
+ export function verifyEntry(saved) {
212
+ const file = path.isAbsolute(saved.file) ? saved.file : path.join(saved.root, saved.file);
213
+ if (!existsSync(file)) return null;
214
+ const source = readFileSync(file, "utf8");
215
+ const digest = createHash("sha256").update(source).digest("hex");
216
+ if (saved.sha256 && digest !== saved.sha256) return null;
217
+
218
+ // Every file the approval produced, not only the wrapper.
219
+ //
220
+ // A `node_bridge` target is two files: the Python wrapper and the `.mjs` it
221
+ // spawns. Checking one of them means the other can be edited after approval
222
+ // and still run, while the digest that was supposed to notice says the target
223
+ // is unchanged. An older record has no `files` and falls back to the single
224
+ // hash above, so an approval made before this check keeps working.
225
+ const extra = Array.isArray(saved.files) ? saved.files : [];
226
+ for (const item of extra) {
227
+ const rel = String(item?.path || "");
228
+ const want = String(item?.sha256 || "");
229
+ if (!rel || !want) return null;
230
+ const full = path.isAbsolute(rel) ? rel : path.join(saved.root, rel);
231
+ if (!existsSync(full)) return null;
232
+ const got = createHash("sha256").update(readFileSync(full, "utf8")).digest("hex");
233
+ if (got !== want) return null;
234
+ }
235
+
236
+ const sysPath = saved.sysPath
237
+ ? path.isAbsolute(saved.sysPath)
238
+ ? saved.sysPath
239
+ : path.join(saved.root, saved.sysPath)
240
+ : path.dirname(file);
241
+
242
+ return {
243
+ kind: "callable",
244
+ ref: saved.ref,
245
+ file,
246
+ sysPath,
247
+ sha256: digest,
248
+ files: extra,
249
+ shape: saved.shape || "",
250
+ language: saved.language,
251
+ framework: saved.framework,
252
+ entryFile: saved.entryFile,
253
+ label: `your agent (${saved.entryFile || saved.ref})`,
254
+ how:
255
+ `${REPO_CONFIG} names ${saved.file}, you approved it` +
256
+ `${saved.approvedAt ? ` on ${saved.approvedAt}` : ""}, and it is unchanged since`,
257
+ probed: [],
258
+ };
221
259
  }
222
260
 
223
261
  // ── What the agent said it was doing ────────────────────────────────────
@@ -582,6 +620,15 @@ export async function startAdapter(target, args, { log = () => {}, onStep = null
582
620
  return { url: target.url, close: async () => {}, translated: false };
583
621
  }
584
622
 
623
+ if (target.kind === "callable") {
624
+ // There is no socket. The harness imports the entry point and calls it in
625
+ // the same process, so there is nothing to listen on and nothing to
626
+ // translate -- the shim already returns what an adapter would have sent.
627
+ // Per-turn narration still happens, but it comes from the shim's own log
628
+ // rather than from requests arriving here; see `targetStats`.
629
+ return { url: "", close: async () => {}, translated: false };
630
+ }
631
+
585
632
  const state = {
586
633
  conversations: new Map(),
587
634
  turns: 0,