@tiny-fish/cli 0.20.2-next.190 → 0.21.1-next.195
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/dist/commands/connect.js +18 -27
- package/dist/commands/doctor.js +113 -37
- package/dist/lib/auth.d.ts +2 -0
- package/dist/lib/auth.js +5 -1
- package/dist/lib/connect-clients.js +11 -14
- package/dist/lib/connect-install.js +8 -13
- package/dist/lib/connect-runtime.d.ts +22 -3
- package/dist/lib/connect-runtime.js +51 -5
- package/dist/lib/cursor-config.d.ts +2 -1
- package/dist/lib/cursor-config.js +6 -3
- package/dist/lib/doctor-report.d.ts +6 -21
- package/dist/lib/doctor-report.js +4 -4
- package/dist/lib/harness-detect.d.ts +10 -0
- package/dist/lib/harness-detect.js +13 -0
- package/dist/lib/registration-detect.d.ts +8 -3
- package/dist/lib/registration-detect.js +121 -53
- package/dist/lib/verify.d.ts +2 -0
- package/dist/lib/verify.js +1 -0
- package/package.json +1 -1
package/dist/commands/connect.js
CHANGED
|
@@ -2,9 +2,10 @@ import spawn from "cross-spawn";
|
|
|
2
2
|
import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, validatedApiKey, } from "../lib/auth.js";
|
|
3
3
|
import { CLAUDE_CODE, CODEX, HERMES, OPENCLAW, OPENCLAW_SKILL_INSTALL_ARGS, OPENCODE, launchNativeMcpClient, launchOpenClawWalkthrough, openExternalUrl, } from "../lib/connect-clients.js";
|
|
4
4
|
import { ensureCliAuthenticated, installTinyFishCli, installWebSkill, SKILL_INSTALL_TIMEOUT_MS, UPGRADE_HINT, } from "../lib/connect-install.js";
|
|
5
|
-
import { ConnectInterruptedError, createConnectTelemetry, requireCommandSupport, runGuarded, settle, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "../lib/connect-runtime.js";
|
|
5
|
+
import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, requireCommandSupport, runGuarded, settle, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "../lib/connect-runtime.js";
|
|
6
6
|
import { cursorInstallDeeplink, cursorMcpPath, writeCursorMcpConfig, } from "../lib/cursor-config.js";
|
|
7
7
|
import { runConnectAll } from "../lib/connect-all.js";
|
|
8
|
+
import { AuthMode } from "../lib/harness-detect.js";
|
|
8
9
|
import { detectHumanInitiated } from "../lib/harness.js";
|
|
9
10
|
import { emitNotice } from "../lib/notice.js";
|
|
10
11
|
import { errLine } from "../lib/output.js";
|
|
@@ -22,12 +23,7 @@ function removeExistingRegistration(client, removal) {
|
|
|
22
23
|
const details = result.stderr?.trim() || result.error?.message || "unknown error";
|
|
23
24
|
if (/No MCP server named "tinyfish"/i.test(details))
|
|
24
25
|
return;
|
|
25
|
-
|
|
26
|
-
throw new Error(`Could not remove existing ${removal.label}: ${details}`, {
|
|
27
|
-
cause: result.error,
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
throw new Error(`Could not remove existing ${removal.label}: ${details}`);
|
|
26
|
+
throw spawnStepError(`Could not remove existing ${removal.label}: ${details}`, result);
|
|
31
27
|
}
|
|
32
28
|
/** false: fall back to OAuth — a config naming an unset variable authenticates nothing. */
|
|
33
29
|
function exportApiKey(apiKey, displayName) {
|
|
@@ -49,7 +45,8 @@ async function connectNativeMcpClient(client, options) {
|
|
|
49
45
|
const state = { stage: "prerequisite_check", settled: false };
|
|
50
46
|
await runGuarded(state, telemetry, () => {
|
|
51
47
|
telemetry.track("started");
|
|
52
|
-
const { optionalSupported, keyAuthSupported } = requireCommandSupport(client);
|
|
48
|
+
const { optionalSupported, keyAuthSupported, harnessVersion } = requireCommandSupport(client);
|
|
49
|
+
telemetry.setHarnessVersion(harnessVersion);
|
|
53
50
|
state.harnessDegraded = !!client.loginArgs && !optionalSupported;
|
|
54
51
|
// Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
|
|
55
52
|
const storedKey = validatedApiKey(options.apiKey);
|
|
@@ -92,10 +89,7 @@ async function connectNativeMcpClient(client, options) {
|
|
|
92
89
|
stdio: "inherit",
|
|
93
90
|
});
|
|
94
91
|
if (addResult.error || addResult.status !== 0) {
|
|
95
|
-
|
|
96
|
-
throw new Error(`Could not add TinyFish to ${client.displayName}`, {
|
|
97
|
-
cause: addResult.error,
|
|
98
|
-
});
|
|
92
|
+
throw spawnStepError(`Could not add TinyFish to ${client.displayName}`, addResult);
|
|
99
93
|
}
|
|
100
94
|
telemetry.track("checkpoint", { phase: "registered" });
|
|
101
95
|
// Codex runs OAuth inside `mcp add` and exits 0 either way, so the recorded state decides
|
|
@@ -121,19 +115,16 @@ async function connectNativeMcpClient(client, options) {
|
|
|
121
115
|
stdio: "inherit",
|
|
122
116
|
});
|
|
123
117
|
if (loginResult.error || loginResult.status !== 0) {
|
|
124
|
-
|
|
125
|
-
throw new Error(`Could not authenticate TinyFish in ${client.displayName}`, {
|
|
126
|
-
cause: loginResult.error,
|
|
127
|
-
});
|
|
118
|
+
throw spawnStepError(`Could not authenticate TinyFish in ${client.displayName}`, loginResult);
|
|
128
119
|
}
|
|
129
120
|
telemetry.track("checkpoint", { phase: "oauth_done" });
|
|
130
121
|
}
|
|
131
122
|
const signInDeferred = !useKeyAuth && !addSignedIn && !pendingLogin;
|
|
132
123
|
const authMode = useKeyAuth
|
|
133
|
-
?
|
|
124
|
+
? AuthMode.ApiKey
|
|
134
125
|
: signInDeferred
|
|
135
126
|
? "deferred"
|
|
136
|
-
:
|
|
127
|
+
: AuthMode.OAuth;
|
|
137
128
|
state.authMode = authMode;
|
|
138
129
|
// Registration/OAuth make MCP work; later steps are cosmetic and must not fail the attempt.
|
|
139
130
|
settle(state, telemetry, "completed", { authMode });
|
|
@@ -181,7 +172,10 @@ function runPostInstallSteps(client, options, state, telemetry, signInDeferred)
|
|
|
181
172
|
/** Warn + report post_install_failed; interrupts are abandonment, not failure. */
|
|
182
173
|
function reportPostInstallFailure(connectClient, displayName, state, telemetry, error) {
|
|
183
174
|
if (!(error instanceof ConnectInterruptedError)) {
|
|
184
|
-
telemetry.track("post_install_failed", {
|
|
175
|
+
telemetry.track("post_install_failed", {
|
|
176
|
+
failedStage: state.stage,
|
|
177
|
+
...(error instanceof ConnectStepError ? { failureReason: error.failureReason } : {}),
|
|
178
|
+
});
|
|
185
179
|
errLine(`The ${displayName} MCP connection succeeded, but a finishing step ` +
|
|
186
180
|
`(${state.stage}) failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
187
181
|
}
|
|
@@ -209,7 +203,7 @@ export async function connectOpenClaw(options) {
|
|
|
209
203
|
};
|
|
210
204
|
await runGuarded(state, telemetry, () => {
|
|
211
205
|
telemetry.track("started");
|
|
212
|
-
requireCommandSupport(OPENCLAW);
|
|
206
|
+
telemetry.setHarnessVersion(requireCommandSupport(OPENCLAW).harnessVersion);
|
|
213
207
|
telemetry.track("checkpoint", { phase: "prerequisite_ok" });
|
|
214
208
|
if (options.installCli !== false) {
|
|
215
209
|
state.stage = "cli_install";
|
|
@@ -223,10 +217,7 @@ export async function connectOpenClaw(options) {
|
|
|
223
217
|
timeout: SKILL_INSTALL_TIMEOUT_MS,
|
|
224
218
|
});
|
|
225
219
|
if (skillInstallResult.error || skillInstallResult.status !== 0) {
|
|
226
|
-
|
|
227
|
-
throw new Error("Could not install the TinyFish skill in OpenClaw", {
|
|
228
|
-
cause: skillInstallResult.error,
|
|
229
|
-
});
|
|
220
|
+
throw spawnStepError("Could not install the TinyFish skill in OpenClaw", skillInstallResult);
|
|
230
221
|
}
|
|
231
222
|
telemetry.track("checkpoint", { phase: "skill_installed" });
|
|
232
223
|
// Stores the key before probing, so a passed --api-key never reaches an interactive login.
|
|
@@ -280,8 +271,8 @@ export async function connectCursor(options) {
|
|
|
280
271
|
// Attempt id rides telemetry only; persisting it would break idempotency.
|
|
281
272
|
const result = writeCursorMcpConfig(mcpUrl.toString(), resolvedKey);
|
|
282
273
|
if (result.status === "corrupt_skip") {
|
|
283
|
-
throw new
|
|
284
|
-
`Fix the file, then re-run: tinyfish connect cursor
|
|
274
|
+
throw new ConnectStepError(`Could not update ${cursorMcpPath()}: existing file could not be read or is not valid JSON (${result.error}). ` +
|
|
275
|
+
`Fix the file, then re-run: tinyfish connect cursor`, "invalid_config");
|
|
285
276
|
}
|
|
286
277
|
if (result.backupPath) {
|
|
287
278
|
errLine(`Backed up existing Cursor MCP config to ${result.backupPath}`);
|
|
@@ -308,7 +299,7 @@ export async function connectCursor(options) {
|
|
|
308
299
|
else {
|
|
309
300
|
errLine("TinyFish is connected. Reload the Cursor window, then approve/sign in under Settings → MCP.");
|
|
310
301
|
}
|
|
311
|
-
settle(state, telemetry, "completed", { authMode: resolvedKey ?
|
|
302
|
+
settle(state, telemetry, "completed", { authMode: resolvedKey ? AuthMode.ApiKey : "deferred" });
|
|
312
303
|
});
|
|
313
304
|
}
|
|
314
305
|
/** Best-effort: false when no handler/open fails — caller falls back to reload copy. */
|
package/dist/commands/doctor.js
CHANGED
|
@@ -2,20 +2,25 @@ import spawn from "cross-spawn";
|
|
|
2
2
|
import { apiKeyStatus } from "../lib/auth.js";
|
|
3
3
|
import { CLI_VERSION } from "../lib/constants.js";
|
|
4
4
|
import { DOCTOR_COULD_NOT_RUN, DOCTOR_SCHEMA_VERSION, doctorReportSchema, exitCodeFor, renderPretty, } from "../lib/doctor-report.js";
|
|
5
|
-
import { ALL_HARNESSES } from "../lib/harness-detect.js";
|
|
5
|
+
import { ALL_HARNESSES, AuthMode, Registered } from "../lib/harness-detect.js";
|
|
6
6
|
import { detectHumanInitiated } from "../lib/harness.js";
|
|
7
7
|
import { err, errLine, out, outLine } from "../lib/output.js";
|
|
8
8
|
import { detectRegistrations } from "../lib/registration-detect.js";
|
|
9
9
|
import { verifyMcpAuth, verifyMcpHealth } from "../lib/verify.js";
|
|
10
10
|
import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectOpenClaw, connectOpencode, DEFAULT_MCP_URL, } from "./connect.js";
|
|
11
11
|
// A green auth mode with a red auth call proves nothing, so the call's verdict gates the claim.
|
|
12
|
-
function provesHarnessReach(status,
|
|
12
|
+
function provesHarnessReach(status, keyAuth, healthOk, mcpUrl) {
|
|
13
13
|
// An endpoint doctor could not reach proves nothing about a harness pointed at it.
|
|
14
14
|
if (!healthOk)
|
|
15
15
|
return false;
|
|
16
16
|
if (pointsElsewhere(status.registeredUrl, mcpUrl))
|
|
17
17
|
return false;
|
|
18
|
-
|
|
18
|
+
if (status.registered !== Registered.Yes)
|
|
19
|
+
return false;
|
|
20
|
+
if (status.connected === true)
|
|
21
|
+
return true;
|
|
22
|
+
// The harness's own key must have verified; the CLI's verdict says nothing about it.
|
|
23
|
+
return status.authMode === AuthMode.ApiKey && keyAuth?.ok === true;
|
|
19
24
|
}
|
|
20
25
|
// Reports the version, never judges it: staleness is the server's call via X-TF-Notice.
|
|
21
26
|
function checkCliVersion() {
|
|
@@ -55,13 +60,41 @@ function pointsElsewhere(registeredUrl, mcpUrl) {
|
|
|
55
60
|
return "an unparseable endpoint";
|
|
56
61
|
}
|
|
57
62
|
}
|
|
58
|
-
function
|
|
63
|
+
function keyedVerdict(status, keyAuth) {
|
|
64
|
+
if (!keyAuth) {
|
|
65
|
+
// A bare warn beside exit 0 reads as a pass.
|
|
66
|
+
const because = status.keyReason ?? "its key is not readable here";
|
|
67
|
+
return { status: "warn", detail: `registered, but ${because}, so its reach is unproven` };
|
|
68
|
+
}
|
|
69
|
+
if (keyAuth.ok) {
|
|
70
|
+
return {
|
|
71
|
+
status: "pass",
|
|
72
|
+
detail: "registered, auth mode api-key (key verified against the TinyFish API)",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (keyAuth.status === 401) {
|
|
76
|
+
// Resolved in doctor's shell, not codex's, so a 401 proves nothing.
|
|
77
|
+
if (status.keyIsIndirect) {
|
|
78
|
+
return {
|
|
79
|
+
status: "warn",
|
|
80
|
+
detail: "registered, but the key doctor resolved for it is rejected",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return { status: "fail", detail: "registered, but TinyFish rejects its API key" };
|
|
84
|
+
}
|
|
85
|
+
// Only 401 blames the key; connect would loop on the rest.
|
|
86
|
+
if (keyAuth.status === 403) {
|
|
87
|
+
return { status: "warn", detail: "registered, but its API key is not allowed to list runs" };
|
|
88
|
+
}
|
|
89
|
+
return { status: "warn", detail: "registered, key could not be verified" };
|
|
90
|
+
}
|
|
91
|
+
function registrationVerdict(status, mcpUrl, keyAuth) {
|
|
59
92
|
if (!status.detected)
|
|
60
93
|
return { status: "skip", detail: "harness not installed" };
|
|
61
|
-
if (status.registered ===
|
|
94
|
+
if (status.registered === Registered.Unknown) {
|
|
62
95
|
return { status: "warn", detail: status.reason ?? "could not determine registration" };
|
|
63
96
|
}
|
|
64
|
-
if (status.registered ===
|
|
97
|
+
if (status.registered === Registered.Yes) {
|
|
65
98
|
// A `tinyfish` entry aimed at a dev server is registered and still cannot reach TinyFish.
|
|
66
99
|
const elsewhere = pointsElsewhere(status.registeredUrl, mcpUrl);
|
|
67
100
|
if (elsewhere) {
|
|
@@ -70,6 +103,20 @@ function registrationVerdict(status, mcpUrl) {
|
|
|
70
103
|
detail: `registered, but points at ${elsewhere} rather than ${endpointOf(mcpUrl)}`,
|
|
71
104
|
};
|
|
72
105
|
}
|
|
106
|
+
// Ordered before the key check: the harness's own verdict wins.
|
|
107
|
+
if (status.connected === false) {
|
|
108
|
+
return {
|
|
109
|
+
status: "warn",
|
|
110
|
+
detail: "registered, but the harness reports it cannot reach TinyFish; sign in again there",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// Symmetric with provesHarnessReach, which already reads a handshake as decisive.
|
|
114
|
+
if (status.connected === true) {
|
|
115
|
+
return { status: "pass", detail: "registered, and the harness reports it reaches TinyFish" };
|
|
116
|
+
}
|
|
117
|
+
// Widening past api-key would warn every healthy oauth install.
|
|
118
|
+
if (status.authMode === AuthMode.ApiKey)
|
|
119
|
+
return keyedVerdict(status, keyAuth);
|
|
73
120
|
// A probe that had to explain itself to reach `yes` is the only thing that explains `unknown`.
|
|
74
121
|
const because = status.reason ? ` (${status.reason})` : "";
|
|
75
122
|
return { status: "pass", detail: `registered, auth mode ${status.authMode}${because}` };
|
|
@@ -79,12 +126,12 @@ function registrationVerdict(status, mcpUrl) {
|
|
|
79
126
|
? { status: "fail", detail: "connected previously but TinyFish is no longer registered" }
|
|
80
127
|
: { status: "warn", detail: "installed but TinyFish was never connected" };
|
|
81
128
|
}
|
|
82
|
-
function checkRegistration(status, mcpUrl) {
|
|
129
|
+
function checkRegistration(status, mcpUrl, keyAuth) {
|
|
83
130
|
return {
|
|
84
131
|
id: "harness-registration",
|
|
85
132
|
title: `${status.harness} registration`,
|
|
86
133
|
harness: status.harness,
|
|
87
|
-
...registrationVerdict(status, mcpUrl),
|
|
134
|
+
...registrationVerdict(status, mcpUrl, keyAuth),
|
|
88
135
|
};
|
|
89
136
|
}
|
|
90
137
|
function checkCredential() {
|
|
@@ -98,8 +145,8 @@ function checkCredential() {
|
|
|
98
145
|
};
|
|
99
146
|
return "key" in resolved ? { check, key: resolved.key } : { check };
|
|
100
147
|
}
|
|
101
|
-
|
|
102
|
-
if (!
|
|
148
|
+
function checkAuthCall(auth) {
|
|
149
|
+
if (!auth) {
|
|
103
150
|
return {
|
|
104
151
|
id: "cli-auth-call",
|
|
105
152
|
title: "Authenticated call",
|
|
@@ -108,7 +155,6 @@ async function checkAuthCall(key) {
|
|
|
108
155
|
harness: null,
|
|
109
156
|
};
|
|
110
157
|
}
|
|
111
|
-
const auth = await verifyMcpAuth(key);
|
|
112
158
|
return {
|
|
113
159
|
id: "cli-auth-call",
|
|
114
160
|
title: "Authenticated call",
|
|
@@ -117,32 +163,37 @@ async function checkAuthCall(key) {
|
|
|
117
163
|
harness: null,
|
|
118
164
|
};
|
|
119
165
|
}
|
|
166
|
+
// `verifyMcpAuth` hits BASE_URL; a sandbox key 401s against prod.
|
|
167
|
+
function isDefaultEndpoint(mcpUrl) {
|
|
168
|
+
try {
|
|
169
|
+
return endpointOf(mcpUrl) === endpointOf(DEFAULT_MCP_URL);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function verifyHarnessKey(status, cliKey, cliAuth, mcpUrl) {
|
|
176
|
+
if (!isDefaultEndpoint(mcpUrl))
|
|
177
|
+
return undefined;
|
|
178
|
+
// `pointsElsewhere` already fails; a second reason only misleads.
|
|
179
|
+
if (pointsElsewhere(status.registeredUrl, mcpUrl))
|
|
180
|
+
return undefined;
|
|
181
|
+
// OpenClaw's skill shells this CLI, so its verdict stands in.
|
|
182
|
+
if (status.harness === "openclaw")
|
|
183
|
+
return cliAuth;
|
|
184
|
+
if (!status.apiKey)
|
|
185
|
+
return undefined;
|
|
186
|
+
if (status.apiKey === cliKey && cliAuth)
|
|
187
|
+
return cliAuth;
|
|
188
|
+
return verifyMcpAuth(status.apiKey);
|
|
189
|
+
}
|
|
120
190
|
// Only Cursor's repair is a pure local file write; every other one needs a browser sign-in.
|
|
121
191
|
const UNATTENDED_SAFE = new Set(["cursor"]);
|
|
122
192
|
function repairsFor(checks, statuses) {
|
|
123
193
|
const repairs = [];
|
|
124
|
-
const credentialResolves = checks.some((c) => c.id === "cli-credential" && c.status === "pass");
|
|
125
|
-
for (const status of statuses) {
|
|
126
|
-
// `unknown` earns nothing: running connect off a failed probe contradicts the check's detail.
|
|
127
|
-
// A registered-but-misaimed entry does earn one, since connect rewrites the URL.
|
|
128
|
-
const misaimed = checks.some((c) => c.harness === status.harness && c.status === "fail");
|
|
129
|
-
if (!status.detected || (status.registered !== "no" && !misaimed))
|
|
130
|
-
continue;
|
|
131
|
-
repairs.push({
|
|
132
|
-
for: status.registered === "yes"
|
|
133
|
-
? `${status.harness}-points-elsewhere`
|
|
134
|
-
: status.connectedBefore
|
|
135
|
-
? `${status.harness}-registration-lost`
|
|
136
|
-
: `${status.harness}-not-connected`,
|
|
137
|
-
action: "connect",
|
|
138
|
-
harness: status.harness,
|
|
139
|
-
command: `tinyfish connect ${status.harness}`,
|
|
140
|
-
// Without a credential even Cursor's repair shells an interactive `auth login`.
|
|
141
|
-
unattended_safe: UNATTENDED_SAFE.has(status.harness) && credentialResolves,
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
194
|
// A revoked-but-well-formed key passes the credential check and fails the call; both need login.
|
|
145
195
|
const credentialBroken = checks.some((c) => (c.id === "cli-credential" || c.id === "cli-auth-call") && c.status === "fail");
|
|
196
|
+
// connect writes the stored key, so replace a dead one first.
|
|
146
197
|
if (credentialBroken) {
|
|
147
198
|
repairs.push({
|
|
148
199
|
for: "cli-credential-invalid",
|
|
@@ -152,26 +203,51 @@ function repairsFor(checks, statuses) {
|
|
|
152
203
|
unattended_safe: false,
|
|
153
204
|
});
|
|
154
205
|
}
|
|
206
|
+
const authCallPassed = checks.some((c) => c.id === "cli-auth-call" && c.status === "pass");
|
|
207
|
+
for (const status of statuses) {
|
|
208
|
+
// `unknown` earns nothing: connect would contradict the check's detail.
|
|
209
|
+
// A registered entry that still fails earns one too.
|
|
210
|
+
const brokenRegistration = checks.some((c) => c.harness === status.harness && c.status === "fail");
|
|
211
|
+
if (!status.detected || (status.registered !== Registered.No && !brokenRegistration))
|
|
212
|
+
continue;
|
|
213
|
+
repairs.push({
|
|
214
|
+
for: status.registered === Registered.Yes
|
|
215
|
+
? `${status.harness}-registration-broken`
|
|
216
|
+
: status.connectedBefore
|
|
217
|
+
? `${status.harness}-registration-lost`
|
|
218
|
+
: `${status.harness}-not-connected`,
|
|
219
|
+
action: "connect",
|
|
220
|
+
harness: status.harness,
|
|
221
|
+
command: `tinyfish connect ${status.harness}`,
|
|
222
|
+
// A dead well-formed key passes the format check.
|
|
223
|
+
unattended_safe: UNATTENDED_SAFE.has(status.harness) && authCallPassed,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
155
226
|
return repairs;
|
|
156
227
|
}
|
|
157
228
|
export async function runDoctor(options) {
|
|
158
229
|
const statuses = detectRegistrations(options.harness ? [options.harness] : undefined);
|
|
159
230
|
const credential = checkCredential();
|
|
231
|
+
const cliAuth = credential.key ? verifyMcpAuth(credential.key) : undefined;
|
|
232
|
+
const [connectivity, cliAuthResult, keyAuths] = await Promise.all([
|
|
233
|
+
checkConnectivity(options.mcpUrl),
|
|
234
|
+
cliAuth,
|
|
235
|
+
Promise.all(statuses.map((status) => verifyHarnessKey(status, credential.key, cliAuth, options.mcpUrl))),
|
|
236
|
+
]);
|
|
160
237
|
const checks = [
|
|
161
238
|
checkCliVersion(),
|
|
162
|
-
|
|
163
|
-
...statuses.map((status) => checkRegistration(status, options.mcpUrl)),
|
|
239
|
+
connectivity,
|
|
240
|
+
...statuses.map((status, i) => checkRegistration(status, options.mcpUrl, keyAuths[i])),
|
|
164
241
|
credential.check,
|
|
165
|
-
|
|
242
|
+
checkAuthCall(cliAuthResult),
|
|
166
243
|
];
|
|
167
|
-
const authCallPassed = checks.some((c) => c.id === "cli-auth-call" && c.status === "pass");
|
|
168
244
|
const healthOk = checks.some((c) => c.id === "mcp-connectivity" && c.status === "pass");
|
|
169
|
-
const harnesses = statuses.map((status) => ({
|
|
245
|
+
const harnesses = statuses.map((status, i) => ({
|
|
170
246
|
harness: status.harness,
|
|
171
247
|
detected: status.detected,
|
|
172
248
|
registered: status.registered,
|
|
173
249
|
auth_mode: status.authMode,
|
|
174
|
-
proves_harness_reach: provesHarnessReach(status,
|
|
250
|
+
proves_harness_reach: provesHarnessReach(status, keyAuths[i], healthOk, options.mcpUrl),
|
|
175
251
|
}));
|
|
176
252
|
const body = {
|
|
177
253
|
schema_version: DOCTOR_SCHEMA_VERSION,
|
package/dist/lib/auth.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export declare function persistApiKeyToEnvironment(apiKey: string, options?: Env
|
|
|
36
36
|
export declare function clearConfig(): boolean;
|
|
37
37
|
/** The one rule that is ours to enforce: the key becomes an HTTP header, so CR/LF cannot pass. */
|
|
38
38
|
export declare function isHeaderSafe(key: string): boolean;
|
|
39
|
+
/** No length rule: short real keys must survive. */
|
|
40
|
+
export declare function hasKeyPrefix(key: string): boolean;
|
|
39
41
|
export declare function validateKeyFormat(key: string): boolean;
|
|
40
42
|
export declare function maskKey(key: string): string;
|
|
41
43
|
export type KeySource = "explicit" | "env" | "config" | "none";
|
package/dist/lib/auth.js
CHANGED
|
@@ -192,8 +192,12 @@ export function isHeaderSafe(key) {
|
|
|
192
192
|
}
|
|
193
193
|
return true;
|
|
194
194
|
}
|
|
195
|
+
/** No length rule: short real keys must survive. */
|
|
196
|
+
export function hasKeyPrefix(key) {
|
|
197
|
+
return KEY_PREFIXES.some((p) => key.startsWith(p));
|
|
198
|
+
}
|
|
195
199
|
export function validateKeyFormat(key) {
|
|
196
|
-
return isHeaderSafe(key) &&
|
|
200
|
+
return isHeaderSafe(key) && hasKeyPrefix(key) && key.length > MIN_KEY_LENGTH;
|
|
197
201
|
}
|
|
198
202
|
export function maskKey(key) {
|
|
199
203
|
if (key.length <= 8)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import spawn from "cross-spawn";
|
|
2
2
|
import { errLine, sanitizeLine } from "./output.js";
|
|
3
|
-
import {
|
|
3
|
+
import { spawnStepError, } from "./connect-runtime.js";
|
|
4
4
|
import { codexOauthCompleted } from "./registration-detect.js";
|
|
5
5
|
const HERMES_SEED_TIMEOUT_MS = 120_000;
|
|
6
6
|
// An old install is only one reason a flag can go unseen, so no message asserts the cause.
|
|
@@ -122,7 +122,7 @@ function launchCodexWalkthrough() {
|
|
|
122
122
|
deepLink.searchParams.set("path", process.cwd());
|
|
123
123
|
const result = openExternalUrl(deepLink.toString());
|
|
124
124
|
if (result.error || result.status !== 0) {
|
|
125
|
-
throw
|
|
125
|
+
throw spawnStepError("Could not open Codex", result);
|
|
126
126
|
}
|
|
127
127
|
errLine("Codex opened with the TinyFish walkthrough ready. Send the prompt to start.");
|
|
128
128
|
}
|
|
@@ -156,10 +156,7 @@ function launchHermesWalkthrough() {
|
|
|
156
156
|
timeout: HERMES_SEED_TIMEOUT_MS,
|
|
157
157
|
});
|
|
158
158
|
if (seedResult.error || seedResult.status !== 0) {
|
|
159
|
-
|
|
160
|
-
throw new Error("Could not start the TinyFish walkthrough in Hermes", {
|
|
161
|
-
cause: seedResult.error,
|
|
162
|
-
});
|
|
159
|
+
throw spawnStepError("Could not start the TinyFish walkthrough in Hermes", seedResult);
|
|
163
160
|
}
|
|
164
161
|
// A colourised id would carry its trailing escape sequence into `--resume` (PF-3452).
|
|
165
162
|
const sessionId = sanitizeLine(seedResult.stderr ?? "").match(/session_id:\s*([^\s]+)/i)?.[1];
|
|
@@ -168,12 +165,12 @@ function launchHermesWalkthrough() {
|
|
|
168
165
|
}
|
|
169
166
|
const result = spawn.sync("hermes", ["--resume", sessionId], { stdio: "inherit" });
|
|
170
167
|
if (result.error) {
|
|
171
|
-
throw
|
|
168
|
+
throw spawnStepError("Could not launch Hermes", result);
|
|
172
169
|
}
|
|
173
170
|
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
174
171
|
return;
|
|
175
172
|
if (result.status !== 0) {
|
|
176
|
-
throw
|
|
173
|
+
throw spawnStepError(`Hermes walkthrough exited with status ${result.status ?? "unknown"}`, result);
|
|
177
174
|
}
|
|
178
175
|
}
|
|
179
176
|
export const HERMES = {
|
|
@@ -200,12 +197,12 @@ function launchOpencode() {
|
|
|
200
197
|
stdio: "inherit",
|
|
201
198
|
});
|
|
202
199
|
if (result.error) {
|
|
203
|
-
throw
|
|
200
|
+
throw spawnStepError("Could not launch OpenCode", result);
|
|
204
201
|
}
|
|
205
202
|
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
206
203
|
return;
|
|
207
204
|
if (result.status !== 0) {
|
|
208
|
-
throw
|
|
205
|
+
throw spawnStepError(`OpenCode exited with status ${result.status ?? "unknown"}`, result);
|
|
209
206
|
}
|
|
210
207
|
}
|
|
211
208
|
export const OPENCODE = {
|
|
@@ -252,12 +249,12 @@ export function launchNativeMcpClient(client) {
|
|
|
252
249
|
}
|
|
253
250
|
const result = spawn.sync(client.command, [DEFAULT_ONBOARDING_PROMPT], { stdio: "inherit" });
|
|
254
251
|
if (result.error) {
|
|
255
|
-
throw
|
|
252
|
+
throw spawnStepError(`Could not launch ${client.displayName}`, result);
|
|
256
253
|
}
|
|
257
254
|
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
258
255
|
return false;
|
|
259
256
|
if (result.status !== 0) {
|
|
260
|
-
throw
|
|
257
|
+
throw spawnStepError(`${client.displayName} walkthrough exited with status ${result.status ?? "unknown"}`, result);
|
|
261
258
|
}
|
|
262
259
|
return true;
|
|
263
260
|
}
|
|
@@ -267,12 +264,12 @@ export function launchOpenClawWalkthrough() {
|
|
|
267
264
|
stdio: "inherit",
|
|
268
265
|
});
|
|
269
266
|
if (result.error) {
|
|
270
|
-
throw
|
|
267
|
+
throw spawnStepError("Could not launch OpenClaw", result);
|
|
271
268
|
}
|
|
272
269
|
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
273
270
|
return false;
|
|
274
271
|
if (result.status !== 0) {
|
|
275
|
-
throw
|
|
272
|
+
throw spawnStepError(`OpenClaw walkthrough exited with status ${result.status ?? "unknown"}`, result);
|
|
276
273
|
}
|
|
277
274
|
return true;
|
|
278
275
|
}
|
|
@@ -2,7 +2,7 @@ import * as path from "node:path";
|
|
|
2
2
|
import spawn from "cross-spawn";
|
|
3
3
|
import { loadConfig, persistApiKeyToEnvironment, validateKeyFormat, writeConfig } from "./auth.js";
|
|
4
4
|
import { NATIVE_MCP_CLIENTS, OPENCLAW_SKILL_INSTALL_ARGS, } from "./connect-clients.js";
|
|
5
|
-
import { commandNotFound, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "./connect-runtime.js";
|
|
5
|
+
import { commandNotFound, ConnectStepError, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "./connect-runtime.js";
|
|
6
6
|
import { TINYFISH_CLI_PACKAGE } from "./constants.js";
|
|
7
7
|
import { installRoot } from "./install-root.js";
|
|
8
8
|
import { errLine } from "./output.js";
|
|
@@ -24,8 +24,7 @@ export function installTinyFishCli() {
|
|
|
24
24
|
timeout: SKILL_INSTALL_TIMEOUT_MS,
|
|
25
25
|
});
|
|
26
26
|
if (result.error || result.status !== 0) {
|
|
27
|
-
|
|
28
|
-
throw new Error("Could not install the TinyFish CLI", { cause: result.error });
|
|
27
|
+
throw spawnStepError("Could not install the TinyFish CLI", result);
|
|
29
28
|
}
|
|
30
29
|
}
|
|
31
30
|
/** `skills add` is an unconditional overwrite, so it doubles as the refresh path. */
|
|
@@ -72,10 +71,7 @@ export function installWebSkill(client) {
|
|
|
72
71
|
if (output.trim())
|
|
73
72
|
errLine(output.trimEnd());
|
|
74
73
|
if (result.error || result.status !== 0 || SKILL_INSTALL_FAILURE_PATTERN.test(output)) {
|
|
75
|
-
|
|
76
|
-
throw new Error(`Could not install the TinyFish web skill in ${client.displayName}`, {
|
|
77
|
-
cause: result.error,
|
|
78
|
-
});
|
|
74
|
+
throw spawnStepError(`Could not install the TinyFish web skill in ${client.displayName}`, result);
|
|
79
75
|
}
|
|
80
76
|
}
|
|
81
77
|
/**
|
|
@@ -164,7 +160,7 @@ export function ensureCliAuthenticated(source, apiKey) {
|
|
|
164
160
|
const envKey = apiKey ?? process.env["TINYFISH_API_KEY"];
|
|
165
161
|
if (envKey) {
|
|
166
162
|
if (!validateKeyFormat(envKey)) {
|
|
167
|
-
throw new
|
|
163
|
+
throw new ConnectStepError("TINYFISH_API_KEY has invalid format", "invalid_config");
|
|
168
164
|
}
|
|
169
165
|
// Throwing variant: a write failure must reach connect's catch/flush, not exit(1) past it.
|
|
170
166
|
writeConfig(envKey);
|
|
@@ -176,7 +172,7 @@ export function ensureCliAuthenticated(source, apiKey) {
|
|
|
176
172
|
timeout: NON_INTERACTIVE_TIMEOUT_MS,
|
|
177
173
|
});
|
|
178
174
|
if (commandNotFound(authStatus.error)) {
|
|
179
|
-
throw
|
|
175
|
+
throw spawnStepError(TINYFISH_CLI_NOT_FOUND_MESSAGE, authStatus);
|
|
180
176
|
}
|
|
181
177
|
if (authStatus.status === 0)
|
|
182
178
|
return;
|
|
@@ -184,17 +180,16 @@ export function ensureCliAuthenticated(source, apiKey) {
|
|
|
184
180
|
// "not signed in" and drop the user into an interactive login they never asked for.
|
|
185
181
|
throwIfInterrupted(authStatus);
|
|
186
182
|
if (authStatus.error) {
|
|
187
|
-
throw
|
|
183
|
+
throw spawnStepError("Could not read TinyFish CLI auth status", authStatus);
|
|
188
184
|
}
|
|
189
185
|
errLine("Signing in to the TinyFish CLI...");
|
|
190
186
|
const loginResult = spawn.sync("tinyfish", ["auth", "login", "--source", source], {
|
|
191
187
|
stdio: "inherit",
|
|
192
188
|
});
|
|
193
189
|
if (commandNotFound(loginResult.error)) {
|
|
194
|
-
throw
|
|
190
|
+
throw spawnStepError(TINYFISH_CLI_NOT_FOUND_MESSAGE, loginResult);
|
|
195
191
|
}
|
|
196
192
|
if (loginResult.error || loginResult.status !== 0) {
|
|
197
|
-
|
|
198
|
-
throw new Error("Could not authenticate the TinyFish CLI", { cause: loginResult.error });
|
|
193
|
+
throw spawnStepError("Could not authenticate the TinyFish CLI", loginResult);
|
|
199
194
|
}
|
|
200
195
|
}
|
|
@@ -1,18 +1,35 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AuthMode } from "./harness-detect.js";
|
|
2
2
|
export declare const NON_INTERACTIVE_TIMEOUT_MS = 10000;
|
|
3
3
|
export type AgentClient = "claude-code" | "codex" | "cursor" | "hermes" | "openclaw" | "opencode";
|
|
4
4
|
type ConnectStage = "started" | "checkpoint" | "completed" | "failed" | "aborted" | "post_install_failed";
|
|
5
5
|
type ConnectFailureStage = "prerequisite_check" | "registration_cleanup" | "registration" | "registration_or_authentication" | "client_oauth" | "cli_install" | "skill_install" | "authentication" | "walkthrough_launch";
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
export declare const CONNECT_FAILURE_REASONS: readonly ["harness_not_installed", "harness_command_unsupported", "harness_too_old", "command_not_found", "timeout", "spawn_error", "nonzero_exit", "invalid_config"];
|
|
7
|
+
type ConnectFailureReason = (typeof CONNECT_FAILURE_REASONS)[number];
|
|
8
|
+
export type ConnectAuthMode = Exclude<AuthMode, AuthMode.Unknown> | "deferred";
|
|
8
9
|
type ConnectCheckpoint = "prerequisite_ok" | "registered" | "oauth_done" | "cli_installed" | "skill_installed" | "authenticated";
|
|
9
10
|
/** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
|
|
10
11
|
export declare class ConnectInterruptedError extends Error {
|
|
11
12
|
}
|
|
13
|
+
/** A classified step failure; runGuarded forwards the reason. */
|
|
14
|
+
export declare class ConnectStepError extends Error {
|
|
15
|
+
readonly failureReason: ConnectFailureReason;
|
|
16
|
+
readonly harnessVersion?: string;
|
|
17
|
+
constructor(message: string, failureReason: ConnectFailureReason, opts?: {
|
|
18
|
+
harnessVersion?: string;
|
|
19
|
+
cause?: unknown;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
12
22
|
export declare function throwIfInterrupted(result: {
|
|
13
23
|
signal?: string | null;
|
|
14
24
|
error?: Error;
|
|
15
25
|
}): void;
|
|
26
|
+
interface SpawnStepResult {
|
|
27
|
+
status?: number | null;
|
|
28
|
+
signal?: string | null;
|
|
29
|
+
error?: Error;
|
|
30
|
+
}
|
|
31
|
+
/** Classifies a spawn failure; interrupts throw instead. */
|
|
32
|
+
export declare function spawnStepError(message: string, result: SpawnStepResult): ConnectStepError;
|
|
16
33
|
export declare function commandNotFound(error: unknown): boolean;
|
|
17
34
|
export interface ConnectRunState {
|
|
18
35
|
stage: ConnectFailureStage;
|
|
@@ -34,6 +51,7 @@ interface ConnectStageDetail {
|
|
|
34
51
|
export interface ConnectTelemetry {
|
|
35
52
|
attemptId: string;
|
|
36
53
|
track(stage: ConnectStage, detail?: ConnectStageDetail): void;
|
|
54
|
+
setHarnessVersion(version?: string): void;
|
|
37
55
|
flush(): Promise<void>;
|
|
38
56
|
}
|
|
39
57
|
/** Terminal events exactly once, whichever of flow or signal handler wins. */
|
|
@@ -57,6 +75,7 @@ export interface SupportedCommand {
|
|
|
57
75
|
export declare function requireCommandSupport(client: SupportedCommand): {
|
|
58
76
|
optionalSupported: boolean;
|
|
59
77
|
keyAuthSupported: boolean;
|
|
78
|
+
harnessVersion?: string;
|
|
60
79
|
};
|
|
61
80
|
export declare function createConnectTelemetry(mcpUrl: string, client: AgentClient, opts?: {
|
|
62
81
|
apiKey?: string;
|
|
@@ -7,10 +7,22 @@ import { errLine, sanitizeLine } from "./output.js";
|
|
|
7
7
|
import { postConnectEvent, telemetryDisabled } from "./setup-telemetry.js";
|
|
8
8
|
import { installSignalGuard } from "./signals.js";
|
|
9
9
|
export const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
|
|
10
|
+
// Mirrors the route's Zod enum; tests both sides pin it.
|
|
11
|
+
export const CONNECT_FAILURE_REASONS = [
|
|
12
|
+
"harness_not_installed",
|
|
13
|
+
"harness_command_unsupported",
|
|
14
|
+
"harness_too_old",
|
|
15
|
+
"command_not_found",
|
|
16
|
+
"timeout",
|
|
17
|
+
"spawn_error",
|
|
18
|
+
"nonzero_exit",
|
|
19
|
+
"invalid_config",
|
|
20
|
+
];
|
|
10
21
|
/** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
|
|
11
22
|
export class ConnectInterruptedError extends Error {
|
|
12
23
|
}
|
|
13
|
-
|
|
24
|
+
/** A classified step failure; runGuarded forwards the reason. */
|
|
25
|
+
export class ConnectStepError extends Error {
|
|
14
26
|
failureReason;
|
|
15
27
|
harnessVersion;
|
|
16
28
|
constructor(message, failureReason, opts) {
|
|
@@ -19,6 +31,8 @@ class PrerequisiteError extends Error {
|
|
|
19
31
|
this.harnessVersion = opts?.harnessVersion;
|
|
20
32
|
}
|
|
21
33
|
}
|
|
34
|
+
class PrerequisiteError extends ConnectStepError {
|
|
35
|
+
}
|
|
22
36
|
export function throwIfInterrupted(result) {
|
|
23
37
|
// spawn.sync kills a timed-out child with SIGTERM, so signal alone would misread a slow
|
|
24
38
|
// network as the user walking away. A timeout is a failure and must report as one.
|
|
@@ -28,6 +42,19 @@ export function throwIfInterrupted(result) {
|
|
|
28
42
|
throw new ConnectInterruptedError("Setup interrupted");
|
|
29
43
|
}
|
|
30
44
|
}
|
|
45
|
+
/** Classifies a spawn failure; interrupts throw instead. */
|
|
46
|
+
export function spawnStepError(message, result) {
|
|
47
|
+
throwIfInterrupted(result);
|
|
48
|
+
const code = result.error?.code;
|
|
49
|
+
const reason = !result.error
|
|
50
|
+
? "nonzero_exit"
|
|
51
|
+
: code === "ENOENT"
|
|
52
|
+
? "command_not_found"
|
|
53
|
+
: code === "ETIMEDOUT"
|
|
54
|
+
? "timeout"
|
|
55
|
+
: "spawn_error";
|
|
56
|
+
return new ConnectStepError(message, reason, { cause: result.error });
|
|
57
|
+
}
|
|
31
58
|
export function commandNotFound(error) {
|
|
32
59
|
return error?.code === "ENOENT";
|
|
33
60
|
}
|
|
@@ -65,7 +92,7 @@ export async function runGuarded(state, telemetry, body) {
|
|
|
65
92
|
}
|
|
66
93
|
settle(state, telemetry, "failed", {
|
|
67
94
|
failedStage: state.stage,
|
|
68
|
-
...(error instanceof
|
|
95
|
+
...(error instanceof ConnectStepError
|
|
69
96
|
? { failureReason: error.failureReason, harnessVersion: error.harnessVersion }
|
|
70
97
|
: {}),
|
|
71
98
|
});
|
|
@@ -88,7 +115,14 @@ export function requireCommandSupport(client) {
|
|
|
88
115
|
}
|
|
89
116
|
if (result.error || result.status !== 0) {
|
|
90
117
|
throwIfInterrupted(result);
|
|
91
|
-
|
|
118
|
+
// A hung probe is a timeout, not a missing capability.
|
|
119
|
+
if (result.error?.code === "ETIMEDOUT") {
|
|
120
|
+
throw new ConnectStepError(client.supportCheck.unavailableMessage, "timeout", {
|
|
121
|
+
cause: result.error,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// It ran and exited non-zero, so the version is still answerable.
|
|
125
|
+
throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_command_unsupported", { cause: result.error, harnessVersion: probeHarnessVersion(client.command) });
|
|
92
126
|
}
|
|
93
127
|
// Belt and braces with the colour env: a client that ignores NO_COLOR still has to match.
|
|
94
128
|
const output = sanitizeLine(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
|
|
@@ -105,7 +139,7 @@ export function requireCommandSupport(client) {
|
|
|
105
139
|
harnessVersion: probeHarnessVersion(client.command),
|
|
106
140
|
});
|
|
107
141
|
}
|
|
108
|
-
return { optionalSupported, keyAuthSupported };
|
|
142
|
+
return { optionalSupported, keyAuthSupported, harnessVersion: probeHarnessVersion(client.command) };
|
|
109
143
|
}
|
|
110
144
|
/** Best-effort; the server rejects non-printable characters and >32 chars. */
|
|
111
145
|
function probeHarnessVersion(command) {
|
|
@@ -115,7 +149,9 @@ function probeHarnessVersion(command) {
|
|
|
115
149
|
});
|
|
116
150
|
if (result.error || result.status !== 0)
|
|
117
151
|
return undefined;
|
|
152
|
+
// First line only: sanitizeLine keeps newlines, so stripping splices lines.
|
|
118
153
|
const version = sanitizeLine(result.stdout ?? "")
|
|
154
|
+
.split("\n")[0]
|
|
119
155
|
.replace(/[^\x20-\x7E]/g, "")
|
|
120
156
|
.trim();
|
|
121
157
|
return version ? version.slice(0, 32) : undefined;
|
|
@@ -140,6 +176,8 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
140
176
|
const attemptId = opts?.attemptId ?? seeded ?? randomUUID();
|
|
141
177
|
const endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
|
|
142
178
|
const pending = [];
|
|
179
|
+
let lastTrackAt;
|
|
180
|
+
let harnessVersion;
|
|
143
181
|
async function deliver(body) {
|
|
144
182
|
// Ahead of the key read; postConnectEvent's own opt-out check is too late to skip it.
|
|
145
183
|
if (telemetryDisabled())
|
|
@@ -156,7 +194,14 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
156
194
|
return {
|
|
157
195
|
attemptId,
|
|
158
196
|
// Fire-and-forget; awaiting each event stalls setup when telemetry down.
|
|
197
|
+
setHarnessVersion(version) {
|
|
198
|
+
harnessVersion = version;
|
|
199
|
+
},
|
|
159
200
|
track(stage, detail) {
|
|
201
|
+
const now = Date.now();
|
|
202
|
+
// Clamped: a backward clock step would send a route-rejected negative.
|
|
203
|
+
const stageDurationMs = lastTrackAt === undefined ? undefined : Math.max(0, now - lastTrackAt);
|
|
204
|
+
lastTrackAt = now;
|
|
160
205
|
// Absorbed here, not in flush: flush runs in runGuarded's finally, so a rejection would
|
|
161
206
|
// replace the error the flow is already reporting.
|
|
162
207
|
pending.push(deliver(JSON.stringify({
|
|
@@ -166,9 +211,10 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
166
211
|
failed_stage: detail?.failedStage,
|
|
167
212
|
phase: detail?.phase,
|
|
168
213
|
failure_reason: detail?.failureReason,
|
|
169
|
-
harness_version: detail?.harnessVersion,
|
|
214
|
+
harness_version: detail?.harnessVersion ?? harnessVersion,
|
|
170
215
|
auth_mode: detail?.authMode,
|
|
171
216
|
harness_degraded: detail?.harnessDegraded,
|
|
217
|
+
stage_duration_ms: stageDurationMs,
|
|
172
218
|
runtime_platform: process.platform,
|
|
173
219
|
node_version: process.version,
|
|
174
220
|
cli_version: CLI_VERSION,
|
|
@@ -14,9 +14,10 @@ export interface CursorTinyfishEntry {
|
|
|
14
14
|
hasApiKeyHeader: boolean;
|
|
15
15
|
/** Registered endpoint, so a caller can tell "registered" from "registered at the right place". */
|
|
16
16
|
url?: string;
|
|
17
|
+
apiKey?: string;
|
|
17
18
|
error?: string;
|
|
18
19
|
}
|
|
19
|
-
/**
|
|
20
|
+
/** Carries the header value; callers keep it off the report. */
|
|
20
21
|
export declare function readCursorTinyfishEntry(): CursorTinyfishEntry;
|
|
21
22
|
/** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
|
|
22
23
|
export declare function writeCursorMcpConfig(mcpUrl: string, apiKey?: string): CursorMcpWriteResult;
|
|
@@ -65,7 +65,7 @@ export function planCursorWrite(mcpUrl, apiKey) {
|
|
|
65
65
|
? `${filePath}: would create with a "tinyfish" MCP server entry${authNote}`
|
|
66
66
|
: `${filePath}: would back up to a timestamped copy, then merge in the "tinyfish" MCP server entry${authNote}`;
|
|
67
67
|
}
|
|
68
|
-
/**
|
|
68
|
+
/** Carries the header value; callers keep it off the report. */
|
|
69
69
|
export function readCursorTinyfishEntry() {
|
|
70
70
|
const existing = readExisting();
|
|
71
71
|
if ("error" in existing)
|
|
@@ -77,10 +77,13 @@ export function readCursorTinyfishEntry() {
|
|
|
77
77
|
const headers = entry.headers;
|
|
78
78
|
// Header names are case-insensitive, and this file is hand-editable — matching only the
|
|
79
79
|
// casing we write would understate auth mode for a user who typed it differently.
|
|
80
|
+
const keyHeader = isPlainRecord(headers)
|
|
81
|
+
? Object.entries(headers).find(([name, value]) => name.toLowerCase() === "x-api-key" && typeof value === "string")
|
|
82
|
+
: undefined;
|
|
80
83
|
return {
|
|
81
84
|
present: true,
|
|
82
|
-
hasApiKeyHeader:
|
|
83
|
-
|
|
85
|
+
hasApiKeyHeader: keyHeader !== undefined,
|
|
86
|
+
...(keyHeader ? { apiKey: keyHeader[1] } : {}),
|
|
84
87
|
...(typeof entry.url === "string" ? { url: entry.url } : {}),
|
|
85
88
|
};
|
|
86
89
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { AuthMode, Registered } from "./harness-detect.js";
|
|
2
3
|
/** Bumped whenever a consumer could misread the payload; the cookbook skill releases separately. */
|
|
3
|
-
export declare const DOCTOR_SCHEMA_VERSION =
|
|
4
|
+
export declare const DOCTOR_SCHEMA_VERSION = 2;
|
|
4
5
|
declare const checkStatusSchema: z.ZodEnum<{
|
|
5
6
|
pass: "pass";
|
|
6
7
|
fail: "fail";
|
|
@@ -36,16 +37,8 @@ declare const doctorHarnessSchema: z.ZodObject<{
|
|
|
36
37
|
"claude-code": "claude-code";
|
|
37
38
|
}>;
|
|
38
39
|
detected: z.ZodBoolean;
|
|
39
|
-
registered: z.ZodEnum<
|
|
40
|
-
|
|
41
|
-
yes: "yes";
|
|
42
|
-
no: "no";
|
|
43
|
-
}>;
|
|
44
|
-
auth_mode: z.ZodEnum<{
|
|
45
|
-
unknown: "unknown";
|
|
46
|
-
"api-key": "api-key";
|
|
47
|
-
oauth: "oauth";
|
|
48
|
-
}>;
|
|
40
|
+
registered: z.ZodEnum<typeof Registered>;
|
|
41
|
+
auth_mode: z.ZodEnum<typeof AuthMode>;
|
|
49
42
|
proves_harness_reach: z.ZodBoolean;
|
|
50
43
|
}, z.core.$strip>;
|
|
51
44
|
declare const doctorRepairSchema: z.ZodObject<{
|
|
@@ -98,16 +91,8 @@ export declare const doctorReportSchema: z.ZodObject<{
|
|
|
98
91
|
"claude-code": "claude-code";
|
|
99
92
|
}>;
|
|
100
93
|
detected: z.ZodBoolean;
|
|
101
|
-
registered: z.ZodEnum<
|
|
102
|
-
|
|
103
|
-
yes: "yes";
|
|
104
|
-
no: "no";
|
|
105
|
-
}>;
|
|
106
|
-
auth_mode: z.ZodEnum<{
|
|
107
|
-
unknown: "unknown";
|
|
108
|
-
"api-key": "api-key";
|
|
109
|
-
oauth: "oauth";
|
|
110
|
-
}>;
|
|
94
|
+
registered: z.ZodEnum<typeof Registered>;
|
|
95
|
+
auth_mode: z.ZodEnum<typeof AuthMode>;
|
|
111
96
|
proves_harness_reach: z.ZodBoolean;
|
|
112
97
|
}, z.core.$strip>>;
|
|
113
98
|
repairs: z.ZodArray<z.ZodObject<{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { ALL_HARNESSES } from "./harness-detect.js";
|
|
2
|
+
import { ALL_HARNESSES, AuthMode, Registered } from "./harness-detect.js";
|
|
3
3
|
/** Bumped whenever a consumer could misread the payload; the cookbook skill releases separately. */
|
|
4
|
-
export const DOCTOR_SCHEMA_VERSION =
|
|
4
|
+
export const DOCTOR_SCHEMA_VERSION = 2;
|
|
5
5
|
const checkStatusSchema = z.enum(["pass", "fail", "warn", "skip"]);
|
|
6
6
|
const harnessSchema = z.enum(ALL_HARNESSES);
|
|
7
7
|
const doctorCheckSchema = z.object({
|
|
@@ -14,8 +14,8 @@ const doctorCheckSchema = z.object({
|
|
|
14
14
|
const doctorHarnessSchema = z.object({
|
|
15
15
|
harness: harnessSchema,
|
|
16
16
|
detected: z.boolean(),
|
|
17
|
-
registered: z.enum(
|
|
18
|
-
auth_mode: z.enum(
|
|
17
|
+
registered: z.enum(Registered),
|
|
18
|
+
auth_mode: z.enum(AuthMode),
|
|
19
19
|
proves_harness_reach: z.boolean(),
|
|
20
20
|
});
|
|
21
21
|
// `action` is what `--fix` dispatches on, not the harness field: keying off a null harness
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
export declare const ALL_HARNESSES: readonly ["claude-code", "codex", "cursor", "hermes", "openclaw", "opencode"];
|
|
2
2
|
export type Harness = (typeof ALL_HARNESSES)[number];
|
|
3
|
+
export declare enum Registered {
|
|
4
|
+
Yes = "yes",
|
|
5
|
+
No = "no",
|
|
6
|
+
Unknown = "unknown"
|
|
7
|
+
}
|
|
8
|
+
export declare enum AuthMode {
|
|
9
|
+
ApiKey = "api-key",
|
|
10
|
+
OAuth = "oauth",
|
|
11
|
+
Unknown = "unknown"
|
|
12
|
+
}
|
|
3
13
|
export declare function harnessConfigPath(harness: Harness): string;
|
|
4
14
|
/** For reason strings that name a location; keeps them in sync with the table above. */
|
|
5
15
|
export declare function harnessDisplayPath(harness: Harness): string;
|
|
@@ -9,6 +9,19 @@ export const ALL_HARNESSES = [
|
|
|
9
9
|
"openclaw",
|
|
10
10
|
"opencode",
|
|
11
11
|
];
|
|
12
|
+
// Values are the wire contract; the report schema derives from these.
|
|
13
|
+
export var Registered;
|
|
14
|
+
(function (Registered) {
|
|
15
|
+
Registered["Yes"] = "yes";
|
|
16
|
+
Registered["No"] = "no";
|
|
17
|
+
Registered["Unknown"] = "unknown";
|
|
18
|
+
})(Registered || (Registered = {}));
|
|
19
|
+
export var AuthMode;
|
|
20
|
+
(function (AuthMode) {
|
|
21
|
+
AuthMode["ApiKey"] = "api-key";
|
|
22
|
+
AuthMode["OAuth"] = "oauth";
|
|
23
|
+
AuthMode["Unknown"] = "unknown";
|
|
24
|
+
})(AuthMode || (AuthMode = {}));
|
|
12
25
|
// Presence detection only — dir existence means "installed", nothing more.
|
|
13
26
|
const CONFIG_DIRS = {
|
|
14
27
|
"claude-code": ".claude",
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import { type Harness } from "./harness-detect.js";
|
|
2
|
-
export type Registered = "yes" | "no" | "unknown";
|
|
3
|
-
export type AuthMode = "api-key" | "oauth" | "unknown";
|
|
1
|
+
import { AuthMode, type Harness, Registered } from "./harness-detect.js";
|
|
4
2
|
export interface RegistrationStatus {
|
|
5
3
|
harness: Harness;
|
|
6
4
|
detected: boolean;
|
|
@@ -9,6 +7,13 @@ export interface RegistrationStatus {
|
|
|
9
7
|
connectedBefore: boolean;
|
|
10
8
|
/** The endpoint the harness is actually pointed at, when the probe exposes it. */
|
|
11
9
|
registeredUrl?: string;
|
|
10
|
+
/** For doctor to verify; never for the report. */
|
|
11
|
+
apiKey?: string;
|
|
12
|
+
/** From the environment, not the config, so a 401 is ambiguous. */
|
|
13
|
+
keyIsIndirect?: boolean;
|
|
14
|
+
connected?: boolean;
|
|
15
|
+
/** Why a keyed registration's key could not be read; not `reason`. */
|
|
16
|
+
keyReason?: string;
|
|
12
17
|
reason?: string;
|
|
13
18
|
}
|
|
14
19
|
export declare function codexOauthCompleted(): boolean | undefined;
|
|
@@ -2,10 +2,10 @@ import * as fs from "fs";
|
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import spawn from "cross-spawn";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { loadConfig } from "./auth.js";
|
|
5
|
+
import { hasKeyPrefix, loadConfig } from "./auth.js";
|
|
6
6
|
import { errLine } from "./output.js";
|
|
7
7
|
import { readCursorTinyfishEntry } from "./cursor-config.js";
|
|
8
|
-
import { detectInstalledHarnesses, harnessConfigPath, harnessDisplayPath, } from "./harness-detect.js";
|
|
8
|
+
import { detectInstalledHarnesses, harnessConfigPath, harnessDisplayPath, AuthMode, Registered, } from "./harness-detect.js";
|
|
9
9
|
// 6s tripped on a cold `hermes mcp list`, measured at 6.34s.
|
|
10
10
|
const PROBE_TIMEOUT_MS = 15_000;
|
|
11
11
|
// A list, verified against codex 0.146. A name-keyed map was accepted here too, but every
|
|
@@ -24,6 +24,12 @@ const UNSUPPORTED_SUBCOMMAND = /unknown command|unrecognized subcommand|invalid
|
|
|
24
24
|
// `claude mcp get` exits 1 for a usage error too, so only this message is evidence of absence.
|
|
25
25
|
const NO_SUCH_SERVER = /no (?:mcp )?server named/i;
|
|
26
26
|
const API_KEY_HEADER = /x-api-key/i;
|
|
27
|
+
// `claude mcp get` prints the value; `mcp add` masks it.
|
|
28
|
+
const API_KEY_HEADER_VALUE = /^\s*X-API-Key:\s*(\S+)\s*$/im;
|
|
29
|
+
// Charset is the redaction guard: `***`, `[REDACTED]`, dotted masks.
|
|
30
|
+
function extractableKey(value) {
|
|
31
|
+
return value && hasKeyPrefix(value) && /^[A-Za-z0-9_-]+$/.test(value) ? value : undefined;
|
|
32
|
+
}
|
|
27
33
|
// OpenCode colours its list even through a pipe, putting `\x1b[34m` between the line start and
|
|
28
34
|
// the server name, so an unstripped probe reads a registered harness as absent.
|
|
29
35
|
// eslint-disable-next-line no-control-regex
|
|
@@ -70,10 +76,23 @@ function runProbe(command, args) {
|
|
|
70
76
|
}
|
|
71
77
|
// A plugin registers its server as `plugin:<plugin>:<server>`, which `mcp get tinyfish` misses.
|
|
72
78
|
// The optional prefix must end in `:`, so `not-tinyfish` is not a match.
|
|
73
|
-
const MCP_LIST_TINYFISH_LINE = /^((?:\S*:)?tinyfish):\s+(https?:\S+)
|
|
79
|
+
const MCP_LIST_TINYFISH_LINE = /^((?:\S*:)?tinyfish):\s+(https?:\S+)(.*)$/m;
|
|
80
|
+
const SERVER_NAMED_TINYFISH = /^(?:\S*:)?tinyfish$/;
|
|
74
81
|
// A bare substring also matches `not-tinyfish` and any row quoting a tinyfish URL, so the name
|
|
75
82
|
// is required to be the first word on its line, after whatever status glyph the CLI prints.
|
|
76
83
|
const LISTED_AS_TINYFISH = /^[^\w]*tinyfish(?![\w-])/m;
|
|
84
|
+
// Claude Code reports its own live handshake; a config file cannot.
|
|
85
|
+
const MCP_GET_STATUS = /^\s*Status:\s*(.+)$/m;
|
|
86
|
+
// `mcp get <missing>` names every configured server, with no health check.
|
|
87
|
+
const CONFIGURED_SERVERS = /Configured servers:\s*(.+)$/m;
|
|
88
|
+
/** Ordered: "Connected · tools fetch failed" carries both words; the failure wins. */
|
|
89
|
+
function connectionState(status) {
|
|
90
|
+
if (/fail|error|needs auth|not configured/i.test(status))
|
|
91
|
+
return false;
|
|
92
|
+
if (/\bconnected\b/i.test(status))
|
|
93
|
+
return true;
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
77
96
|
/** The plugin ships its own MCP server, so a working install can carry no `tinyfish` entry. */
|
|
78
97
|
function fromPluginRegistration(command) {
|
|
79
98
|
const probe = runProbe(command, ["mcp", "list"]);
|
|
@@ -82,28 +101,64 @@ function fromPluginRegistration(command) {
|
|
|
82
101
|
const match = MCP_LIST_TINYFISH_LINE.exec(probe.output);
|
|
83
102
|
if (!match)
|
|
84
103
|
return undefined;
|
|
104
|
+
const connected = connectionState(match[3] ?? "");
|
|
85
105
|
// `mcp list` prints no headers, so the credential behind it is unproven, never assumed.
|
|
86
106
|
return {
|
|
87
|
-
registered:
|
|
88
|
-
authMode:
|
|
107
|
+
registered: Registered.Yes,
|
|
108
|
+
authMode: AuthMode.Unknown,
|
|
89
109
|
registeredUrl: match[2],
|
|
90
|
-
|
|
110
|
+
...(connected === undefined ? {} : { connected }),
|
|
111
|
+
reason: `registered as ${match[1]}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** Names can contain spaces, so each candidate is matched whole. */
|
|
115
|
+
function tinyfishServerName(output) {
|
|
116
|
+
const listed = CONFIGURED_SERVERS.exec(output)?.[1];
|
|
117
|
+
return listed
|
|
118
|
+
?.split(",")
|
|
119
|
+
.map((name) => name.trim())
|
|
120
|
+
.find((name) => SERVER_NAMED_TINYFISH.test(name));
|
|
121
|
+
}
|
|
122
|
+
function parseMcpGet(output, keyAuthPattern) {
|
|
123
|
+
// Positive evidence only: whether `mcp get` echoes headers at all is unverified, so absence
|
|
124
|
+
// of the pattern is `unknown`, never proof of OAuth.
|
|
125
|
+
const url = /^\s*URL:\s*(\S+)/m.exec(output)?.[1];
|
|
126
|
+
const key = extractableKey(API_KEY_HEADER_VALUE.exec(output)?.[1]);
|
|
127
|
+
const status = MCP_GET_STATUS.exec(output)?.[1];
|
|
128
|
+
const connected = status === undefined ? undefined : connectionState(status);
|
|
129
|
+
return {
|
|
130
|
+
registered: Registered.Yes,
|
|
131
|
+
authMode: keyAuthPattern.test(output) ? AuthMode.ApiKey : AuthMode.Unknown,
|
|
132
|
+
...(url ? { registeredUrl: url } : {}),
|
|
133
|
+
...(key ? { apiKey: key } : {}),
|
|
134
|
+
...(connected === undefined ? {} : { connected }),
|
|
91
135
|
};
|
|
92
136
|
}
|
|
93
137
|
// claude-code only. Codex cannot use this: its `mcp get` exits 0 on a missing server.
|
|
94
138
|
function fromMcpGet(command, keyAuthPattern) {
|
|
95
139
|
const probe = runProbe(command, ["mcp", "get", "tinyfish"]);
|
|
96
140
|
if (probe.outcome === "unavailable") {
|
|
97
|
-
return { registered:
|
|
141
|
+
return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
|
|
98
142
|
}
|
|
99
143
|
if (probe.exitCode !== 0) {
|
|
100
144
|
if (UNSUPPORTED_SUBCOMMAND.test(probe.output)) {
|
|
101
145
|
return {
|
|
102
|
-
registered:
|
|
103
|
-
authMode:
|
|
146
|
+
registered: Registered.Unknown,
|
|
147
|
+
authMode: AuthMode.Unknown,
|
|
104
148
|
reason: `\`${command} mcp get\` is unsupported by this version`,
|
|
105
149
|
};
|
|
106
150
|
}
|
|
151
|
+
// The roster is free; `mcp list` health-checks every server.
|
|
152
|
+
const scoped = tinyfishServerName(probe.output);
|
|
153
|
+
if (scoped) {
|
|
154
|
+
const rescoped = runProbe(command, ["mcp", "get", scoped]);
|
|
155
|
+
if (rescoped.outcome === "ran" && rescoped.exitCode === 0) {
|
|
156
|
+
return {
|
|
157
|
+
...parseMcpGet(rescoped.output, keyAuthPattern),
|
|
158
|
+
reason: `registered as ${scoped}`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
107
162
|
// Positive evidence from `mcp list` outranks anything inferred from a failed `mcp get`.
|
|
108
163
|
const plugin = fromPluginRegistration(command);
|
|
109
164
|
if (plugin)
|
|
@@ -111,21 +166,14 @@ function fromMcpGet(command, keyAuthPattern) {
|
|
|
111
166
|
// A broken CLI also exits nonzero, and reading that as absence earns a spurious repair.
|
|
112
167
|
if (!NO_SUCH_SERVER.test(probe.output)) {
|
|
113
168
|
return {
|
|
114
|
-
registered:
|
|
115
|
-
authMode:
|
|
169
|
+
registered: Registered.Unknown,
|
|
170
|
+
authMode: AuthMode.Unknown,
|
|
116
171
|
reason: `\`${command} mcp get\` failed without reporting the server as absent`,
|
|
117
172
|
};
|
|
118
173
|
}
|
|
119
|
-
return { registered:
|
|
174
|
+
return { registered: Registered.No, authMode: AuthMode.Unknown };
|
|
120
175
|
}
|
|
121
|
-
|
|
122
|
-
// of the pattern is `unknown`, never proof of OAuth.
|
|
123
|
-
const url = /^\s*URL:\s*(\S+)/m.exec(probe.output)?.[1];
|
|
124
|
-
return {
|
|
125
|
-
registered: "yes",
|
|
126
|
-
authMode: keyAuthPattern.test(probe.output) ? "api-key" : "unknown",
|
|
127
|
-
...(url ? { registeredUrl: url } : {}),
|
|
128
|
-
};
|
|
176
|
+
return parseMcpGet(probe.output, keyAuthPattern);
|
|
129
177
|
}
|
|
130
178
|
// `codex mcp get <missing>` prints an error and exits 0, so presence must never be read from
|
|
131
179
|
// its exit code; `mcp list --json` is the only trustworthy read-back.
|
|
@@ -167,41 +215,61 @@ export function codexOauthCompleted() {
|
|
|
167
215
|
return false;
|
|
168
216
|
return undefined;
|
|
169
217
|
}
|
|
218
|
+
// This reaches the pasted report, so its shape is checked first.
|
|
219
|
+
const ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
220
|
+
/** Codex stores a variable name, not a value, so resolve it. */
|
|
221
|
+
function codexEnvKey(envVar) {
|
|
222
|
+
if (!ENV_VAR_NAME.test(envVar)) {
|
|
223
|
+
return { keyReason: "its key comes from an unusable variable name" };
|
|
224
|
+
}
|
|
225
|
+
const key = extractableKey(process.env[envVar]);
|
|
226
|
+
if (key)
|
|
227
|
+
return { apiKey: key, keyIsIndirect: true };
|
|
228
|
+
return {
|
|
229
|
+
keyReason: process.env[envVar]
|
|
230
|
+
? `$${envVar} does not hold a TinyFish key here`
|
|
231
|
+
: `$${envVar} is not set here`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
170
234
|
function probeCodex() {
|
|
171
235
|
const read = readCodexEntry();
|
|
172
236
|
if ("reason" in read)
|
|
173
|
-
return { registered:
|
|
237
|
+
return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: read.reason };
|
|
174
238
|
const entry = read.entry;
|
|
175
239
|
if (!entry)
|
|
176
|
-
return { registered:
|
|
240
|
+
return { registered: Registered.No, authMode: AuthMode.Unknown };
|
|
177
241
|
const codexUrl = entry.transport?.url;
|
|
242
|
+
// Read from this entry: codex emits `bearer_token_env_var` on every HTTP server, so testing
|
|
243
|
+
// the whole payload reports api-key for TinyFish because some other server carries a key.
|
|
244
|
+
const envVar = entry.auth_status === "o_auth" ? undefined : entry.transport?.bearer_token_env_var;
|
|
178
245
|
return {
|
|
179
|
-
registered:
|
|
180
|
-
// Read from this entry: codex emits `bearer_token_env_var` on every HTTP server, so testing
|
|
181
|
-
// the whole payload reports api-key for TinyFish because some other server carries a key.
|
|
246
|
+
registered: Registered.Yes,
|
|
182
247
|
authMode: entry.auth_status === "o_auth"
|
|
183
|
-
?
|
|
184
|
-
:
|
|
185
|
-
?
|
|
186
|
-
:
|
|
248
|
+
? AuthMode.OAuth
|
|
249
|
+
: envVar
|
|
250
|
+
? AuthMode.ApiKey
|
|
251
|
+
: AuthMode.Unknown,
|
|
187
252
|
...(codexUrl ? { registeredUrl: codexUrl } : {}),
|
|
253
|
+
...(envVar ? codexEnvKey(envVar) : {}),
|
|
188
254
|
};
|
|
189
255
|
}
|
|
190
256
|
function probeCursor() {
|
|
191
257
|
const entry = readCursorTinyfishEntry();
|
|
192
258
|
if (entry.error) {
|
|
193
259
|
return {
|
|
194
|
-
registered:
|
|
195
|
-
authMode:
|
|
260
|
+
registered: Registered.Unknown,
|
|
261
|
+
authMode: AuthMode.Unknown,
|
|
196
262
|
reason: "mcp.json exists but could not be read or parsed",
|
|
197
263
|
};
|
|
198
264
|
}
|
|
199
265
|
if (!entry.present)
|
|
200
|
-
return { registered:
|
|
266
|
+
return { registered: Registered.No, authMode: AuthMode.Unknown };
|
|
267
|
+
const key = extractableKey(entry.apiKey);
|
|
201
268
|
return {
|
|
202
|
-
registered:
|
|
203
|
-
authMode: entry.hasApiKeyHeader ?
|
|
269
|
+
registered: Registered.Yes,
|
|
270
|
+
authMode: entry.hasApiKeyHeader ? AuthMode.ApiKey : AuthMode.Unknown,
|
|
204
271
|
...(entry.url ? { registeredUrl: entry.url } : {}),
|
|
272
|
+
...(key ? { apiKey: key } : {}),
|
|
205
273
|
};
|
|
206
274
|
}
|
|
207
275
|
// Both list commands print this header in every state, including "no servers configured".
|
|
@@ -213,31 +281,31 @@ const OPENCODE_ROW_URL = /^[^\w]*tinyfish(?![\w-]).*\n[^\w]*(https?:\S+)/m;
|
|
|
213
281
|
function fromMcpList(command, output, registeredMode, urlPattern) {
|
|
214
282
|
if (LISTED_AS_TINYFISH.test(output)) {
|
|
215
283
|
const url = urlPattern?.exec(output)?.[1];
|
|
216
|
-
return { registered:
|
|
284
|
+
return { registered: Registered.Yes, authMode: registeredMode, ...(url ? { registeredUrl: url } : {}) };
|
|
217
285
|
}
|
|
218
286
|
if (!MCP_LIST_HEADER.test(output)) {
|
|
219
287
|
return {
|
|
220
|
-
registered:
|
|
221
|
-
authMode:
|
|
288
|
+
registered: Registered.Unknown,
|
|
289
|
+
authMode: AuthMode.Unknown,
|
|
222
290
|
reason: `could not interpret \`${command} mcp list\` output`,
|
|
223
291
|
};
|
|
224
292
|
}
|
|
225
|
-
return { registered:
|
|
293
|
+
return { registered: Registered.No, authMode: AuthMode.Unknown };
|
|
226
294
|
}
|
|
227
295
|
// connect only ever writes an OAuth Hermes entry, so `oauth` is a fact, not a detection gap.
|
|
228
296
|
function probeHermes() {
|
|
229
297
|
const probe = runProbe("hermes", ["mcp", "list"]);
|
|
230
298
|
if (probe.outcome === "unavailable") {
|
|
231
|
-
return { registered:
|
|
299
|
+
return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
|
|
232
300
|
}
|
|
233
301
|
if (probe.exitCode !== 0) {
|
|
234
302
|
return {
|
|
235
|
-
registered:
|
|
236
|
-
authMode:
|
|
303
|
+
registered: Registered.Unknown,
|
|
304
|
+
authMode: AuthMode.Unknown,
|
|
237
305
|
reason: `\`hermes mcp list\` exited ${probe.exitCode}`,
|
|
238
306
|
};
|
|
239
307
|
}
|
|
240
|
-
return fromMcpList("hermes", probe.output,
|
|
308
|
+
return fromMcpList("hermes", probe.output, AuthMode.OAuth);
|
|
241
309
|
}
|
|
242
310
|
const SKILL_DIR_IS_TINYFISH = /^(?:@tinyfish[/_-])?tinyfish(?![\w-])/i;
|
|
243
311
|
// OpenClaw installs a skill, not an MCP server; the skill shells the CLI, so auth is the CLI key.
|
|
@@ -249,30 +317,30 @@ function probeOpenClaw() {
|
|
|
249
317
|
}
|
|
250
318
|
catch {
|
|
251
319
|
return {
|
|
252
|
-
registered:
|
|
253
|
-
authMode:
|
|
320
|
+
registered: Registered.Unknown,
|
|
321
|
+
authMode: AuthMode.Unknown,
|
|
254
322
|
reason: `no global skills directory at ${harnessDisplayPath("openclaw")}/skills; OpenClaw layout is unverified`,
|
|
255
323
|
};
|
|
256
324
|
}
|
|
257
325
|
// A bare substring also matched a skill merely named `not-tinyfish-thing`.
|
|
258
326
|
return entries.some((name) => SKILL_DIR_IS_TINYFISH.test(name))
|
|
259
|
-
? { registered:
|
|
260
|
-
: { registered:
|
|
327
|
+
? { registered: Registered.Yes, authMode: AuthMode.ApiKey }
|
|
328
|
+
: { registered: Registered.No, authMode: AuthMode.Unknown };
|
|
261
329
|
}
|
|
262
330
|
// `opencode mcp list` prints no headers, so a key-authed registration is indistinguishable.
|
|
263
331
|
function probeOpencode() {
|
|
264
332
|
const probe = runProbe("opencode", ["mcp", "list"]);
|
|
265
333
|
if (probe.outcome === "unavailable") {
|
|
266
|
-
return { registered:
|
|
334
|
+
return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
|
|
267
335
|
}
|
|
268
336
|
if (probe.exitCode !== 0) {
|
|
269
337
|
return {
|
|
270
|
-
registered:
|
|
271
|
-
authMode:
|
|
338
|
+
registered: Registered.Unknown,
|
|
339
|
+
authMode: AuthMode.Unknown,
|
|
272
340
|
reason: `\`opencode mcp list\` exited ${probe.exitCode}`,
|
|
273
341
|
};
|
|
274
342
|
}
|
|
275
|
-
return fromMcpList("opencode", probe.output,
|
|
343
|
+
return fromMcpList("opencode", probe.output, AuthMode.Unknown, OPENCODE_ROW_URL);
|
|
276
344
|
}
|
|
277
345
|
const PROBES = {
|
|
278
346
|
"claude-code": () => fromMcpGet("claude", API_KEY_HEADER),
|
|
@@ -295,7 +363,7 @@ export function detectRegistrations(harnesses) {
|
|
|
295
363
|
};
|
|
296
364
|
// Undetected means nothing to probe: no spawn cost, and no misleading "unknown".
|
|
297
365
|
if (!detection.detected) {
|
|
298
|
-
return { ...base, registered:
|
|
366
|
+
return { ...base, registered: Registered.No, authMode: AuthMode.Unknown };
|
|
299
367
|
}
|
|
300
368
|
try {
|
|
301
369
|
return { ...base, ...PROBES[detection.harness]() };
|
|
@@ -308,8 +376,8 @@ export function detectRegistrations(harnesses) {
|
|
|
308
376
|
}
|
|
309
377
|
return {
|
|
310
378
|
...base,
|
|
311
|
-
registered:
|
|
312
|
-
authMode:
|
|
379
|
+
registered: Registered.Unknown,
|
|
380
|
+
authMode: AuthMode.Unknown,
|
|
313
381
|
reason: "the probe failed unexpectedly",
|
|
314
382
|
};
|
|
315
383
|
}
|
package/dist/lib/verify.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export interface VerifyResult {
|
|
|
5
5
|
reason?: string;
|
|
6
6
|
/** Authored summary of `reason`, safe to publish. Additive: `connect` still prints `reason`. */
|
|
7
7
|
code?: string;
|
|
8
|
+
/** Lets doctor tell 401 from 403 without matching prose. */
|
|
9
|
+
status?: number;
|
|
8
10
|
}
|
|
9
11
|
/** Reachability check. Verify failure is a warning, never install failure. */
|
|
10
12
|
export declare function verifyMcpHealth(mcpUrl: string): Promise<VerifyResult>;
|
package/dist/lib/verify.js
CHANGED