@tyvm/knowhow 0.0.156 → 0.0.158
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 +17 -1
- package/package.json +1 -1
- package/src/agents/base/base.ts +176 -39
- package/src/auth/browserLogin.ts +2 -13
- package/src/auth/environmentAuth.ts +51 -0
- package/src/auth/jwtStore.ts +70 -0
- package/src/auth/keyAuth.ts +4 -15
- package/src/auth/registerIdentity.ts +8 -8
- package/src/chat/modules/AgentModule.ts +3 -3
- package/src/chat/modules/RemoteSyncModule.ts +45 -17
- package/src/cli.ts +2 -0
- package/src/clients/anthropic.ts +156 -79
- package/src/clients/attribution.ts +53 -0
- package/src/clients/http.ts +211 -84
- package/src/clients/index.ts +45 -13
- package/src/clients/openai.ts +441 -220
- package/src/clients/openrouter.ts +10 -3
- package/src/clients/pricing/fireworks.ts +32 -2
- package/src/clients/pricing/openai.ts +15 -1
- package/src/clients/pricing/types.ts +15 -0
- package/src/clients/responseMetadata.ts +221 -0
- package/src/clients/types.ts +49 -4
- package/src/commands/mcp.ts +1 -0
- package/src/commands/misc.ts +1 -1
- package/src/commands/remote.ts +77 -0
- package/src/config.ts +9 -1
- package/src/login.ts +54 -42
- package/src/remotes.ts +110 -0
- package/src/services/KnowhowClient.ts +12 -16
- package/src/services/Mcp.ts +94 -7
- package/src/services/McpWebsocketTransport.ts +14 -11
- package/src/types.ts +12 -0
- package/tests/unit/McpWebsocketTransport.test.ts +56 -0
- package/tests/unit/auth/jwtStore.test.ts +55 -0
- package/tests/unit/auth/registerIdentity.test.ts +8 -2
- package/tests/unit/clients/anthropic.responseMetadata.test.ts +81 -0
- package/tests/unit/clients/attribution.test.ts +185 -0
- package/tests/unit/clients/http.responseMetadata.test.ts +86 -0
- package/tests/unit/clients/openai.responseMetadata.test.ts +90 -0
- package/tests/unit/clients/responseMetadata.test.ts +123 -0
- package/tests/unit/commands/remote.test.ts +79 -0
- package/tests/unit/login.test.ts +64 -3
- package/tests/unit/services/KnowhowClient.test.ts +2 -2
- package/tests/unit/services/Mcp.test.ts +2 -2
- package/ts_build/package.json +1 -1
- package/ts_build/src/agents/base/base.d.ts +8 -3
- package/ts_build/src/agents/base/base.js +112 -26
- package/ts_build/src/agents/base/base.js.map +1 -1
- package/ts_build/src/auth/browserLogin.js +2 -7
- package/ts_build/src/auth/browserLogin.js.map +1 -1
- package/ts_build/src/auth/environmentAuth.d.ts +5 -0
- package/ts_build/src/auth/environmentAuth.js +48 -0
- package/ts_build/src/auth/environmentAuth.js.map +1 -0
- package/ts_build/src/auth/jwtStore.d.ts +4 -0
- package/ts_build/src/auth/jwtStore.js +65 -0
- package/ts_build/src/auth/jwtStore.js.map +1 -0
- package/ts_build/src/auth/keyAuth.d.ts +1 -1
- package/ts_build/src/auth/keyAuth.js +4 -16
- package/ts_build/src/auth/keyAuth.js.map +1 -1
- package/ts_build/src/auth/registerIdentity.js +6 -7
- package/ts_build/src/auth/registerIdentity.js.map +1 -1
- package/ts_build/src/chat/modules/AgentModule.js +3 -3
- package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
- package/ts_build/src/chat/modules/RemoteSyncModule.d.ts +1 -0
- package/ts_build/src/chat/modules/RemoteSyncModule.js +19 -7
- package/ts_build/src/chat/modules/RemoteSyncModule.js.map +1 -1
- package/ts_build/src/cli.js +2 -0
- package/ts_build/src/cli.js.map +1 -1
- package/ts_build/src/clients/anthropic.d.ts +1 -0
- package/ts_build/src/clients/anthropic.js +83 -38
- package/ts_build/src/clients/anthropic.js.map +1 -1
- package/ts_build/src/clients/attribution.d.ts +2 -0
- package/ts_build/src/clients/attribution.js +32 -0
- package/ts_build/src/clients/attribution.js.map +1 -0
- package/ts_build/src/clients/http.js +100 -20
- package/ts_build/src/clients/http.js.map +1 -1
- package/ts_build/src/clients/index.d.ts +2 -1
- package/ts_build/src/clients/index.js +24 -6
- package/ts_build/src/clients/index.js.map +1 -1
- package/ts_build/src/clients/openai.d.ts +2 -1
- package/ts_build/src/clients/openai.js +225 -99
- package/ts_build/src/clients/openai.js.map +1 -1
- package/ts_build/src/clients/openrouter.d.ts +2 -2
- package/ts_build/src/clients/openrouter.js +6 -2
- package/ts_build/src/clients/openrouter.js.map +1 -1
- package/ts_build/src/clients/pricing/fireworks.d.ts +4 -0
- package/ts_build/src/clients/pricing/fireworks.js +14 -2
- package/ts_build/src/clients/pricing/fireworks.js.map +1 -1
- package/ts_build/src/clients/pricing/openai.js +15 -1
- package/ts_build/src/clients/pricing/openai.js.map +1 -1
- package/ts_build/src/clients/pricing/types.d.ts +6 -0
- package/ts_build/src/clients/pricing/types.js.map +1 -1
- package/ts_build/src/clients/responseMetadata.d.ts +15 -0
- package/ts_build/src/clients/responseMetadata.js +141 -0
- package/ts_build/src/clients/responseMetadata.js.map +1 -0
- package/ts_build/src/clients/types.d.ts +18 -1
- package/ts_build/src/clients/types.js +2 -1
- package/ts_build/src/clients/types.js.map +1 -1
- package/ts_build/src/commands/mcp.js.map +1 -1
- package/ts_build/src/commands/misc.js +1 -1
- package/ts_build/src/commands/misc.js.map +1 -1
- package/ts_build/src/commands/remote.d.ts +2 -0
- package/ts_build/src/commands/remote.js +70 -0
- package/ts_build/src/commands/remote.js.map +1 -0
- package/ts_build/src/config.js +8 -0
- package/ts_build/src/config.js.map +1 -1
- package/ts_build/src/login.d.ts +4 -3
- package/ts_build/src/login.js +34 -35
- package/ts_build/src/login.js.map +1 -1
- package/ts_build/src/remotes.d.ts +14 -0
- package/ts_build/src/remotes.js +117 -0
- package/ts_build/src/remotes.js.map +1 -0
- package/ts_build/src/services/KnowhowClient.d.ts +1 -1
- package/ts_build/src/services/KnowhowClient.js +12 -14
- package/ts_build/src/services/KnowhowClient.js.map +1 -1
- package/ts_build/src/services/Mcp.d.ts +1 -1
- package/ts_build/src/services/Mcp.js +68 -7
- package/ts_build/src/services/Mcp.js.map +1 -1
- package/ts_build/src/services/McpWebsocketTransport.d.ts +1 -0
- package/ts_build/src/services/McpWebsocketTransport.js +11 -6
- package/ts_build/src/services/McpWebsocketTransport.js.map +1 -1
- package/ts_build/src/types.d.ts +13 -0
- package/ts_build/src/types.js.map +1 -1
- package/ts_build/tests/unit/McpWebsocketTransport.test.d.ts +1 -0
- package/ts_build/tests/unit/McpWebsocketTransport.test.js +47 -0
- package/ts_build/tests/unit/McpWebsocketTransport.test.js.map +1 -0
- package/ts_build/tests/unit/auth/jwtStore.test.d.ts +1 -0
- package/ts_build/tests/unit/auth/jwtStore.test.js +46 -0
- package/ts_build/tests/unit/auth/jwtStore.test.js.map +1 -0
- package/ts_build/tests/unit/auth/registerIdentity.test.js +8 -2
- package/ts_build/tests/unit/auth/registerIdentity.test.js.map +1 -1
- package/ts_build/tests/unit/clients/anthropic.responseMetadata.test.d.ts +1 -0
- package/ts_build/tests/unit/clients/anthropic.responseMetadata.test.js +77 -0
- package/ts_build/tests/unit/clients/anthropic.responseMetadata.test.js.map +1 -0
- package/ts_build/tests/unit/clients/attribution.test.d.ts +1 -0
- package/ts_build/tests/unit/clients/attribution.test.js +153 -0
- package/ts_build/tests/unit/clients/attribution.test.js.map +1 -0
- package/ts_build/tests/unit/clients/http.responseMetadata.test.d.ts +1 -0
- package/ts_build/tests/unit/clients/http.responseMetadata.test.js +77 -0
- package/ts_build/tests/unit/clients/http.responseMetadata.test.js.map +1 -0
- package/ts_build/tests/unit/clients/openai.responseMetadata.test.d.ts +1 -0
- package/ts_build/tests/unit/clients/openai.responseMetadata.test.js +86 -0
- package/ts_build/tests/unit/clients/openai.responseMetadata.test.js.map +1 -0
- package/ts_build/tests/unit/clients/responseMetadata.test.d.ts +1 -0
- package/ts_build/tests/unit/clients/responseMetadata.test.js +96 -0
- package/ts_build/tests/unit/clients/responseMetadata.test.js.map +1 -0
- package/ts_build/tests/unit/commands/remote.test.d.ts +1 -0
- package/ts_build/tests/unit/commands/remote.test.js +70 -0
- package/ts_build/tests/unit/commands/remote.test.js.map +1 -0
- package/ts_build/tests/unit/login.test.js +44 -3
- package/ts_build/tests/unit/login.test.js.map +1 -1
- package/ts_build/tests/unit/services/KnowhowClient.test.js +2 -2
- package/ts_build/tests/unit/services/KnowhowClient.test.js.map +1 -1
- package/ts_build/tests/unit/services/Mcp.test.js +2 -2
- package/ts_build/tests/unit/services/Mcp.test.js.map +1 -1
package/src/login.ts
CHANGED
|
@@ -8,6 +8,16 @@ import { KNOWHOW_API_URL } from "./services/KnowhowClient";
|
|
|
8
8
|
import { BrowserLoginService } from "./auth/browserLogin";
|
|
9
9
|
import { authenticateWithKey, hasKeyPair, registerPublicKey } from "./auth/keyAuth";
|
|
10
10
|
import { getDefaultPrivateKeyPath, getOrCreatePublicKey } from "./auth/keyManager";
|
|
11
|
+
import { storeJwtForApi } from "./auth/jwtStore";
|
|
12
|
+
import { getOrgIdForApi, setOrgIdForApi } from "./auth/environmentAuth";
|
|
13
|
+
import {
|
|
14
|
+
createRemote,
|
|
15
|
+
findRemoteForApi,
|
|
16
|
+
inferRemoteName,
|
|
17
|
+
registerRemote,
|
|
18
|
+
resolveJwtPath,
|
|
19
|
+
} from "./remotes";
|
|
20
|
+
import { KnowhowRemote } from "./types";
|
|
11
21
|
|
|
12
22
|
/**
|
|
13
23
|
* Log in to Knowhow.
|
|
@@ -23,37 +33,31 @@ import { getDefaultPrivateKeyPath, getOrCreatePublicKey } from "./auth/keyManage
|
|
|
23
33
|
* @param jwtFlag True when --jwt flag is passed.
|
|
24
34
|
* @param identityPath Optional path to a specific private key file (--identity flag).
|
|
25
35
|
*/
|
|
26
|
-
export async function login(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
36
|
+
export async function login(
|
|
37
|
+
jwtFlag?: boolean,
|
|
38
|
+
identityPath?: string,
|
|
39
|
+
apiUrl: string = KNOWHOW_API_URL,
|
|
40
|
+
remoteName?: string
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const config = await getConfig();
|
|
43
|
+
const remote = findRemoteForApi(config, apiUrl) ??
|
|
44
|
+
createRemote(remoteName ?? inferRemoteName(apiUrl), apiUrl);
|
|
30
45
|
|
|
31
46
|
if (jwtFlag) {
|
|
32
47
|
const jwt = await ask("Enter your JWT: ");
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const jwtFile = path.join(configDir, ".jwt");
|
|
36
|
-
|
|
37
|
-
if (!fs.existsSync(configDir)) {
|
|
38
|
-
fs.mkdirSync(configDir, { recursive: true });
|
|
39
|
-
}
|
|
40
|
-
fs.writeFileSync(jwtFile, jwt);
|
|
41
|
-
fs.chmodSync(jwtFile, 0o600);
|
|
42
|
-
console.log("JWT updated successfully.");
|
|
48
|
+
const jwtFile = storeJwtForApi(jwt, remote.apiUrl, remote.jwtPath);
|
|
49
|
+
console.log(`JWT updated successfully in ${path.relative(process.cwd(), jwtFile)}.`);
|
|
43
50
|
} else {
|
|
44
51
|
const selectedIdentityPath = identityPath ?? getDefaultPrivateKeyPath();
|
|
45
|
-
const
|
|
46
|
-
const orgId = config.orgId;
|
|
52
|
+
const orgId = getOrgIdForApi(config, remote.apiUrl);
|
|
47
53
|
|
|
48
|
-
// A local key is not proof of registration. Let the selected environment
|
|
49
|
-
// check it, but only when an organization is available to bind the attempt.
|
|
50
54
|
if (orgId && hasKeyPair(selectedIdentityPath)) {
|
|
51
55
|
console.log("Found CLI identity — authenticating with public key...");
|
|
52
56
|
try {
|
|
53
|
-
const success = await authenticateWithKey(orgId,
|
|
57
|
+
const success = await authenticateWithKey(orgId, remote.apiUrl, selectedIdentityPath);
|
|
54
58
|
if (success) {
|
|
55
59
|
console.log("✅ Successfully authenticated via public key!");
|
|
56
|
-
return await postLoginConfigUpdate(selectedIdentityPath);
|
|
60
|
+
return await postLoginConfigUpdate(remote, selectedIdentityPath);
|
|
57
61
|
}
|
|
58
62
|
console.warn("Key authentication returned no JWT, falling back to browser login...");
|
|
59
63
|
} catch (error: unknown) {
|
|
@@ -62,30 +66,31 @@ export async function login(jwtFlag?: boolean, identityPath?: string): Promise<v
|
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
|
|
66
|
-
await doBrowserLogin(selectedIdentityPath);
|
|
69
|
+
await doBrowserLogin(remote, selectedIdentityPath, orgId);
|
|
67
70
|
identityPath = selectedIdentityPath;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
await postLoginConfigUpdate(identityPath);
|
|
73
|
+
await postLoginConfigUpdate(remote, identityPath);
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
/** Complete browser PKCE, then best-effort bootstrap the selected identity. */
|
|
74
|
-
async function doBrowserLogin(
|
|
77
|
+
async function doBrowserLogin(
|
|
78
|
+
remote: KnowhowRemote,
|
|
79
|
+
identityPath?: string,
|
|
80
|
+
orgId?: string
|
|
81
|
+
): Promise<void> {
|
|
75
82
|
console.log("Starting browser-based authentication...");
|
|
76
83
|
try {
|
|
77
|
-
const
|
|
78
|
-
const existingOrgId = existingConfig?.orgId;
|
|
79
|
-
const browserLoginService = new BrowserLoginService(undefined, existingOrgId);
|
|
84
|
+
const browserLoginService = new BrowserLoginService(remote.apiUrl, orgId);
|
|
80
85
|
await browserLoginService.login();
|
|
81
86
|
console.log("✅ Successfully authenticated via browser!");
|
|
82
87
|
|
|
83
88
|
try {
|
|
84
89
|
const keyPair = getOrCreatePublicKey(identityPath);
|
|
85
90
|
await registerPublicKey(
|
|
86
|
-
await loadJwt(),
|
|
91
|
+
await loadJwt(remote),
|
|
87
92
|
keyPair.publicKeyBase64,
|
|
88
|
-
|
|
93
|
+
remote.apiUrl
|
|
89
94
|
);
|
|
90
95
|
console.log(`Registered CLI identity ${keyPair.fingerprint}`);
|
|
91
96
|
} catch (registrationError: unknown) {
|
|
@@ -114,10 +119,13 @@ function isTemporaryIdentityPath(identityPath: string): boolean {
|
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
/** After any successful login, update the local config with the current user/org. */
|
|
117
|
-
async function postLoginConfigUpdate(
|
|
122
|
+
async function postLoginConfigUpdate(
|
|
123
|
+
remote: KnowhowRemote,
|
|
124
|
+
identityPath?: string
|
|
125
|
+
): Promise<void> {
|
|
118
126
|
try {
|
|
119
|
-
const storedJwt = await loadJwt();
|
|
120
|
-
const { user, currentOrg } = await checkJwt(storedJwt);
|
|
127
|
+
const storedJwt = await loadJwt(remote);
|
|
128
|
+
const { user, currentOrg } = await checkJwt(storedJwt, remote.apiUrl);
|
|
121
129
|
const orgId = currentOrg?.organizationId;
|
|
122
130
|
|
|
123
131
|
console.log(
|
|
@@ -137,8 +145,10 @@ async function postLoginConfigUpdate(identityPath?: string): Promise<void> {
|
|
|
137
145
|
config.modelProviders.push({ provider: "knowhow" });
|
|
138
146
|
}
|
|
139
147
|
|
|
148
|
+
registerRemote(config, orgId ? { ...remote, orgId } : remote);
|
|
149
|
+
config.activeRemote = remote.name;
|
|
140
150
|
if (orgId) {
|
|
141
|
-
config.
|
|
151
|
+
setOrgIdForApi(config, remote.apiUrl, orgId);
|
|
142
152
|
}
|
|
143
153
|
|
|
144
154
|
if (identityPath && !isTemporaryIdentityPath(identityPath)) {
|
|
@@ -164,23 +174,25 @@ async function postLoginConfigUpdate(identityPath?: string): Promise<void> {
|
|
|
164
174
|
}
|
|
165
175
|
}
|
|
166
176
|
|
|
167
|
-
export async function loadJwt(): Promise<string> {
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const jwt = fs.readFileSync(jwtFile, "utf-8").trim();
|
|
177
|
+
export async function loadJwt(remote?: KnowhowRemote): Promise<string> {
|
|
178
|
+
const selectedRemote = remote ??
|
|
179
|
+
findRemoteForApi(await getConfig(), KNOWHOW_API_URL) ??
|
|
180
|
+
createRemote(inferRemoteName(KNOWHOW_API_URL), KNOWHOW_API_URL);
|
|
181
|
+
const jwtFile = resolveJwtPath(selectedRemote.jwtPath);
|
|
182
|
+
const jwt = fs.existsSync(jwtFile) ? fs.readFileSync(jwtFile, "utf8").trim() : "";
|
|
174
183
|
|
|
175
184
|
if (!jwt) {
|
|
185
|
+
if (!fs.existsSync(jwtFile)) {
|
|
186
|
+
throw new Error(`Error: JWT file not found: ${jwtFile}`);
|
|
187
|
+
}
|
|
176
188
|
throw new Error("Error: JWT is empty. Re-login with knowhow login --jwt.");
|
|
177
189
|
}
|
|
178
190
|
|
|
179
191
|
return jwt;
|
|
180
192
|
}
|
|
181
193
|
|
|
182
|
-
export async function checkJwt(storedJwt: string) {
|
|
183
|
-
const response = await http.get(`${
|
|
194
|
+
export async function checkJwt(storedJwt: string, apiUrl: string = KNOWHOW_API_URL) {
|
|
195
|
+
const response = await http.get(`${apiUrl}/api/users/me`, {
|
|
184
196
|
headers: {
|
|
185
197
|
Authorization: `Bearer ${storedJwt}`,
|
|
186
198
|
},
|
package/src/remotes.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { Config, KnowhowRemote } from "./types";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_REMOTE_NAME = "origin";
|
|
6
|
+
export const DEFAULT_API_URL = "https://api.knowhow.tyvm.ai";
|
|
7
|
+
|
|
8
|
+
export function normalizeApiUrl(apiUrl: string): string {
|
|
9
|
+
const trimmed = apiUrl.trim().replace(/\/+$/, "");
|
|
10
|
+
const url = new URL(trimmed);
|
|
11
|
+
if (!/^https?:$/.test(url.protocol)) {
|
|
12
|
+
throw new Error(`Remote URL must use http or https: ${apiUrl}`);
|
|
13
|
+
}
|
|
14
|
+
return url.toString().replace(/\/$/, "");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function defaultJwtPath(apiUrl: string): string {
|
|
18
|
+
const normalized = normalizeApiUrl(apiUrl);
|
|
19
|
+
if (normalized === DEFAULT_API_URL) return ".knowhow/.jwt";
|
|
20
|
+
const { hostname, port } = new URL(normalized);
|
|
21
|
+
let suffix: string;
|
|
22
|
+
if (hostname === "localhost" || hostname === "::1" || hostname.startsWith("127.")) {
|
|
23
|
+
suffix = "local";
|
|
24
|
+
} else if (hostname === "api.dev.knowhow.tyvm.ai" || hostname.includes(".dev.")) {
|
|
25
|
+
suffix = "dev";
|
|
26
|
+
} else {
|
|
27
|
+
suffix = `${hostname}${port ? `-${port}` : ""}`
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^a-z0-9.-]+/g, "-")
|
|
30
|
+
.replace(/^-+|-+$/g, "");
|
|
31
|
+
}
|
|
32
|
+
return `.knowhow/.jwt.${suffix}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createRemote(name: string, apiUrl: string): KnowhowRemote {
|
|
36
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
|
|
37
|
+
throw new Error("Remote names may only contain letters, numbers, '.', '_' and '-'.");
|
|
38
|
+
}
|
|
39
|
+
const normalizedUrl = normalizeApiUrl(apiUrl);
|
|
40
|
+
return { name, apiUrl: normalizedUrl, jwtPath: defaultJwtPath(normalizedUrl) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getConfiguredRemotes(config: Config): Record<string, KnowhowRemote> {
|
|
44
|
+
return config.remotes ?? {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function findRemoteForApi(config: Config, apiUrl: string): KnowhowRemote | undefined {
|
|
48
|
+
const normalizedUrl = normalizeApiUrl(apiUrl);
|
|
49
|
+
return Object.values(getConfiguredRemotes(config)).find(
|
|
50
|
+
(remote) => normalizeApiUrl(remote.apiUrl) === normalizedUrl
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function findRemoteForApiSync(apiUrl: string): KnowhowRemote | undefined {
|
|
55
|
+
try {
|
|
56
|
+
const config = JSON.parse(
|
|
57
|
+
fs.readFileSync(path.join(process.cwd(), ".knowhow", "knowhow.json"), "utf8")
|
|
58
|
+
) as Config;
|
|
59
|
+
return findRemoteForApi(config, apiUrl);
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getActiveRemote(config: Config): KnowhowRemote {
|
|
66
|
+
const envUrl = process.env.KNOWHOW_API_URL;
|
|
67
|
+
if (envUrl) {
|
|
68
|
+
return findRemoteForApi(config, envUrl) ?? createRemote(inferRemoteName(envUrl), envUrl);
|
|
69
|
+
}
|
|
70
|
+
const name = config.activeRemote ?? DEFAULT_REMOTE_NAME;
|
|
71
|
+
return config.remotes?.[name] ?? createRemote(DEFAULT_REMOTE_NAME, DEFAULT_API_URL);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getActiveRemoteSync(): KnowhowRemote {
|
|
75
|
+
try {
|
|
76
|
+
const config = JSON.parse(
|
|
77
|
+
fs.readFileSync(path.join(process.cwd(), ".knowhow", "knowhow.json"), "utf8")
|
|
78
|
+
) as Config;
|
|
79
|
+
return getActiveRemote(config);
|
|
80
|
+
} catch {
|
|
81
|
+
const apiUrl = process.env.KNOWHOW_API_URL ?? DEFAULT_API_URL;
|
|
82
|
+
return createRemote(inferRemoteName(apiUrl), apiUrl);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function registerRemote(config: Config, remote: KnowhowRemote): void {
|
|
87
|
+
const migratedRemotes: Record<string, KnowhowRemote> = {};
|
|
88
|
+
for (const [apiUrl, orgId] of Object.entries(config.orgIds ?? {})) {
|
|
89
|
+
const name = inferRemoteName(apiUrl);
|
|
90
|
+
migratedRemotes[name] = {
|
|
91
|
+
...createRemote(name, apiUrl),
|
|
92
|
+
orgId,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
config.remotes = { ...migratedRemotes, ...(config.remotes ?? {}), [remote.name]: remote };
|
|
96
|
+
delete config.orgIds;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function inferRemoteName(apiUrl: string): string {
|
|
100
|
+
const normalized = normalizeApiUrl(apiUrl);
|
|
101
|
+
if (normalized === DEFAULT_API_URL) return DEFAULT_REMOTE_NAME;
|
|
102
|
+
const { hostname } = new URL(normalized);
|
|
103
|
+
if (hostname === "localhost" || hostname === "::1" || hostname.startsWith("127.")) return "local";
|
|
104
|
+
if (hostname === "api.dev.knowhow.tyvm.ai" || hostname.includes(".dev.")) return "dev";
|
|
105
|
+
return hostname.toLowerCase().replace(/[^a-z0-9.-]+/g, "-");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function resolveJwtPath(jwtPath: string): string {
|
|
109
|
+
return path.resolve(process.cwd(), jwtPath);
|
|
110
|
+
}
|
|
@@ -5,8 +5,10 @@ import fs from "fs";
|
|
|
5
5
|
import { getConfigSync } from "../config";
|
|
6
6
|
import { exchangeAvailablePublicKeyJwt, storeJwt } from "../auth/keyAuth";
|
|
7
7
|
import { keyPairExists } from "../auth/keyManager";
|
|
8
|
+
import { getOrgIdForApi } from "../auth/environmentAuth";
|
|
9
|
+
import { loadJwtFromDisk } from "../auth/jwtStore";
|
|
10
|
+
import { getActiveRemoteSync } from "../remotes";
|
|
8
11
|
import { Message } from "../clients/types";
|
|
9
|
-
import path from "path";
|
|
10
12
|
import {
|
|
11
13
|
CompletionOptions,
|
|
12
14
|
CompletionResponse,
|
|
@@ -125,7 +127,7 @@ export interface GitCredentialResponse {
|
|
|
125
127
|
expiresAt: string | null;
|
|
126
128
|
}
|
|
127
129
|
|
|
128
|
-
export function loadKnowhowJwt(): string {
|
|
130
|
+
export function loadKnowhowJwt(apiUrl: string = KNOWHOW_API_URL): string {
|
|
129
131
|
// Check env var first — this is how cloud workers/CI runners get auth
|
|
130
132
|
// (injected via KNOWHOW_JWT env var in the runner's .env file by GithubRunnerService).
|
|
131
133
|
// This avoids depending on a potentially stale .knowhow/.jwt file from the snapshot.
|
|
@@ -133,17 +135,10 @@ export function loadKnowhowJwt(): string {
|
|
|
133
135
|
return process.env.KNOWHOW_JWT;
|
|
134
136
|
}
|
|
135
137
|
|
|
136
|
-
|
|
137
|
-
if (!fs.existsSync(jwtFile)) {
|
|
138
|
-
return "";
|
|
139
|
-
}
|
|
140
|
-
const jwt = fs.readFileSync(jwtFile, "utf-8").trim();
|
|
141
|
-
|
|
142
|
-
return jwt;
|
|
138
|
+
return loadJwtFromDisk(apiUrl);
|
|
143
139
|
}
|
|
144
140
|
|
|
145
|
-
export const KNOWHOW_API_URL =
|
|
146
|
-
process.env.KNOWHOW_API_URL || "https://api.knowhow.tyvm.ai";
|
|
141
|
+
export const KNOWHOW_API_URL = getActiveRemoteSync().apiUrl;
|
|
147
142
|
|
|
148
143
|
@TraceAll()
|
|
149
144
|
export class KnowhowSimpleClient {
|
|
@@ -159,7 +154,7 @@ export class KnowhowSimpleClient {
|
|
|
159
154
|
// Explicit tokens (including KNOWHOW_JWT worker credentials) have no
|
|
160
155
|
// corresponding local identity and must never trigger interactive renewal.
|
|
161
156
|
this.canRefreshJwt = jwt === undefined && !process.env.KNOWHOW_JWT;
|
|
162
|
-
this.jwt = jwt ?? loadKnowhowJwt();
|
|
157
|
+
this.jwt = jwt ?? loadKnowhowJwt(this.baseUrl);
|
|
163
158
|
this.setJwt(this.jwt);
|
|
164
159
|
if (this.canRefreshJwt) {
|
|
165
160
|
Object.defineProperty(this.headers, HTTP_UNAUTHORIZED_HANDLER, {
|
|
@@ -175,7 +170,7 @@ export class KnowhowSimpleClient {
|
|
|
175
170
|
* Reload the JWT from disk (useful after login refreshes the token).
|
|
176
171
|
*/
|
|
177
172
|
refreshJwt() {
|
|
178
|
-
const freshJwt = loadKnowhowJwt();
|
|
173
|
+
const freshJwt = loadKnowhowJwt(this.baseUrl);
|
|
179
174
|
if (freshJwt) {
|
|
180
175
|
this.setJwt(freshJwt);
|
|
181
176
|
this.jwtValidated = false;
|
|
@@ -206,12 +201,13 @@ export class KnowhowSimpleClient {
|
|
|
206
201
|
this.refreshPromise = (async () => {
|
|
207
202
|
const config = getConfigSync();
|
|
208
203
|
const identityPath = config.cliIdentityPath;
|
|
209
|
-
|
|
204
|
+
const orgId = getOrgIdForApi(config, this.baseUrl);
|
|
205
|
+
if (!orgId || (!keyPairExists(identityPath) && !keyPairExists())) {
|
|
210
206
|
throw new Error("JWT expired and the CLI identity is unavailable. Please run `knowhow login` again.");
|
|
211
207
|
}
|
|
212
208
|
try {
|
|
213
|
-
const freshJwt = await exchangeAvailablePublicKeyJwt(
|
|
214
|
-
storeJwt(freshJwt);
|
|
209
|
+
const freshJwt = await exchangeAvailablePublicKeyJwt(orgId, this.baseUrl, identityPath);
|
|
210
|
+
storeJwt(freshJwt, this.baseUrl);
|
|
215
211
|
this.setJwt(freshJwt);
|
|
216
212
|
this.jwtValidated = true;
|
|
217
213
|
} catch (error) {
|
package/src/services/Mcp.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
2
2
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
3
|
import Anthropic from "@anthropic-ai/sdk";
|
|
4
|
+
import { spawn } from "child_process";
|
|
4
5
|
|
|
5
6
|
import fs from "fs";
|
|
7
|
+
import path from "path";
|
|
6
8
|
import { McpConfig } from "../types";
|
|
7
9
|
import { Tool } from "../clients";
|
|
8
10
|
import { getConfig } from "../config";
|
|
11
|
+
import { loadJwtFromDisk } from "../auth/jwtStore";
|
|
9
12
|
import { TraceAll } from "../util/Trace";
|
|
10
13
|
import { ToolsService } from "./Tools";
|
|
11
14
|
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
@@ -30,6 +33,84 @@ export const knowhowConfig = {
|
|
|
30
33
|
export * from "./McpServer";
|
|
31
34
|
export * from "./McpWebsocketTransport";
|
|
32
35
|
|
|
36
|
+
/*
|
|
37
|
+
* Returns true if the given URL string points to localhost / 127.x / ::1.
|
|
38
|
+
*/
|
|
39
|
+
function isLocalhostUrl(url: string): boolean {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = new URL(url);
|
|
42
|
+
const host = parsed.hostname;
|
|
43
|
+
return (
|
|
44
|
+
host === "localhost" ||
|
|
45
|
+
host === "127.0.0.1" ||
|
|
46
|
+
host === "::1" ||
|
|
47
|
+
/^127\./.test(host)
|
|
48
|
+
);
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if a URL is reachable by making a simple HTTP GET/HEAD request.
|
|
56
|
+
* Resolves true if we get any HTTP response (even 4xx), false on network error.
|
|
57
|
+
*/
|
|
58
|
+
async function isUrlReachable(url: string, timeoutMs = 2000): Promise<boolean> {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
const parsed = new URL(url);
|
|
61
|
+
const lib = parsed.protocol === "https:" ? require("https") : require("http");
|
|
62
|
+
const req = lib.request(
|
|
63
|
+
{ hostname: parsed.hostname, port: parsed.port || (parsed.protocol === "https:" ? 443 : 80), path: parsed.pathname || "/", method: "GET", timeout: timeoutMs },
|
|
64
|
+
() => { resolve(true); req.destroy(); }
|
|
65
|
+
);
|
|
66
|
+
req.on("error", () => resolve(false));
|
|
67
|
+
req.on("timeout", () => { req.destroy(); resolve(false); });
|
|
68
|
+
req.end();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* If an MCP config has both a localhost `url` and a `command`, this function
|
|
74
|
+
* ensures the server is running: checks reachability, and if not up, spawns
|
|
75
|
+
* the command and waits until the URL is reachable (or timeout expires).
|
|
76
|
+
*/
|
|
77
|
+
async function ensureLocalServer(mcp: McpConfig): Promise<void> {
|
|
78
|
+
if (!mcp.url || !mcp.command || !isLocalhostUrl(mcp.url)) return;
|
|
79
|
+
|
|
80
|
+
const alreadyUp = await isUrlReachable(mcp.url);
|
|
81
|
+
if (alreadyUp) {
|
|
82
|
+
console.log(`MCP '${mcp.name}': server already running at ${mcp.url}`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log(`MCP '${mcp.name}': starting server with command: ${mcp.command} ${(mcp.args || []).join(" ")}`);
|
|
87
|
+
|
|
88
|
+
const child = spawn(mcp.command, mcp.args || [], {
|
|
89
|
+
detached: true,
|
|
90
|
+
stdio: "ignore",
|
|
91
|
+
env: mcp.env ? { ...process.env, ...mcp.env } : process.env,
|
|
92
|
+
});
|
|
93
|
+
child.unref(); // don't keep the knowhow process alive just for this child
|
|
94
|
+
|
|
95
|
+
// Wait up to 30s for the server to become reachable
|
|
96
|
+
const maxWait = 30_000;
|
|
97
|
+
const interval = 500;
|
|
98
|
+
const started = Date.now();
|
|
99
|
+
|
|
100
|
+
while (Date.now() - started < maxWait) {
|
|
101
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
102
|
+
const up = await isUrlReachable(mcp.url!);
|
|
103
|
+
if (up) {
|
|
104
|
+
console.log(`MCP '${mcp.name}': server is up at ${mcp.url}`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
throw new Error(
|
|
110
|
+
`MCP '${mcp.name}': timed out waiting for server to start at ${mcp.url} (command: ${mcp.command} ${(mcp.args || []).join(" ")})`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
33
114
|
/*
|
|
34
115
|
*
|
|
35
116
|
* McpService is a service that manages connections to multiple MCP servers.
|
|
@@ -53,7 +134,7 @@ export class McpService {
|
|
|
53
134
|
}
|
|
54
135
|
|
|
55
136
|
this.config = mcpServers;
|
|
56
|
-
this.transports = mcpServers.map((mcp) => this.createTransport(mcp));
|
|
137
|
+
this.transports = await Promise.all(mcpServers.map((mcp) => this.createTransport(mcp)));
|
|
57
138
|
this.clients = this.transports.map(() => new Client(knowhowMcpClient, knowhowConfig));
|
|
58
139
|
|
|
59
140
|
return this.clients;
|
|
@@ -64,8 +145,9 @@ export class McpService {
|
|
|
64
145
|
* Re-reads authorization_token_file every time so that token refreshes
|
|
65
146
|
* are picked up without restarting the agent process.
|
|
66
147
|
*/
|
|
67
|
-
createTransport(mcp: McpConfig): Transport {
|
|
68
|
-
|
|
148
|
+
async createTransport(mcp: McpConfig): Promise<Transport> {
|
|
149
|
+
await ensureLocalServer(mcp);
|
|
150
|
+
if (mcp.command && !mcp.url) {
|
|
69
151
|
const stdioParams: StdioServerParameters = {
|
|
70
152
|
command: mcp.command,
|
|
71
153
|
args: mcp.args,
|
|
@@ -79,8 +161,13 @@ export class McpService {
|
|
|
79
161
|
if (mcp.url) {
|
|
80
162
|
// Always re-read the token file so refreshed tokens are picked up
|
|
81
163
|
if (mcp.authorization_token_file) {
|
|
82
|
-
|
|
83
|
-
|
|
164
|
+
if (path.basename(mcp.authorization_token_file) === ".jwt") {
|
|
165
|
+
const apiUrl = process.env.KNOWHOW_API_URL || "https://api.knowhow.tyvm.ai";
|
|
166
|
+
mcp.authorization_token = loadJwtFromDisk(apiUrl, mcp.authorization_token_file);
|
|
167
|
+
} else {
|
|
168
|
+
const token = fs.readFileSync(mcp.authorization_token_file, "utf-8");
|
|
169
|
+
mcp.authorization_token = token.trim();
|
|
170
|
+
}
|
|
84
171
|
}
|
|
85
172
|
const scheme = mcp.authorization_scheme === "basic" ? "Basic" : "Bearer";
|
|
86
173
|
const transport = new StreamableHTTPClientTransport(new URL(mcp.url), {
|
|
@@ -216,7 +303,7 @@ export class McpService {
|
|
|
216
303
|
// Always create a fresh transport + client before connecting.
|
|
217
304
|
// This handles cases where a previous connection attempt failed and left
|
|
218
305
|
// the transport in a started-but-not-connected state.
|
|
219
|
-
this.transports[index] = this.createTransport(this.config[index]);
|
|
306
|
+
this.transports[index] = await this.createTransport(this.config[index]);
|
|
220
307
|
this.clients[index] = new Client(knowhowMcpClient, knowhowConfig);
|
|
221
308
|
|
|
222
309
|
const client = this.clients[index];
|
|
@@ -315,7 +402,7 @@ export class McpService {
|
|
|
315
402
|
// Replace with a fresh transport + client so connectSingle() can reconnect
|
|
316
403
|
// without hitting "StreamableHTTPClientTransport already started".
|
|
317
404
|
// createTransport() re-reads authorization_token_file, picking up refreshed tokens.
|
|
318
|
-
this.transports[index] = this.createTransport(this.config[index]);
|
|
405
|
+
this.transports[index] = await this.createTransport(this.config[index]);
|
|
319
406
|
this.clients[index] = new Client(knowhowMcpClient, knowhowConfig);
|
|
320
407
|
|
|
321
408
|
// Remove tools from cache
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
2
1
|
import {
|
|
3
2
|
JSONRPCMessage,
|
|
4
3
|
JSONRPCMessageSchema,
|
|
@@ -9,6 +8,7 @@ import { WebSocket } from "ws";
|
|
|
9
8
|
export class MCPWebSocketTransport implements Transport {
|
|
10
9
|
protected _socket: WebSocket;
|
|
11
10
|
onmessage?: (message: JSONRPCMessage) => void;
|
|
11
|
+
onrawmessage?: (message: unknown) => boolean | Promise<boolean>;
|
|
12
12
|
onerror?: (error: Error) => void;
|
|
13
13
|
onclose?: () => void;
|
|
14
14
|
|
|
@@ -27,15 +27,19 @@ export class MCPWebSocketTransport implements Transport {
|
|
|
27
27
|
} else {
|
|
28
28
|
parsed = JSON.parse(data.toString());
|
|
29
29
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
30
|
+
Promise.resolve(this.onrawmessage?.(parsed) ?? false)
|
|
31
|
+
.then((consumed) => {
|
|
32
|
+
if (consumed) return;
|
|
33
|
+
const message = JSONRPCMessageSchema.parse(parsed);
|
|
34
|
+
// Process message asynchronously to avoid blocking the WebSocket
|
|
35
|
+
// event loop while a long-running tool call is in progress.
|
|
36
|
+
// Without this, all subsequent messages are queued until the
|
|
37
|
+
// current tool call completes.
|
|
38
|
+
setImmediate(() => {
|
|
39
|
+
this.onmessage?.(message);
|
|
40
|
+
});
|
|
41
|
+
})
|
|
42
|
+
.catch((error) => this.onerror?.(error as Error));
|
|
39
43
|
} catch (error) {
|
|
40
44
|
this.onerror?.(error as Error);
|
|
41
45
|
}
|
|
@@ -53,7 +57,6 @@ export class MCPWebSocketTransport implements Transport {
|
|
|
53
57
|
async send(message: JSONRPCMessage): Promise<void> {
|
|
54
58
|
return new Promise((resolve, reject) => {
|
|
55
59
|
const json = JSON.stringify(message);
|
|
56
|
-
console.log("MCPWs sending", json);
|
|
57
60
|
|
|
58
61
|
if (this._socket.readyState !== WebSocket.OPEN) {
|
|
59
62
|
return reject(
|
package/src/types.ts
CHANGED
|
@@ -70,11 +70,23 @@ export type EmbedSource = {
|
|
|
70
70
|
minLength?: number;
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
+
export type KnowhowRemote = {
|
|
74
|
+
name: string;
|
|
75
|
+
apiUrl: string;
|
|
76
|
+
jwtPath: string;
|
|
77
|
+
orgId?: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
73
80
|
export type Config = {
|
|
74
81
|
openaiBaseUrl?: string;
|
|
75
82
|
promptsDir: string;
|
|
76
83
|
lintCommands?: { [fileExtension: string]: string };
|
|
84
|
+
/** Legacy organization field; migrated into the selected remote on login. */
|
|
77
85
|
orgId?: string;
|
|
86
|
+
/** Legacy environment map retained for reading pre-remote configurations. */
|
|
87
|
+
orgIds?: Record<string, string>;
|
|
88
|
+
remotes?: Record<string, KnowhowRemote>;
|
|
89
|
+
activeRemote?: string;
|
|
78
90
|
/** Private Ed25519 identity selected with `knowhow login --identity`. */
|
|
79
91
|
cliIdentityPath?: string;
|
|
80
92
|
syncRemote?: boolean;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
import { WebSocket } from "ws";
|
|
3
|
+
import { MCPWebSocketTransport } from "../../src/services/McpWebsocketTransport";
|
|
4
|
+
|
|
5
|
+
class TestSocket extends EventEmitter {
|
|
6
|
+
readyState = WebSocket.OPEN;
|
|
7
|
+
// Simulate send() calling the callback immediately (required by MCPWebSocketTransport.send())
|
|
8
|
+
send = jest.fn((data: string, cb?: (err?: Error) => void) => { cb?.(); });
|
|
9
|
+
close = jest.fn();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const flush = () => new Promise<void>((resolve) => setImmediate(resolve));
|
|
13
|
+
|
|
14
|
+
describe("MCPWebSocketTransport", () => {
|
|
15
|
+
it("delivers valid JSON-RPC frames to onmessage", async () => {
|
|
16
|
+
const socket = new TestSocket();
|
|
17
|
+
const transport = new MCPWebSocketTransport(socket as unknown as WebSocket);
|
|
18
|
+
const response = { jsonrpc: "2.0", id: 1, result: {} };
|
|
19
|
+
transport.onmessage = jest.fn();
|
|
20
|
+
transport.onerror = jest.fn();
|
|
21
|
+
|
|
22
|
+
await transport.start();
|
|
23
|
+
socket.emit("message", JSON.stringify(response));
|
|
24
|
+
await flush();
|
|
25
|
+
await flush();
|
|
26
|
+
|
|
27
|
+
expect(transport.onmessage).toHaveBeenCalledWith(response);
|
|
28
|
+
expect(transport.onerror).not.toHaveBeenCalled();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("calls onerror for invalid JSON-RPC frames", async () => {
|
|
32
|
+
const socket = new TestSocket();
|
|
33
|
+
const transport = new MCPWebSocketTransport(socket as unknown as WebSocket);
|
|
34
|
+
const invalid = { notJsonRpc: true };
|
|
35
|
+
transport.onmessage = jest.fn();
|
|
36
|
+
transport.onerror = jest.fn();
|
|
37
|
+
|
|
38
|
+
await transport.start();
|
|
39
|
+
socket.emit("message", JSON.stringify(invalid));
|
|
40
|
+
await flush();
|
|
41
|
+
|
|
42
|
+
expect(transport.onmessage).not.toHaveBeenCalled();
|
|
43
|
+
expect(transport.onerror).toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("sends serialized JSON over the socket", async () => {
|
|
47
|
+
const socket = new TestSocket();
|
|
48
|
+
const transport = new MCPWebSocketTransport(socket as unknown as WebSocket);
|
|
49
|
+
await transport.start();
|
|
50
|
+
|
|
51
|
+
const msg = { jsonrpc: "2.0" as const, method: "ping", id: 42 };
|
|
52
|
+
await transport.send(msg);
|
|
53
|
+
|
|
54
|
+
expect(socket.send).toHaveBeenCalledWith(JSON.stringify(msg), expect.any(Function));
|
|
55
|
+
});
|
|
56
|
+
});
|