@tpsdev-ai/flair 0.32.0 → 0.34.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 +64 -64
- package/SECURITY.md +7 -0
- package/config.yaml +34 -0
- package/dist/cli.js +659 -111
- package/dist/component-env.js +286 -0
- package/dist/deploy.js +190 -3
- package/dist/doctor-client.js +357 -7
- package/dist/hook-install.js +39 -9
- package/dist/lib/auth-resolve.js +85 -2
- package/dist/lib/launchd-management.js +328 -0
- package/dist/lib/mcp-enable.js +19 -0
- package/dist/resources/AdminInstance.js +20 -2
- package/dist/resources/Memory.js +24 -2
- package/dist/resources/OAuth.js +41 -25
- package/dist/resources/auth-middleware.js +26 -0
- package/dist/resources/dcr-gate.js +194 -0
- package/dist/resources/in-process-api.js +5 -1
- package/dist/resources/mcp-handler.js +91 -4
- package/dist/resources/mcp-oauth.js +89 -7
- package/dist/resources/mcp-tools.js +40 -0
- package/dist/resources/oauth-discovery.js +242 -0
- package/dist/resources/oauth-wellknown.js +111 -0
- package/dist/resources/rate-limit.js +400 -0
- package/docs/auth.md +122 -5
- package/docs/deploying-on-fabric.md +35 -2
- package/docs/deployment.md +1 -1
- package/docs/embedding-in-a-harper-app.md +6 -1
- package/docs/hosted-on-fabric.md +1 -1
- package/docs/mcp-clients.md +28 -4
- package/docs/quickstart.md +29 -4
- package/docs/the-team.md +8 -4
- package/docs/troubleshooting.md +37 -1
- package/package.json +1 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* launchd-management.ts — "is this instance actually under launchd?", and
|
|
3
|
+
* "why did launchd refuse to run it?" (flair#1022).
|
|
4
|
+
*
|
|
5
|
+
* `flair upgrade` on macOS restarts through launchd and falls back to a plain
|
|
6
|
+
* detached spawn when a launchd operation fails. The fallback is the right
|
|
7
|
+
* behaviour — a running instance beats a down one — but it changes a load
|
|
8
|
+
* bearing property of the install that nothing was measuring: the process is no
|
|
9
|
+
* longer owned by a service manager, so it does not come back after a reboot.
|
|
10
|
+
* The reported incident ended in `verified: healthy, authenticated, running
|
|
11
|
+
* <new version>`, every word of which was true, while the instance had just
|
|
12
|
+
* been orphaned. **Healthy and managed are different claims** and only the
|
|
13
|
+
* first was being made.
|
|
14
|
+
*
|
|
15
|
+
* Two things live here, and the split matters:
|
|
16
|
+
*
|
|
17
|
+
* 1. `assessLaunchdManagement()` — an OBSERVATION taken after the fact.
|
|
18
|
+
* Deliberately not a flag threaded down from whichever function did the
|
|
19
|
+
* falling back: `flair upgrade` hands its restart to the NEWLY INSTALLED
|
|
20
|
+
* CLI in a child process (flair#905), so no in-process bookkeeping
|
|
21
|
+
* survives the boundary. Asking launchd directly, at verification time,
|
|
22
|
+
* is the only form of this check that works on both the delegated and
|
|
23
|
+
* the in-process path — and it also catches a detachment this run did
|
|
24
|
+
* not cause.
|
|
25
|
+
*
|
|
26
|
+
* 2. `diagnoseLaunchdPlistPaths()` — a PRE-FLIGHT taken before the fact, and
|
|
27
|
+
* the reason the incident took two minutes to produce no information.
|
|
28
|
+
* **`launchctl load` and `launchctl start` both exit 0 for a job whose
|
|
29
|
+
* program does not exist.** Measured on macOS 15, not assumed: loading a
|
|
30
|
+
* plist whose ProgramArguments[0] points at a deleted path succeeds,
|
|
31
|
+
* `start` succeeds, and the only evidence of the failure is a nonzero
|
|
32
|
+
* `LastExitStatus` and a missing `PID` in `launchctl list <label>` after
|
|
33
|
+
* the fact. So the CLI's launchd path cannot learn anything from
|
|
34
|
+
* launchctl's exit codes; it waits the full startup budget for a port
|
|
35
|
+
* that was never going to open, and then falls back.
|
|
36
|
+
*
|
|
37
|
+
* A plist records absolute paths — the node binary (`process.execPath` at
|
|
38
|
+
* `flair init` time), Harper's entrypoint under that same install's
|
|
39
|
+
* `node_modules`, and the package working directory. Switch Node runtimes
|
|
40
|
+
* with a version manager and every one of them can move. That is knowable
|
|
41
|
+
* from a `readFileSync` and an `existsSync`, in microseconds, and it names
|
|
42
|
+
* both the stale path and the fix.
|
|
43
|
+
*
|
|
44
|
+
* Never logs plist CONTENTS. The plist embeds HDB_ADMIN_PASSWORD; only
|
|
45
|
+
* extracted program/working-directory paths ever reach a message, and the
|
|
46
|
+
* extractor below reads exactly those keys rather than returning the document.
|
|
47
|
+
*/
|
|
48
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
49
|
+
import { unescapeXml } from "./xml-escape.js";
|
|
50
|
+
/**
|
|
51
|
+
* Ceiling on the `launchctl list` spawn. A status query answers instantly when
|
|
52
|
+
* launchd is reachable; this only exists so an unreachable service manager
|
|
53
|
+
* cannot turn a post-upgrade summary line into a hang. Same reasoning as
|
|
54
|
+
* scheduler-platform's STATUS_CHECK_TIMEOUT_MS.
|
|
55
|
+
*/
|
|
56
|
+
export const LAUNCHCTL_QUERY_TIMEOUT_MS = 5_000;
|
|
57
|
+
/**
|
|
58
|
+
* Extract ONLY the exec-related paths from a plist: ProgramArguments and
|
|
59
|
+
* WorkingDirectory.
|
|
60
|
+
*
|
|
61
|
+
* Regex rather than a plist parser for the same reason `readPlistRootPath`
|
|
62
|
+
* uses one — this reads documents `buildLaunchdPlist` wrote, the shape is
|
|
63
|
+
* fixed, and a parser would pull the whole `EnvironmentVariables` dict
|
|
64
|
+
* (including the admin password) into memory to answer a question about two
|
|
65
|
+
* keys. Values are XML-escaped on the way in, so they are unescaped on the way
|
|
66
|
+
* out; a path containing `&` is stored as `&` and returned as `&`.
|
|
67
|
+
*
|
|
68
|
+
* Returns null when the file cannot be read at all. A readable plist with
|
|
69
|
+
* neither key returns an empty/null refs object, which callers treat as "no
|
|
70
|
+
* evidence" rather than "broken" — a hand-written plist that uses `Program`
|
|
71
|
+
* instead of `ProgramArguments` is not ours to judge.
|
|
72
|
+
*/
|
|
73
|
+
export function readPlistProgramRefs(plistPath, read = (p) => readFileSync(p, "utf-8")) {
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = read(plistPath);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const programArguments = [];
|
|
82
|
+
const argsBlock = raw.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
|
|
83
|
+
if (argsBlock) {
|
|
84
|
+
for (const m of argsBlock[1].matchAll(/<string>([^<]*)<\/string>/g)) {
|
|
85
|
+
programArguments.push(unescapeXml(m[1]));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const wd = raw.match(/<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/string>/);
|
|
89
|
+
return { programArguments, workingDirectory: wd ? unescapeXml(wd[1]) : null };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Does this plist still point at things that exist?
|
|
93
|
+
*
|
|
94
|
+
* Only ABSOLUTE paths are checked. `ProgramArguments` for a Flair service is
|
|
95
|
+
* `[<node>, <harper entrypoint>, "run", "."]` — the trailing literals are
|
|
96
|
+
* arguments, not paths, and an install whose plist was hand-edited to use a
|
|
97
|
+
* relative program is resolved by launchd against WorkingDirectory in a way
|
|
98
|
+
* this check has no business second-guessing. A missing absolute path, by
|
|
99
|
+
* contrast, is not ambiguous: launchd cannot exec it, and will not say so.
|
|
100
|
+
*
|
|
101
|
+
* The remedy is `flair init` because init unconditionally rewrites the plist
|
|
102
|
+
* from `process.execPath` and the currently-resolved Harper entrypoint — it is
|
|
103
|
+
* what re-points a service at the Node the operator is actually using now —
|
|
104
|
+
* followed by a restart to bring the job up under the rewritten plist.
|
|
105
|
+
*/
|
|
106
|
+
export function diagnoseLaunchdPlistPaths(plistPath, deps = {}) {
|
|
107
|
+
const exists = deps.exists ?? existsSync;
|
|
108
|
+
const refs = readPlistProgramRefs(plistPath, deps.read);
|
|
109
|
+
if (!refs)
|
|
110
|
+
return null;
|
|
111
|
+
const remedy = ["flair init", "flair restart"];
|
|
112
|
+
const rewriteNote = "`flair init` rewrites the plist against the Node runtime in use now, and `flair restart` " +
|
|
113
|
+
"brings the job back up under it.";
|
|
114
|
+
for (const arg of refs.programArguments) {
|
|
115
|
+
if (!arg.startsWith("/"))
|
|
116
|
+
continue;
|
|
117
|
+
if (exists(arg))
|
|
118
|
+
continue;
|
|
119
|
+
return {
|
|
120
|
+
kind: "ProgramArguments",
|
|
121
|
+
stalePath: arg,
|
|
122
|
+
message: `the launchd plist at ${plistPath} runs ${arg}, which no longer exists. ` +
|
|
123
|
+
`launchd cannot exec a missing program and reports no error for it — ` +
|
|
124
|
+
`load and start both succeed and the service never comes up. ` +
|
|
125
|
+
`A plist commonly goes stale like this after switching Node runtimes, ` +
|
|
126
|
+
`which moves both the node binary and the globally installed package tree. ` +
|
|
127
|
+
rewriteNote,
|
|
128
|
+
remedy,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const wd = refs.workingDirectory;
|
|
132
|
+
if (wd && wd.startsWith("/") && !exists(wd)) {
|
|
133
|
+
return {
|
|
134
|
+
kind: "WorkingDirectory",
|
|
135
|
+
stalePath: wd,
|
|
136
|
+
message: `the launchd plist at ${plistPath} sets WorkingDirectory to ${wd}, which no longer exists. ` +
|
|
137
|
+
`launchd refuses to spawn a job whose working directory is missing, and reports no error for it — ` +
|
|
138
|
+
`load and start both succeed and the service never comes up. ` +
|
|
139
|
+
rewriteNote,
|
|
140
|
+
remedy,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Parse `launchctl list <label>` output.
|
|
147
|
+
*
|
|
148
|
+
* The output is a plist-ish dict of `"Key" = value;` lines. The two that
|
|
149
|
+
* matter: `"PID"` is present ONLY while the job is running, and
|
|
150
|
+
* `"LastExitStatus"` records how the last run ended. A job whose program is
|
|
151
|
+
* missing has no PID and a nonzero LastExitStatus — that combination is the
|
|
152
|
+
* signature of the failure this module exists to name.
|
|
153
|
+
*/
|
|
154
|
+
export function parseLaunchctlList(output) {
|
|
155
|
+
const pidMatch = output.match(/"PID"\s*=\s*(\d+)\s*;/);
|
|
156
|
+
const exitMatch = output.match(/"LastExitStatus"\s*=\s*(-?\d+)\s*;/);
|
|
157
|
+
return {
|
|
158
|
+
pid: pidMatch ? Number(pidMatch[1]) : null,
|
|
159
|
+
lastExitStatus: exitMatch ? Number(exitMatch[1]) : null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
export function readLaunchctlJobState(label, list) {
|
|
163
|
+
let res;
|
|
164
|
+
try {
|
|
165
|
+
res = list(label);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return { registered: false, pid: null, lastExitStatus: null };
|
|
169
|
+
}
|
|
170
|
+
if (res.code !== 0)
|
|
171
|
+
return { registered: false, pid: null, lastExitStatus: null };
|
|
172
|
+
const { pid, lastExitStatus } = parseLaunchctlList(res.stdout);
|
|
173
|
+
return { registered: true, pid, lastExitStatus };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Which PID to treat as "the process serving this instance".
|
|
177
|
+
*
|
|
178
|
+
* Harper's own `hdb.pid` is preferred — it is written by the serving process on
|
|
179
|
+
* every boot regardless of who spawned it, so it is the same number on the
|
|
180
|
+
* launchd path and on the direct-spawn fallback, which is what makes comparing
|
|
181
|
+
* it against launchd's reported PID a real comparison.
|
|
182
|
+
*
|
|
183
|
+
* The liveness check is the part that is easy to leave out and expensive to
|
|
184
|
+
* omit. A `hdb.pid` left behind by a process that is gone names a PID that
|
|
185
|
+
* matches nothing, and a mismatch is what this module reports as DETACHED — so
|
|
186
|
+
* a stale file would produce a loud, wrong warning on a perfectly healthy
|
|
187
|
+
* launchd install. A check that cries wolf on healthy installs is worse than no
|
|
188
|
+
* check at all, because it is the reason the real warning gets skipped. A dead
|
|
189
|
+
* PID is no evidence, so it is discarded and the port listener answers instead.
|
|
190
|
+
*/
|
|
191
|
+
export function pickInstancePid(input) {
|
|
192
|
+
const { pidFilePid, isAlive, listeningPids } = input;
|
|
193
|
+
if (pidFilePid !== null && isAlive(pidFilePid))
|
|
194
|
+
return pidFilePid;
|
|
195
|
+
return listeningPids.length > 0 ? listeningPids[0] : null;
|
|
196
|
+
}
|
|
197
|
+
/** True when the instance is running outside the service manager that is registered to own it. */
|
|
198
|
+
export function isDetached(m) {
|
|
199
|
+
return m.state === "detached";
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Is this instance under launchd right now?
|
|
203
|
+
*
|
|
204
|
+
* The evidence, in the order it is weighed:
|
|
205
|
+
*
|
|
206
|
+
* - Not darwin, or no plist for this data dir ⇒ nothing claims to manage it,
|
|
207
|
+
* and there is no degradation to report. `no-service` is deliberately NOT
|
|
208
|
+
* an alarm: an instance that was never registered has not lost anything,
|
|
209
|
+
* and warning about it on every run is how a real warning gets ignored.
|
|
210
|
+
* - `launchctl list <label>` cannot find the label, although the plist is on
|
|
211
|
+
* disk ⇒ **detached**. The service exists but is not loaded.
|
|
212
|
+
* - launchd reports no PID for the label ⇒ **detached**. The registered job
|
|
213
|
+
* is not running, so whatever is serving the port is not launchd's.
|
|
214
|
+
* `LastExitStatus` is carried into the detail because it is the difference
|
|
215
|
+
* between "never started" and "started and died".
|
|
216
|
+
* - launchd reports a PID that is not the instance's PID ⇒ **detached**, and
|
|
217
|
+
* this is the exact shape the incident produced: launchd holds a job that
|
|
218
|
+
* is failing, while a directly-spawned process answers on the port.
|
|
219
|
+
* - launchd reports a PID and we cannot read the instance's own ⇒ **managed**.
|
|
220
|
+
* A live job under this instance's label is positive evidence; refusing to
|
|
221
|
+
* believe it because `hdb.pid` was unreadable would warn on healthy
|
|
222
|
+
* installs, which is its own defect.
|
|
223
|
+
*
|
|
224
|
+
* A parent-process check is NOT used, and that is worth stating because it is
|
|
225
|
+
* the obvious first idea: the direct-start fallback spawns `detached: true` and
|
|
226
|
+
* `unref()`s, so once the CLI exits its child is reparented to PID 1 — exactly
|
|
227
|
+
* like a launchd-managed job. Both paths look identical from the parent PID,
|
|
228
|
+
* so the parent PID cannot distinguish them.
|
|
229
|
+
*/
|
|
230
|
+
export function assessLaunchdManagement(input) {
|
|
231
|
+
const { platform, label, plistPath, instancePid, plistExists, list } = input;
|
|
232
|
+
if (platform !== "darwin") {
|
|
233
|
+
return { state: "not-applicable", detail: `${platform} does not use launchd` };
|
|
234
|
+
}
|
|
235
|
+
if (!plistExists(plistPath)) {
|
|
236
|
+
return { state: "no-service", detail: `no launchd service is registered for this instance (${plistPath})` };
|
|
237
|
+
}
|
|
238
|
+
const job = readLaunchctlJobState(label, list);
|
|
239
|
+
const diagnose = input.diagnose ?? ((p) => diagnoseLaunchdPlistPaths(p));
|
|
240
|
+
const detachedRemedy = () => {
|
|
241
|
+
const stale = diagnose(plistPath);
|
|
242
|
+
if (!stale) {
|
|
243
|
+
return {
|
|
244
|
+
remedy: ["flair restart"],
|
|
245
|
+
because: "",
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return { remedy: stale.remedy, because: ` Cause: ${stale.message}` };
|
|
249
|
+
};
|
|
250
|
+
if (!job.registered) {
|
|
251
|
+
const { remedy, because } = detachedRemedy();
|
|
252
|
+
return {
|
|
253
|
+
state: "detached",
|
|
254
|
+
label,
|
|
255
|
+
detail: `the launchd service ${label} is registered on disk but not loaded, so launchd is not managing this instance.${because}`,
|
|
256
|
+
remedy,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (job.pid === null) {
|
|
260
|
+
const { remedy, because } = detachedRemedy();
|
|
261
|
+
const exit = job.lastExitStatus === null
|
|
262
|
+
? ""
|
|
263
|
+
: ` launchd's last run of it exited ${job.lastExitStatus}.`;
|
|
264
|
+
return {
|
|
265
|
+
state: "detached",
|
|
266
|
+
label,
|
|
267
|
+
detail: `the launchd job ${label} is loaded but not running, so whatever is serving this instance was not started by launchd.${exit}${because}`,
|
|
268
|
+
remedy,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
if (instancePid !== null && instancePid !== job.pid) {
|
|
272
|
+
const { remedy, because } = detachedRemedy();
|
|
273
|
+
return {
|
|
274
|
+
state: "detached",
|
|
275
|
+
label,
|
|
276
|
+
detail: `this instance is served by process ${instancePid}, but launchd's job ${label} is process ${job.pid} — ` +
|
|
277
|
+
`the running instance is not the one launchd manages.${because}`,
|
|
278
|
+
remedy,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return { state: "managed", label, detail: `launchd job ${label} is running as process ${job.pid}` };
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* The lines to print for a degraded (detached) outcome.
|
|
285
|
+
*
|
|
286
|
+
* Kept here rather than at the two call sites so `flair restart` and `flair
|
|
287
|
+
* upgrade` cannot drift into saying different things about the same condition,
|
|
288
|
+
* and so the wording is assertable in a unit test without running either
|
|
289
|
+
* command. Deliberately says what is WRONG (not managed), what it COSTS (no
|
|
290
|
+
* restart after reboot), and what to DO — an operator who reads only the first
|
|
291
|
+
* line still knows they have to act.
|
|
292
|
+
*/
|
|
293
|
+
export function renderDetachedWarning(m, headline) {
|
|
294
|
+
const lines = [`⚠️ ${headline}`, ` ${m.detail}`];
|
|
295
|
+
lines.push(" This instance will NOT come back after a reboot until launchd manages it again.");
|
|
296
|
+
// One paste-able line, not one command per line: an operator copying a fix
|
|
297
|
+
// out of a warning copies a line, and a two-step fix pasted as one step is
|
|
298
|
+
// how half a remedy gets applied.
|
|
299
|
+
if (m.remedy?.length)
|
|
300
|
+
lines.push(` Fix: ${m.remedy.join(" && ")}`);
|
|
301
|
+
return lines;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The final status line of a successful `flair upgrade`, given what the probe
|
|
305
|
+
* found AND what launchd is doing.
|
|
306
|
+
*
|
|
307
|
+
* This is the reported defect reduced to a function, on purpose. The bug was
|
|
308
|
+
* never in the probe — `healthy, authenticated, running <version>` was true —
|
|
309
|
+
* it was that those three facts were rendered as an unqualified ✅ while a
|
|
310
|
+
* fourth, unmeasured fact (the instance had been dropped out of its process
|
|
311
|
+
* manager) was the one that mattered. Deciding it here rather than inline in
|
|
312
|
+
* the command means the decision is testable without performing an upgrade,
|
|
313
|
+
* which on the darwin path nothing else can do: no CI lane runs it.
|
|
314
|
+
*
|
|
315
|
+
* The verified facts are still reported in the degraded case. The upgrade DID
|
|
316
|
+
* land, and hiding that would swap one misleading summary for another; what
|
|
317
|
+
* changes is the marker and the sentence around it.
|
|
318
|
+
*/
|
|
319
|
+
export function renderVerifiedSummary(version, m) {
|
|
320
|
+
const facts = `healthy, authenticated${version ? `, running ${version}` : ""}`;
|
|
321
|
+
if (!isDetached(m)) {
|
|
322
|
+
return { degraded: false, lines: [`✅ verified: ${facts}`] };
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
degraded: true,
|
|
326
|
+
lines: renderDetachedWarning(m, `upgrade landed (${facts}) but the instance is NOT running under launchd.`),
|
|
327
|
+
};
|
|
328
|
+
}
|
package/dist/lib/mcp-enable.js
CHANGED
|
@@ -559,6 +559,25 @@ export async function selfVerifyMcpMetadata(issuer, deps = {}) {
|
|
|
559
559
|
detail: `${url} responded but the metadata shape is unexpected (issuer/registration_endpoint/token_endpoint) — got issuer=${JSON.stringify(body?.issuer)}`,
|
|
560
560
|
};
|
|
561
561
|
}
|
|
562
|
+
// flair#1000: this path is now served by flair ITSELF when FLAIR_MCP_OAUTH is
|
|
563
|
+
// off, so a 200 no longer proves the plugin answered. flair's own document
|
|
564
|
+
// (resources/oauth-discovery.ts) is identifiable by its token endpoint —
|
|
565
|
+
// `<issuer>/OAuthToken`, where the plugin's is `<issuer>/oauth/mcp/token`.
|
|
566
|
+
// Name the real cause here: before this existed the operator got a 404 whose
|
|
567
|
+
// message already asked the right question, and falling through to the CIMD
|
|
568
|
+
// branch would send them to a plugin knob that is not the problem.
|
|
569
|
+
if (body.token_endpoint === `${normalizedIssuer}/OAuthToken`) {
|
|
570
|
+
return {
|
|
571
|
+
ok: false,
|
|
572
|
+
issuer: body.issuer,
|
|
573
|
+
registrationEndpoint: body.registration_endpoint,
|
|
574
|
+
tokenEndpoint: body.token_endpoint,
|
|
575
|
+
detail: `${url} answered with flair's OWN OAuth 2.1 authorization server, not the MCP one ` +
|
|
576
|
+
`(token_endpoint=${body.token_endpoint}) — the /mcp surface is NOT enabled on that instance. ` +
|
|
577
|
+
`Is FLAIR_MCP_OAUTH actually set on the restarted instance, and is the '@harperfast/oauth' ` +
|
|
578
|
+
`component declared in its config.yaml?`,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
562
581
|
// flair#756: confirm CIMD is actually advertised (node_modules/@harperfast/
|
|
563
582
|
// oauth/dist/lib/mcp/wellKnown.js:129-165's buildAuthorizationServerMetadata:
|
|
564
583
|
// `client_id_metadata_document_supported` is set only when
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Resource } from "harper";
|
|
2
|
-
import { layout, htmlResponse } from "./admin-layout.js";
|
|
2
|
+
import { layout, htmlResponse, esc } from "./admin-layout.js";
|
|
3
|
+
import { mcpRouteState } from "./mcp-oauth.js";
|
|
3
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
5
|
import { join, dirname } from "node:path";
|
|
5
6
|
import { homedir } from "node:os";
|
|
@@ -98,6 +99,23 @@ export class AdminInstance extends Resource {
|
|
|
98
99
|
const ctx = this.getContext?.() ?? {};
|
|
99
100
|
const request = ctx.request ?? ctx;
|
|
100
101
|
const publicUrl = resolvePublicUrl(request);
|
|
102
|
+
// The MCP row (flair#1001). Every other row in the Endpoints table is a
|
|
103
|
+
// route this component always registers; `/mcp` is the one that may not be
|
|
104
|
+
// there — it is default-OFF, and even with FLAIR_MCP_OAUTH on it does not
|
|
105
|
+
// mount without an issuer. This page used to print the URL unconditionally,
|
|
106
|
+
// so a default install advertised an endpoint that 404s.
|
|
107
|
+
//
|
|
108
|
+
// The state comes from the router itself (mcp-oauth.ts records the outcome
|
|
109
|
+
// of its own mount decision) rather than from a second read of the flag
|
|
110
|
+
// here — a second read is a second source of truth, which is this bug one
|
|
111
|
+
// level up. The row is kept, not dropped: a missing row tells an operator
|
|
112
|
+
// nothing, while a named status and the variable that changes it is
|
|
113
|
+
// actionable and still teaches them the surface exists.
|
|
114
|
+
const mcp = mcpRouteState();
|
|
115
|
+
const mcpCell = mcp.mounted
|
|
116
|
+
? `<code>${publicUrl}/mcp</code>`
|
|
117
|
+
: `<span class="badge badge-gray">${esc(mcp.status)}</span>` +
|
|
118
|
+
`<div style="margin-top:4px;color:#666;font-size:0.9em">${esc(mcp.reason)}</div>`;
|
|
101
119
|
// Try to read instance public key
|
|
102
120
|
let publicKey = "—";
|
|
103
121
|
const keyDir = join(homedir(), ".flair", "keys");
|
|
@@ -139,7 +157,7 @@ export class AdminInstance extends Resource {
|
|
|
139
157
|
<h3>Endpoints</h3>
|
|
140
158
|
<table style="box-shadow:none">
|
|
141
159
|
<tr><td>API</td><td><code>${publicUrl}/</code></td></tr>
|
|
142
|
-
<tr><td>MCP</td><td
|
|
160
|
+
<tr><td>MCP</td><td>${mcpCell}</td></tr>
|
|
143
161
|
<tr><td>OAuth Discovery</td><td><code>${publicUrl}/OAuthMetadata</code></td></tr>
|
|
144
162
|
<tr><td>OAuth Authorize</td><td><code>${publicUrl}/OAuthAuthorize</code></td></tr>
|
|
145
163
|
<tr><td>OAuth Token</td><td><code>${publicUrl}/OAuthToken</code></td></tr>
|
package/dist/resources/Memory.js
CHANGED
|
@@ -243,8 +243,27 @@ async function runDedupGate(ctx, content) {
|
|
|
243
243
|
return findConservativeDedupMatch(ctx, content.agentId, content.content, embedding, cosineThreshold, lexicalThreshold);
|
|
244
244
|
}
|
|
245
245
|
/** Build the final write response: always `written: true`, always includes
|
|
246
|
-
* `id`, and layers the dedup collision signal on top when
|
|
247
|
-
* code path where a match suppresses these base fields.
|
|
246
|
+
* `id`, `visibility`, and layers the dedup collision signal on top when
|
|
247
|
+
* present. Never a code path where a match suppresses these base fields.
|
|
248
|
+
*
|
|
249
|
+
* ── Why `visibility` is in the write response (flair#991) ──────────────────
|
|
250
|
+
* Visibility is the one field on a memory the caller most often does NOT
|
|
251
|
+
* set and yet most needs to know: the durability-keyed default above stamps
|
|
252
|
+
* `private` for a bare write and `shared` for a permanent/persistent one, so
|
|
253
|
+
* "who can read this" is decided by a rule the writer never typed. Returning
|
|
254
|
+
* it makes the landed value observable on EVERY write surface at once —
|
|
255
|
+
* `flair memory add`'s printed JSON, the REST response, the native /mcp
|
|
256
|
+
* `memory_store` result, and packages/flair-mcp's `effectiveVisibility` line
|
|
257
|
+
* (which read this field all along and had nothing to read, so it always
|
|
258
|
+
* rendered "(server default)").
|
|
259
|
+
*
|
|
260
|
+
* Read from `content`, not from `base`: `content.visibility` is the value
|
|
261
|
+
* that was actually persisted a few lines earlier, and assigning after the
|
|
262
|
+
* `...base` spread means the persisted value wins over anything the storage
|
|
263
|
+
* layer echoes back. Omitted (not `null`) when unset, which happens only on
|
|
264
|
+
* the put()-over-an-existing-record path where a partial merge carried no
|
|
265
|
+
* visibility — reporting `null` there would read as "no one but the owner",
|
|
266
|
+
* the opposite of what an absent field means to `isPrivateVisibility()`. */
|
|
248
267
|
function buildWriteResponse(content, result, dedupMatch) {
|
|
249
268
|
const base = result && typeof result === "object" && !Array.isArray(result) ? result : {};
|
|
250
269
|
const response = {
|
|
@@ -253,6 +272,9 @@ function buildWriteResponse(content, result, dedupMatch) {
|
|
|
253
272
|
written: true,
|
|
254
273
|
deduplicated: !!dedupMatch,
|
|
255
274
|
};
|
|
275
|
+
if (content.visibility !== undefined && content.visibility !== null) {
|
|
276
|
+
response.visibility = content.visibility;
|
|
277
|
+
}
|
|
256
278
|
if (dedupMatch) {
|
|
257
279
|
response.matchedId = dedupMatch.matchedId;
|
|
258
280
|
response.matchConfidence = { cosine: dedupMatch.cosine, lexical: dedupMatch.lexical };
|
package/dist/resources/OAuth.js
CHANGED
|
@@ -2,11 +2,13 @@ import { Resource, databases } from "harper";
|
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import { handleJwtBearerGrant } from "./XAA.js";
|
|
4
4
|
import { resolveAgentAuth } from "./agent-auth.js";
|
|
5
|
+
import { decideRegistration } from "./dcr-gate.js";
|
|
6
|
+
import { buildAuthorizationServerMetadata } from "./oauth-discovery.js";
|
|
5
7
|
/**
|
|
6
8
|
* OAuth 2.1 Authorization Server for Flair.
|
|
7
9
|
*
|
|
8
10
|
* Endpoints (all mapped via Harper's resource routing):
|
|
9
|
-
* GET /OAuthMetadata → /.well-known/oauth-authorization-server
|
|
11
|
+
* GET /OAuthMetadata → alias of /.well-known/oauth-authorization-server
|
|
10
12
|
* POST /OAuthRegister → /oauth/register (DCR)
|
|
11
13
|
* GET /OAuthAuthorize → /oauth/authorize (consent screen)
|
|
12
14
|
* POST /OAuthToken → /oauth/token (token exchange)
|
|
@@ -57,6 +59,19 @@ function redirectTo(url, status = 302) {
|
|
|
57
59
|
return new Response(null, { status, headers: { Location: url } });
|
|
58
60
|
}
|
|
59
61
|
// ─── Discovery metadata ──────────────────────────────────────────────────────
|
|
62
|
+
/**
|
|
63
|
+
* `/OAuthMetadata` — an ALIAS of `/.well-known/oauth-authorization-server`
|
|
64
|
+
* (flair#1000), not a second implementation.
|
|
65
|
+
*
|
|
66
|
+
* The document body moved verbatim to `buildAuthorizationServerMetadata()` in
|
|
67
|
+
* resources/oauth-discovery.ts and BOTH paths now call it. The well-known path
|
|
68
|
+
* is the one RFC 8414 defines and the one every client probes; this path is
|
|
69
|
+
* kept because it is what shipped, is referenced by docs/auth.md and by
|
|
70
|
+
* deployed callers, and costs one delegating line. Keeping it as an alias
|
|
71
|
+
* rather than as an independent handler is the whole point: two endpoints that
|
|
72
|
+
* can drift apart is the failure mode, and after this change there is no code
|
|
73
|
+
* path that can produce one document without producing the other.
|
|
74
|
+
*/
|
|
60
75
|
export class OAuthMetadata extends Resource {
|
|
61
76
|
// OAuth discovery metadata is intentionally public — RFC 8414 § 3 requires
|
|
62
77
|
// it be accessible without authentication so clients can bootstrap their
|
|
@@ -65,30 +80,7 @@ export class OAuthMetadata extends Resource {
|
|
|
65
80
|
// of a 401 from Harper's intrinsic auth layer. Same pattern as Health (#386).
|
|
66
81
|
allowRead() { return true; }
|
|
67
82
|
async get() {
|
|
68
|
-
|
|
69
|
-
return {
|
|
70
|
-
issuer: baseUrl,
|
|
71
|
-
authorization_endpoint: `${baseUrl}/OAuthAuthorize`,
|
|
72
|
-
token_endpoint: `${baseUrl}/OAuthToken`,
|
|
73
|
-
registration_endpoint: `${baseUrl}/OAuthRegister`,
|
|
74
|
-
revocation_endpoint: `${baseUrl}/OAuthRevoke`,
|
|
75
|
-
response_types_supported: ["code"],
|
|
76
|
-
grant_types_supported: [
|
|
77
|
-
"authorization_code",
|
|
78
|
-
"refresh_token",
|
|
79
|
-
"urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
80
|
-
],
|
|
81
|
-
token_endpoint_auth_methods_supported: ["none", "client_secret_basic"],
|
|
82
|
-
code_challenge_methods_supported: ["S256"],
|
|
83
|
-
scopes_supported: [
|
|
84
|
-
"memory:read", "memory:write", "memory:admin",
|
|
85
|
-
"principal:read", "principal:admin",
|
|
86
|
-
"connector:read", "connector:admin",
|
|
87
|
-
],
|
|
88
|
-
extensions_supported: [
|
|
89
|
-
"io.modelcontextprotocol/enterprise-managed-authorization",
|
|
90
|
-
],
|
|
91
|
-
};
|
|
83
|
+
return buildAuthorizationServerMetadata();
|
|
92
84
|
}
|
|
93
85
|
}
|
|
94
86
|
// ─── Dynamic Client Registration (RFC 7591) ──────────────────────────────────
|
|
@@ -96,8 +88,32 @@ export class OAuthRegister extends Resource {
|
|
|
96
88
|
// Dynamic Client Registration (RFC 7591) — clients must be able to register
|
|
97
89
|
// anonymously to bootstrap. Validation (redirect_uri allow-list, etc.)
|
|
98
90
|
// happens in post(). Pattern matches FederationPair.allowCreate (#299).
|
|
91
|
+
//
|
|
92
|
+
// This stays `true` — it is Harper's ROLE gate, and returning false here would
|
|
93
|
+
// answer with Harper's own 403 body instead of an RFC 7591-shaped error. WHO
|
|
94
|
+
// may register is decided in post() via resources/dcr-gate.ts, in one place.
|
|
95
|
+
// Deliberately not enforced in both: one condition in two places is how the
|
|
96
|
+
// two drift apart.
|
|
99
97
|
allowCreate() { return true; }
|
|
100
98
|
async post(data) {
|
|
99
|
+
// Registration gate FIRST — before the redirect-URI policy is applied,
|
|
100
|
+
// before anything is read off the body, and before any write. A caller who
|
|
101
|
+
// may not register learns only that; running the redirect-URI check first
|
|
102
|
+
// would turn a closed endpoint into a probe for what this server accepts.
|
|
103
|
+
const decision = decideRegistration(this.getContext?.()?.request ?? this.getContext?.());
|
|
104
|
+
if (!decision.allowed) {
|
|
105
|
+
if (decision.reason === "disabled") {
|
|
106
|
+
return new Response(JSON.stringify({
|
|
107
|
+
error: "access_denied",
|
|
108
|
+
error_description: "dynamic client registration is not enabled on this server " +
|
|
109
|
+
"(set FLAIR_OAUTH_DCR_TOKEN to enable it)",
|
|
110
|
+
}), { status: 403, headers: { "content-type": "application/json" } });
|
|
111
|
+
}
|
|
112
|
+
return new Response(JSON.stringify({
|
|
113
|
+
error: "invalid_token",
|
|
114
|
+
error_description: "a valid initial access token is required to register a client",
|
|
115
|
+
}), { status: 401, headers: { "content-type": "application/json" } });
|
|
116
|
+
}
|
|
101
117
|
const redirectUris = data?.redirect_uris ?? [];
|
|
102
118
|
const clientName = data?.client_name ?? "Unknown Client";
|
|
103
119
|
// 1.0: only claude.com redirect URI permitted
|
|
@@ -5,6 +5,7 @@ import { isAdmin, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
|
|
|
5
5
|
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
|
|
6
6
|
import { resolveReadScope } from "./memory-read-scope.js";
|
|
7
7
|
import { isForbiddenOwnerMutation, resolveGuardedRecord } from "./record-owner-guard.js";
|
|
8
|
+
import { checkHttpRateLimit } from "./rate-limit.js";
|
|
8
9
|
// --- Admin credentials ---
|
|
9
10
|
// Admin auth is sourced exclusively from Harper's own environment variables
|
|
10
11
|
// (HDB_ADMIN_PASSWORD / FLAIR_ADMIN_PASSWORD). No filesystem token file.
|
|
@@ -65,6 +66,26 @@ async function backfillEmbedding(memoryId) {
|
|
|
65
66
|
// ─── HTTP middleware ──────────────────────────────────────────────────────────
|
|
66
67
|
server.http(async (request, nextLayer) => {
|
|
67
68
|
const url = new URL(request.url, "http://" + (request.headers.get("host") || "localhost"));
|
|
69
|
+
// ── Rate limiting, FIRST ───────────────────────────────────────────────────
|
|
70
|
+
// Before the public-path passthrough below (the OAuth endpoints all sit on it,
|
|
71
|
+
// so a hook placed after it would never run for them), and before anything
|
|
72
|
+
// reads a credential.
|
|
73
|
+
//
|
|
74
|
+
// Ordering is a security property, not tidiness. The counter is consumed for
|
|
75
|
+
// every request to a throttled endpoint whether or not the credential that
|
|
76
|
+
// came with it was any good — if only failures were counted, "did this consume
|
|
77
|
+
// budget" would answer "was that credential valid", which is a cleaner
|
|
78
|
+
// enumeration oracle than the 400 the endpoint already returns. Because the
|
|
79
|
+
// decision is made here, a limited request carries no information about what
|
|
80
|
+
// it was carrying: a valid authorization code and a garbage one get the same
|
|
81
|
+
// 429 and the same body.
|
|
82
|
+
//
|
|
83
|
+
// Only the OAuth endpoints named in rate-limit.ts's PATH_POLICY are affected.
|
|
84
|
+
// Every other path — /Memory, /Presence, /FederationSync, everything agents
|
|
85
|
+
// actually use — returns null here and is untouched.
|
|
86
|
+
const limited = checkHttpRateLimit(request, url.pathname);
|
|
87
|
+
if (limited)
|
|
88
|
+
return limited;
|
|
68
89
|
// A2A discovery endpoints: GET returns public agent-card metadata (per
|
|
69
90
|
// A2A spec, cards are intentionally public). POST invokes JSON-RPC
|
|
70
91
|
// actions (message/send writes OrgEvents on behalf of agents,
|
|
@@ -91,6 +112,11 @@ server.http(async (request, nextLayer) => {
|
|
|
91
112
|
url.pathname === "/OAuthAuthorize" ||
|
|
92
113
|
url.pathname === "/OAuthToken" ||
|
|
93
114
|
url.pathname === "/OAuthRevoke" ||
|
|
115
|
+
// Belt-and-braces since flair#1000: `/.well-known/oauth-authorization-server`
|
|
116
|
+
// is served from its OWN urlPath mount (resources/oauth-wellknown.ts), which
|
|
117
|
+
// gets its own dispatch chain — this middleware never sees a request for it.
|
|
118
|
+
// The entry stays so the path is still public if that mount ever moves back
|
|
119
|
+
// onto the default chain.
|
|
94
120
|
url.pathname === "/.well-known/oauth-authorization-server" ||
|
|
95
121
|
url.pathname === "/OAuthMetadata" ||
|
|
96
122
|
// Presence roster is public-safe (field-allowlisted); GET serves the
|