@tyvm/knowhow 0.0.157 → 0.0.159
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 +2 -2
- package/src/agents/base/base.ts +161 -34
- 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/cli.ts +2 -0
- package/src/clients/index.ts +25 -5
- package/src/clients/pricing/fireworks.ts +32 -2
- 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/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 +2 -2
- package/ts_build/src/agents/base/base.d.ts +8 -3
- package/ts_build/src/agents/base/base.js +98 -22
- 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/cli.js +2 -0
- package/ts_build/src/cli.js.map +1 -1
- package/ts_build/src/clients/index.js +19 -5
- package/ts_build/src/clients/index.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/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/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/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
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import {
|
|
5
|
+
getJwtFileName,
|
|
6
|
+
loadJwtFromDisk,
|
|
7
|
+
storeJwtForApi,
|
|
8
|
+
} from "../../../src/auth/jwtStore";
|
|
9
|
+
|
|
10
|
+
describe("environment-specific JWT storage", () => {
|
|
11
|
+
const originalCwd = process.cwd();
|
|
12
|
+
let workDir: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "knowhow-jwt-store-"));
|
|
16
|
+
process.chdir(workDir);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
process.chdir(originalCwd);
|
|
21
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("uses readable names for production, development, and local APIs", () => {
|
|
25
|
+
expect(getJwtFileName("https://api.knowhow.tyvm.ai")).toBe(".jwt");
|
|
26
|
+
expect(getJwtFileName("https://api.dev.knowhow.tyvm.ai")).toBe(".jwt.dev");
|
|
27
|
+
expect(getJwtFileName("http://localhost:4000")).toBe(".jwt.local");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("keeps tokens for each API environment independent", () => {
|
|
31
|
+
storeJwtForApi("prod-token", "https://api.knowhow.tyvm.ai");
|
|
32
|
+
storeJwtForApi("dev-token", "https://api.dev.knowhow.tyvm.ai");
|
|
33
|
+
storeJwtForApi("local-token", "http://localhost:4000");
|
|
34
|
+
|
|
35
|
+
expect(loadJwtFromDisk("https://api.knowhow.tyvm.ai")).toBe("prod-token");
|
|
36
|
+
expect(loadJwtFromDisk("https://api.dev.knowhow.tyvm.ai")).toBe("dev-token");
|
|
37
|
+
expect(loadJwtFromDisk("http://localhost:4000")).toBe("local-token");
|
|
38
|
+
expect(fs.statSync(path.join(workDir, ".knowhow", ".jwt.dev")).mode & 0o777).toBe(0o600);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("does not send the legacy production token to another environment", () => {
|
|
42
|
+
const configDir = path.join(workDir, ".knowhow");
|
|
43
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
44
|
+
fs.writeFileSync(path.join(configDir, ".jwt"), "production-token");
|
|
45
|
+
|
|
46
|
+
expect(loadJwtFromDisk("https://api.dev.knowhow.tyvm.ai")).toBe("");
|
|
47
|
+
expect(loadJwtFromDisk("https://api.knowhow.tyvm.ai")).toBe("production-token");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("uses a host-specific file for additional Knowhow instances", () => {
|
|
51
|
+
expect(getJwtFileName("https://api.staging.example.com:8443")).toBe(
|
|
52
|
+
".jwt.api.staging.example.com-8443"
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -31,6 +31,7 @@ describe("registerIdentity", () => {
|
|
|
31
31
|
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "knowhow-register-identity-"));
|
|
32
32
|
fs.mkdirSync(path.join(workDir, ".knowhow"), { recursive: true });
|
|
33
33
|
fs.writeFileSync(path.join(workDir, ".knowhow", ".jwt"), sourceJwt);
|
|
34
|
+
fs.writeFileSync(path.join(workDir, ".knowhow", ".jwt.api.example"), sourceJwt);
|
|
34
35
|
process.chdir(workDir);
|
|
35
36
|
getOrCreatePublicKey.mockReturnValue({
|
|
36
37
|
publicKeyBase64,
|
|
@@ -53,11 +54,16 @@ describe("registerIdentity", () => {
|
|
|
53
54
|
await registerIdentity({ apiUrl: "https://api.example" });
|
|
54
55
|
expect(registerPublicKey).toHaveBeenCalledWith(sourceJwt, publicKeyBase64, "https://api.example");
|
|
55
56
|
expect(exchangePublicKeyJwt).toHaveBeenCalledWith("org-test", "https://api.example", undefined);
|
|
56
|
-
expect(storeJwt).toHaveBeenCalledWith("identity.jwt.token");
|
|
57
|
+
expect(storeJwt).toHaveBeenCalledWith("identity.jwt.token", "https://api.example");
|
|
57
58
|
expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({
|
|
58
|
-
orgId: "org-test",
|
|
59
59
|
modelProviders: [{ provider: "knowhow" }],
|
|
60
60
|
cliIdentityPath: expect.stringContaining("id_ed25519"),
|
|
61
|
+
remotes: {
|
|
62
|
+
origin: expect.objectContaining({
|
|
63
|
+
apiUrl: "https://api.example",
|
|
64
|
+
orgId: "org-test",
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
61
67
|
}));
|
|
62
68
|
});
|
|
63
69
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
|
|
3
|
+
const getConfig = jest.fn();
|
|
4
|
+
const updateConfig = jest.fn();
|
|
5
|
+
|
|
6
|
+
jest.mock("../../../src/config", () => ({ getConfig, updateConfig }));
|
|
7
|
+
|
|
8
|
+
import { addRemoteCommand } from "../../../src/commands/remote";
|
|
9
|
+
|
|
10
|
+
describe("remote command", () => {
|
|
11
|
+
let program: Command;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
jest.clearAllMocks();
|
|
15
|
+
jest.spyOn(console, "log").mockImplementation(() => undefined);
|
|
16
|
+
program = new Command();
|
|
17
|
+
program.exitOverride();
|
|
18
|
+
addRemoteCommand(program);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => jest.restoreAllMocks());
|
|
22
|
+
|
|
23
|
+
it("adds a named remote with an independent JWT path", async () => {
|
|
24
|
+
const config: any = {
|
|
25
|
+
orgIds: {
|
|
26
|
+
"http://localhost:4000": "org-local",
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
getConfig.mockResolvedValue(config);
|
|
30
|
+
|
|
31
|
+
await program.parseAsync([
|
|
32
|
+
"node",
|
|
33
|
+
"knowhow",
|
|
34
|
+
"remote",
|
|
35
|
+
"add",
|
|
36
|
+
"dev",
|
|
37
|
+
"https://api.dev.knowhow.tyvm.ai/",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
expect(updateConfig).toHaveBeenCalledWith(config);
|
|
41
|
+
expect(config.remotes.dev).toEqual({
|
|
42
|
+
name: "dev",
|
|
43
|
+
apiUrl: "https://api.dev.knowhow.tyvm.ai",
|
|
44
|
+
jwtPath: ".knowhow/.jwt.dev",
|
|
45
|
+
});
|
|
46
|
+
expect(config.orgIds).toBeUndefined();
|
|
47
|
+
expect(config.remotes.local).toEqual({
|
|
48
|
+
name: "local",
|
|
49
|
+
apiUrl: "http://localhost:4000",
|
|
50
|
+
jwtPath: ".knowhow/.jwt.local",
|
|
51
|
+
orgId: "org-local",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("selects a configured remote for subsequent commands", async () => {
|
|
56
|
+
const config: any = {
|
|
57
|
+
remotes: {
|
|
58
|
+
local: {
|
|
59
|
+
name: "local",
|
|
60
|
+
apiUrl: "http://localhost:4000",
|
|
61
|
+
jwtPath: ".knowhow/.jwt.local",
|
|
62
|
+
orgId: "org-local",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
getConfig.mockResolvedValue(config);
|
|
67
|
+
|
|
68
|
+
await program.parseAsync([
|
|
69
|
+
"node",
|
|
70
|
+
"knowhow",
|
|
71
|
+
"remote",
|
|
72
|
+
"use",
|
|
73
|
+
"local",
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
expect(config.activeRemote).toBe("local");
|
|
77
|
+
expect(updateConfig).toHaveBeenCalledWith(config);
|
|
78
|
+
});
|
|
79
|
+
});
|
package/tests/unit/login.test.ts
CHANGED
|
@@ -48,7 +48,7 @@ describe("login public-key bootstrap", () => {
|
|
|
48
48
|
jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
49
49
|
workingDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "knowhow-login-"));
|
|
50
50
|
fs.mkdirSync(path.join(workingDirectory, ".knowhow"));
|
|
51
|
-
fs.writeFileSync(path.join(workingDirectory, ".knowhow", ".jwt"), "raw-jwt");
|
|
51
|
+
fs.writeFileSync(path.join(workingDirectory, ".knowhow", ".jwt.api.example"), "raw-jwt");
|
|
52
52
|
process.chdir(workingDirectory);
|
|
53
53
|
hasKeyPair.mockReturnValue(false);
|
|
54
54
|
browserLogin.mockResolvedValue(undefined);
|
|
@@ -87,7 +87,42 @@ describe("login public-key bootstrap", () => {
|
|
|
87
87
|
"https://api.example"
|
|
88
88
|
);
|
|
89
89
|
expect(updateConfig).toHaveBeenCalledWith(
|
|
90
|
-
expect.objectContaining({
|
|
90
|
+
expect.objectContaining({
|
|
91
|
+
activeRemote: "api.example",
|
|
92
|
+
modelProviders: [{ provider: "knowhow" }],
|
|
93
|
+
remotes: expect.objectContaining({
|
|
94
|
+
"api.example": {
|
|
95
|
+
name: "api.example",
|
|
96
|
+
apiUrl: "https://api.example",
|
|
97
|
+
jwtPath: ".knowhow/.jwt.api.example",
|
|
98
|
+
orgId: "org-1",
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("persists an explicit --api-url as the authenticated remote", async () => {
|
|
106
|
+
fs.writeFileSync(path.join(workingDirectory, ".knowhow", ".jwt.local"), "local-jwt");
|
|
107
|
+
|
|
108
|
+
await login(false, undefined, "http://localhost:4000/", "local");
|
|
109
|
+
|
|
110
|
+
expect(updateConfig).toHaveBeenCalledWith(
|
|
111
|
+
expect.objectContaining({
|
|
112
|
+
activeRemote: "local",
|
|
113
|
+
remotes: expect.objectContaining({
|
|
114
|
+
local: {
|
|
115
|
+
name: "local",
|
|
116
|
+
apiUrl: "http://localhost:4000",
|
|
117
|
+
jwtPath: ".knowhow/.jwt.local",
|
|
118
|
+
orgId: "org-1",
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
expect(httpGet).toHaveBeenCalledWith(
|
|
124
|
+
"http://localhost:4000/api/users/me",
|
|
125
|
+
expect.any(Object)
|
|
91
126
|
);
|
|
92
127
|
});
|
|
93
128
|
|
|
@@ -119,9 +154,9 @@ describe("login public-key bootstrap", () => {
|
|
|
119
154
|
expect(getOrCreatePublicKey).toHaveBeenCalledWith("/keys/default");
|
|
120
155
|
expect(updateConfig).toHaveBeenCalledWith(
|
|
121
156
|
expect.objectContaining({
|
|
122
|
-
orgId: "org-1",
|
|
123
157
|
cliIdentityPath: "/keys/default",
|
|
124
158
|
modelProviders: [{ provider: "knowhow" }],
|
|
159
|
+
remotes: expect.objectContaining({ "api.example": expect.objectContaining({ orgId: "org-1" }) }),
|
|
125
160
|
})
|
|
126
161
|
);
|
|
127
162
|
});
|
|
@@ -143,6 +178,32 @@ describe("login public-key bootstrap", () => {
|
|
|
143
178
|
expect(getOrCreatePublicKey).toHaveBeenCalledWith("/keys/dev");
|
|
144
179
|
});
|
|
145
180
|
|
|
181
|
+
it("uses the selected API's JWT org instead of an org saved by another environment", async () => {
|
|
182
|
+
const devJwt = `${Buffer.from("{}").toString("base64url")}.${Buffer.from(
|
|
183
|
+
JSON.stringify({ org: "org-dev" })
|
|
184
|
+
).toString("base64url")}.signature`;
|
|
185
|
+
fs.writeFileSync(
|
|
186
|
+
path.join(workingDirectory, ".knowhow", ".jwt.api.example"),
|
|
187
|
+
devJwt
|
|
188
|
+
);
|
|
189
|
+
hasKeyPair.mockReturnValue(true);
|
|
190
|
+
getConfig.mockResolvedValue({
|
|
191
|
+
orgId: "org-local",
|
|
192
|
+
orgIds: { "http://localhost:4000": "org-local" },
|
|
193
|
+
modelProviders: [],
|
|
194
|
+
});
|
|
195
|
+
authenticateWithKey.mockResolvedValueOnce(true);
|
|
196
|
+
|
|
197
|
+
await expect(login(false, "/keys/default")).resolves.toBeUndefined();
|
|
198
|
+
|
|
199
|
+
expect(authenticateWithKey).toHaveBeenCalledWith(
|
|
200
|
+
"org-dev",
|
|
201
|
+
"https://api.example",
|
|
202
|
+
"/keys/default"
|
|
203
|
+
);
|
|
204
|
+
expect(browserLogin).not.toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
|
|
146
207
|
it("does not attempt key authentication without a configured organization", async () => {
|
|
147
208
|
hasKeyPair.mockReturnValue(true);
|
|
148
209
|
getConfig.mockResolvedValue({ modelProviders: [] });
|
|
@@ -54,7 +54,7 @@ describe("KnowhowSimpleClient identity renewal", () => {
|
|
|
54
54
|
"https://api.example.test",
|
|
55
55
|
"/tmp/identity"
|
|
56
56
|
);
|
|
57
|
-
expect(mockStoreJwt).toHaveBeenCalledWith("fresh-jwt");
|
|
57
|
+
expect(mockStoreJwt).toHaveBeenCalledWith("fresh-jwt", "https://api.example.test");
|
|
58
58
|
expect(mockGet.mock.calls[0][1].headers.Authorization).toBe("Bearer fresh-jwt");
|
|
59
59
|
});
|
|
60
60
|
|
|
@@ -77,7 +77,7 @@ describe("KnowhowSimpleClient identity renewal", () => {
|
|
|
77
77
|
"https://api.example.test",
|
|
78
78
|
"/tmp/identity"
|
|
79
79
|
);
|
|
80
|
-
expect(mockStoreJwt).toHaveBeenCalledWith("fresh-jwt");
|
|
80
|
+
expect(mockStoreJwt).toHaveBeenCalledWith("fresh-jwt", "https://api.example.test");
|
|
81
81
|
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
82
82
|
expect(mockGet.mock.calls[0][1].headers.Authorization).toBe("Bearer fresh-jwt");
|
|
83
83
|
});
|
|
@@ -33,11 +33,11 @@ describe("McpService JWT file transport", () => {
|
|
|
33
33
|
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
it("reads the refreshed JWT when the transport is created", () => {
|
|
36
|
+
it("reads the refreshed JWT when the transport is created", async () => {
|
|
37
37
|
fs.writeFileSync(tokenFile, "expired-jwt");
|
|
38
38
|
fs.writeFileSync(tokenFile, "fresh-jwt");
|
|
39
39
|
|
|
40
|
-
new McpService().createTransport({
|
|
40
|
+
await new McpService().createTransport({
|
|
41
41
|
name: "authenticated-remote",
|
|
42
42
|
url: "https://api.example.test/mcp",
|
|
43
43
|
authorization_token_file: tokenFile,
|
package/ts_build/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tyvm/knowhow",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.159",
|
|
4
4
|
"description": "ai cli with plugins and agents",
|
|
5
5
|
"main": "ts_build/src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"@inquirer/editor": "^4.2.18",
|
|
59
59
|
"@modelcontextprotocol/sdk": "^1.13.3",
|
|
60
60
|
"@simplewebauthn/server": "^13.3.0",
|
|
61
|
-
"@tyvm/knowhow-tunnel": "0.0.
|
|
61
|
+
"@tyvm/knowhow-tunnel": "0.0.6",
|
|
62
62
|
"commander": "^14.0.0",
|
|
63
63
|
"diff": "^5.2.0",
|
|
64
64
|
"express": "^4.19.2",
|
|
@@ -70,6 +70,9 @@ export declare abstract class BaseAgent implements IAgent {
|
|
|
70
70
|
protected compressThreshold: number;
|
|
71
71
|
protected compressMinMessages: number;
|
|
72
72
|
protected _forceCompact: boolean;
|
|
73
|
+
private _compactionPromise;
|
|
74
|
+
private _completedCompaction;
|
|
75
|
+
private _compactionGeneration;
|
|
73
76
|
private _interruptResolve;
|
|
74
77
|
private _interruptToken;
|
|
75
78
|
protected threads: Message[][];
|
|
@@ -147,7 +150,7 @@ export declare abstract class BaseAgent implements IAgent {
|
|
|
147
150
|
getTurnCount(): number;
|
|
148
151
|
adjustTotalCostUsd(cost: number): void;
|
|
149
152
|
getTotalCostUsd(): number;
|
|
150
|
-
adjustTokenUsage(usage: any, messages?: Message[]): void;
|
|
153
|
+
adjustTokenUsage(usage: any, messages?: Message[], updateLastPromptTokens?: boolean): void;
|
|
151
154
|
getTokenUsage(): {
|
|
152
155
|
totalInputTokens: number;
|
|
153
156
|
totalOutputTokens: number;
|
|
@@ -156,6 +159,7 @@ export declare abstract class BaseAgent implements IAgent {
|
|
|
156
159
|
};
|
|
157
160
|
protected createAgentCompletion(params: CachedChatCompletion, options?: {
|
|
158
161
|
interruptValue?: CompletionResponse;
|
|
162
|
+
updateLastPromptTokens?: boolean;
|
|
159
163
|
}): Promise<CompletionResponse>;
|
|
160
164
|
startNewThread(messages: Message[]): void;
|
|
161
165
|
updateCurrentThread(messages: Message[]): void;
|
|
@@ -192,6 +196,7 @@ export declare abstract class BaseAgent implements IAgent {
|
|
|
192
196
|
content: string;
|
|
193
197
|
} | null;
|
|
194
198
|
extractContentFromMessages(messages: Message[]): string;
|
|
195
|
-
|
|
196
|
-
|
|
199
|
+
private startBackgroundCompaction;
|
|
200
|
+
getTaskBreakdown(messages: Message[], cacheResult?: boolean): Promise<string>;
|
|
201
|
+
compressMessages(messages: Message[], onSummary?: (summary: string) => void, taskBreakdown?: string): Promise<Message[]>;
|
|
197
202
|
}
|