@tyvm/knowhow 0.0.155 → 0.0.156
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/package.json +1 -1
- package/src/agents/tools/index.ts +1 -0
- package/src/agents/tools/list.ts +2 -0
- package/src/agents/tools/loadImageAsBase64.ts +83 -0
- package/src/auth/keyAuth.ts +5 -5
- package/src/commands/services.ts +23 -18
- package/src/login.ts +22 -6
- package/src/processors/Base64ImageDetector.ts +12 -11
- package/src/tunnel.ts +13 -3
- package/tests/processors/Base64ImageDetector.test.ts +32 -0
- package/tests/unit/agents/tools/loadImageAsBase64.test.ts +44 -0
- package/tests/unit/auth/keyAuth.test.ts +21 -0
- package/tests/unit/login.test.ts +39 -1
- package/tests/unit/services/Mcp.test.ts +56 -0
- package/tests/unit/tunnel.test.ts +51 -0
- package/ts_build/package.json +1 -1
- package/ts_build/src/agents/tools/index.d.ts +1 -0
- package/ts_build/src/agents/tools/index.js +1 -0
- package/ts_build/src/agents/tools/index.js.map +1 -1
- package/ts_build/src/agents/tools/list.js +2 -0
- package/ts_build/src/agents/tools/list.js.map +1 -1
- package/ts_build/src/agents/tools/loadImageAsBase64.d.ts +3 -0
- package/ts_build/src/agents/tools/loadImageAsBase64.js +79 -0
- package/ts_build/src/agents/tools/loadImageAsBase64.js.map +1 -0
- package/ts_build/src/auth/keyAuth.js +5 -5
- package/ts_build/src/auth/keyAuth.js.map +1 -1
- package/ts_build/src/commands/services.js +15 -8
- package/ts_build/src/commands/services.js.map +1 -1
- package/ts_build/src/login.js +19 -5
- package/ts_build/src/login.js.map +1 -1
- package/ts_build/src/processors/Base64ImageDetector.js +7 -3
- package/ts_build/src/processors/Base64ImageDetector.js.map +1 -1
- package/ts_build/src/tunnel.js +9 -1
- package/ts_build/src/tunnel.js.map +1 -1
- package/ts_build/tests/processors/Base64ImageDetector.test.js +28 -0
- package/ts_build/tests/processors/Base64ImageDetector.test.js.map +1 -1
- package/ts_build/tests/unit/agents/tools/loadImageAsBase64.test.d.ts +1 -0
- package/ts_build/tests/unit/agents/tools/loadImageAsBase64.test.js +67 -0
- package/ts_build/tests/unit/agents/tools/loadImageAsBase64.test.js.map +1 -0
- package/ts_build/tests/unit/auth/keyAuth.test.js +21 -0
- package/ts_build/tests/unit/auth/keyAuth.test.js.map +1 -1
- package/ts_build/tests/unit/login.test.js +29 -1
- package/ts_build/tests/unit/login.test.js.map +1 -1
- package/ts_build/tests/unit/services/Mcp.test.d.ts +1 -0
- package/ts_build/tests/unit/services/Mcp.test.js +45 -0
- package/ts_build/tests/unit/services/Mcp.test.js.map +1 -0
- package/ts_build/tests/unit/tunnel.test.d.ts +1 -0
- package/ts_build/tests/unit/tunnel.test.js +47 -0
- package/ts_build/tests/unit/tunnel.test.js.map +1 -0
package/package.json
CHANGED
package/src/agents/tools/list.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
stopObservingDefinition,
|
|
21
21
|
} from "./observe";
|
|
22
22
|
import { skillsToolDefinitions } from "./skills";
|
|
23
|
+
import { loadImageAsBase64Definition } from "./loadImageAsBase64";
|
|
23
24
|
|
|
24
25
|
function getPluginNames(): string {
|
|
25
26
|
try {
|
|
@@ -720,6 +721,7 @@ export const includedTools = [
|
|
|
720
721
|
},
|
|
721
722
|
},
|
|
722
723
|
},
|
|
724
|
+
loadImageAsBase64Definition,
|
|
723
725
|
googleSearchDefinition,
|
|
724
726
|
startAgentTaskDefinition,
|
|
725
727
|
replyToParentDefinition,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { Tool } from "../../clients/types";
|
|
4
|
+
|
|
5
|
+
const MAX_IMAGE_BYTES = 15 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
const MIME_TYPES: Record<string, string> = {
|
|
8
|
+
png: "image/png",
|
|
9
|
+
jpg: "image/jpeg",
|
|
10
|
+
jpeg: "image/jpeg",
|
|
11
|
+
gif: "image/gif",
|
|
12
|
+
webp: "image/webp",
|
|
13
|
+
bmp: "image/bmp",
|
|
14
|
+
svg: "image/svg+xml",
|
|
15
|
+
avif: "image/avif",
|
|
16
|
+
ico: "image/x-icon",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function detectMimeType(buffer: Buffer, fallback: string): string {
|
|
20
|
+
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
|
21
|
+
return "image/jpeg";
|
|
22
|
+
}
|
|
23
|
+
if (buffer.length >= 4 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
|
|
24
|
+
return "image/png";
|
|
25
|
+
}
|
|
26
|
+
if (buffer.length >= 4 && buffer.subarray(0, 4).toString("ascii") === "GIF8") {
|
|
27
|
+
return "image/gif";
|
|
28
|
+
}
|
|
29
|
+
if (
|
|
30
|
+
buffer.length >= 12 &&
|
|
31
|
+
buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
|
|
32
|
+
buffer.subarray(8, 12).toString("ascii") === "WEBP"
|
|
33
|
+
) {
|
|
34
|
+
return "image/webp";
|
|
35
|
+
}
|
|
36
|
+
return fallback;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Load a reasonably sized image from the worker filesystem for UI/AI preview. */
|
|
40
|
+
export async function loadImageAsBase64(
|
|
41
|
+
filePath: string,
|
|
42
|
+
detail: "auto" | "low" | "high" = "auto"
|
|
43
|
+
): Promise<string> {
|
|
44
|
+
const stats = await fs.promises.stat(filePath).catch(() => null);
|
|
45
|
+
if (!stats) throw new Error(`File not found: ${filePath}`);
|
|
46
|
+
if (!stats.isFile()) throw new Error(`Path is not a file: ${filePath}`);
|
|
47
|
+
if (stats.size > MAX_IMAGE_BYTES) {
|
|
48
|
+
throw new Error(`Image is too large to preview (${Math.ceil(stats.size / 1024 / 1024)} MB; maximum 15 MB)`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const extension = path.extname(filePath).slice(1).toLowerCase();
|
|
52
|
+
const extensionMimeType = MIME_TYPES[extension];
|
|
53
|
+
if (!extensionMimeType) {
|
|
54
|
+
throw new Error(`Unsupported image format: ${extension || "unknown"}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const image = await fs.promises.readFile(filePath);
|
|
58
|
+
const mimeType = detectMimeType(image, extensionMimeType);
|
|
59
|
+
return JSON.stringify({
|
|
60
|
+
type: "image_url",
|
|
61
|
+
image_url: {
|
|
62
|
+
url: `data:${mimeType};base64,${image.toString("base64")}`,
|
|
63
|
+
detail,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const loadImageAsBase64Definition: Tool = {
|
|
69
|
+
type: "function",
|
|
70
|
+
function: {
|
|
71
|
+
name: "loadImageAsBase64",
|
|
72
|
+
description: "Load an image from the worker filesystem and return it as a base64 data URL for previewing.",
|
|
73
|
+
parameters: {
|
|
74
|
+
type: "object",
|
|
75
|
+
positional: true,
|
|
76
|
+
properties: {
|
|
77
|
+
filePath: { type: "string", description: "Absolute or workspace-relative image path" },
|
|
78
|
+
detail: { type: "string", enum: ["auto", "low", "high"], description: "Image detail hint" },
|
|
79
|
+
},
|
|
80
|
+
required: ["filePath"],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
};
|
package/src/auth/keyAuth.ts
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
signMessage,
|
|
11
11
|
} from "./keyManager";
|
|
12
12
|
|
|
13
|
-
const JWT_FILE_PATH = path.join(process.cwd(), ".knowhow", ".jwt");
|
|
14
13
|
const DEFAULT_API_URL = process.env.KNOWHOW_API_URL || "https://api.knowhow.tyvm.ai";
|
|
15
14
|
|
|
16
15
|
/**
|
|
@@ -140,13 +139,14 @@ export async function registerPublicKey(
|
|
|
140
139
|
}
|
|
141
140
|
|
|
142
141
|
export function storeJwt(jwt: string): void {
|
|
143
|
-
const
|
|
144
|
-
const
|
|
142
|
+
const jwtFilePath = path.join(process.cwd(), ".knowhow", ".jwt");
|
|
143
|
+
const directory = path.dirname(jwtFilePath);
|
|
144
|
+
const temporaryPath = `${jwtFilePath}.${process.pid}.tmp`;
|
|
145
145
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
146
146
|
try {
|
|
147
147
|
fs.writeFileSync(temporaryPath, jwt, { mode: 0o600, flag: "wx" });
|
|
148
|
-
fs.renameSync(temporaryPath,
|
|
149
|
-
fs.chmodSync(
|
|
148
|
+
fs.renameSync(temporaryPath, jwtFilePath);
|
|
149
|
+
fs.chmodSync(jwtFilePath, 0o600);
|
|
150
150
|
} finally {
|
|
151
151
|
fs.rmSync(temporaryPath, { force: true });
|
|
152
152
|
}
|
package/src/commands/services.ts
CHANGED
|
@@ -69,32 +69,37 @@ export async function setupServices() {
|
|
|
69
69
|
|
|
70
70
|
Agents.setAgentContext(agentContext);
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
await Mcp.connectToConfigured(Tools);
|
|
75
|
-
} catch (mcpError) {
|
|
76
|
-
const msg = mcpError instanceof Error ? mcpError.message : String(mcpError);
|
|
77
|
-
console.warn(
|
|
78
|
-
`⚠ Some MCP servers failed to connect (continuing without them): ${msg}`
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
// Attempt silent JWT refresh via SSH identity before registering models.
|
|
82
|
-
// This handles the case where the JWT has expired but the user has a valid
|
|
83
|
-
// SSH identity registered — they shouldn't need to run `knowhow login` again
|
|
84
|
-
// unless they want to switch organizations.
|
|
72
|
+
// Refresh authentication before MCP transports, model clients, or remote
|
|
73
|
+
// sync modules can read the project JWT file.
|
|
85
74
|
try {
|
|
86
75
|
const startupConfig = getConfigSync();
|
|
87
76
|
if (startupConfig.orgId && !process.env.KNOWHOW_JWT) {
|
|
88
|
-
await authenticateWithKey(
|
|
77
|
+
const refreshed = await authenticateWithKey(
|
|
89
78
|
startupConfig.orgId,
|
|
90
79
|
process.env.KNOWHOW_API_URL || "https://api.knowhow.tyvm.ai",
|
|
91
80
|
startupConfig.cliIdentityPath
|
|
92
81
|
);
|
|
82
|
+
if (!refreshed) {
|
|
83
|
+
console.warn(
|
|
84
|
+
`⚠ Could not renew the Knowhow session: no available CLI identity is registered ` +
|
|
85
|
+
`for configured organization ${startupConfig.orgId}. Run \`knowhow login\` to select ` +
|
|
86
|
+
`an organization and register the global identity.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
93
89
|
}
|
|
94
|
-
} catch (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
92
|
+
console.warn(`⚠ Could not renew the Knowhow session before startup: ${message}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log("🔌 Connecting to MCP...");
|
|
96
|
+
try {
|
|
97
|
+
await Mcp.connectToConfigured(Tools);
|
|
98
|
+
} catch (mcpError) {
|
|
99
|
+
const msg = mcpError instanceof Error ? mcpError.message : String(mcpError);
|
|
100
|
+
console.warn(
|
|
101
|
+
`⚠ Some MCP servers failed to connect (continuing without them): ${msg}`
|
|
102
|
+
);
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
console.log("Connecting to clients...");
|
package/src/login.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import http from "./utils/http";
|
|
2
2
|
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
3
4
|
import path from "path";
|
|
4
5
|
import { ask } from "./utils";
|
|
5
6
|
import { getConfig, updateConfig } from "./config";
|
|
6
7
|
import { KNOWHOW_API_URL } from "./services/KnowhowClient";
|
|
7
8
|
import { BrowserLoginService } from "./auth/browserLogin";
|
|
8
9
|
import { authenticateWithKey, hasKeyPair, registerPublicKey } from "./auth/keyAuth";
|
|
9
|
-
import { getOrCreatePublicKey } from "./auth/keyManager";
|
|
10
|
+
import { getDefaultPrivateKeyPath, getOrCreatePublicKey } from "./auth/keyManager";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Log in to Knowhow.
|
|
@@ -40,18 +41,19 @@ export async function login(jwtFlag?: boolean, identityPath?: string): Promise<v
|
|
|
40
41
|
fs.chmodSync(jwtFile, 0o600);
|
|
41
42
|
console.log("JWT updated successfully.");
|
|
42
43
|
} else {
|
|
44
|
+
const selectedIdentityPath = identityPath ?? getDefaultPrivateKeyPath();
|
|
43
45
|
const config = await getConfig();
|
|
44
46
|
const orgId = config.orgId;
|
|
45
47
|
|
|
46
48
|
// A local key is not proof of registration. Let the selected environment
|
|
47
49
|
// check it, but only when an organization is available to bind the attempt.
|
|
48
|
-
if (orgId && hasKeyPair(
|
|
50
|
+
if (orgId && hasKeyPair(selectedIdentityPath)) {
|
|
49
51
|
console.log("Found CLI identity — authenticating with public key...");
|
|
50
52
|
try {
|
|
51
|
-
const success = await authenticateWithKey(orgId, KNOWHOW_API_URL,
|
|
53
|
+
const success = await authenticateWithKey(orgId, KNOWHOW_API_URL, selectedIdentityPath);
|
|
52
54
|
if (success) {
|
|
53
55
|
console.log("✅ Successfully authenticated via public key!");
|
|
54
|
-
return await postLoginConfigUpdate(
|
|
56
|
+
return await postLoginConfigUpdate(selectedIdentityPath);
|
|
55
57
|
}
|
|
56
58
|
console.warn("Key authentication returned no JWT, falling back to browser login...");
|
|
57
59
|
} catch (error: unknown) {
|
|
@@ -61,7 +63,8 @@ export async function login(jwtFlag?: boolean, identityPath?: string): Promise<v
|
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
// Fall back when no org/key is configured or when key auth is unavailable.
|
|
64
|
-
await doBrowserLogin(
|
|
66
|
+
await doBrowserLogin(selectedIdentityPath);
|
|
67
|
+
identityPath = selectedIdentityPath;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
await postLoginConfigUpdate(identityPath);
|
|
@@ -101,6 +104,15 @@ async function doBrowserLogin(identityPath?: string): Promise<void> {
|
|
|
101
104
|
}
|
|
102
105
|
}
|
|
103
106
|
|
|
107
|
+
function isTemporaryIdentityPath(identityPath: string): boolean {
|
|
108
|
+
const resolvedIdentity = path.resolve(identityPath);
|
|
109
|
+
const temporaryRoots = new Set([os.tmpdir(), "/tmp", "/var/tmp"]);
|
|
110
|
+
return Array.from(temporaryRoots).some((temporaryRoot) => {
|
|
111
|
+
const relativePath = path.relative(path.resolve(temporaryRoot), resolvedIdentity);
|
|
112
|
+
return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
104
116
|
/** After any successful login, update the local config with the current user/org. */
|
|
105
117
|
async function postLoginConfigUpdate(identityPath?: string): Promise<void> {
|
|
106
118
|
try {
|
|
@@ -129,8 +141,12 @@ async function postLoginConfigUpdate(identityPath?: string): Promise<void> {
|
|
|
129
141
|
config.orgId = orgId;
|
|
130
142
|
}
|
|
131
143
|
|
|
132
|
-
if (identityPath) {
|
|
144
|
+
if (identityPath && !isTemporaryIdentityPath(identityPath)) {
|
|
133
145
|
config.cliIdentityPath = path.resolve(identityPath);
|
|
146
|
+
} else if (
|
|
147
|
+
config.cliIdentityPath && isTemporaryIdentityPath(config.cliIdentityPath)
|
|
148
|
+
) {
|
|
149
|
+
delete config.cliIdentityPath;
|
|
134
150
|
}
|
|
135
151
|
|
|
136
152
|
await updateConfig(config);
|
|
@@ -316,25 +316,26 @@ export class Base64ImageProcessor {
|
|
|
316
316
|
|
|
317
317
|
createProcessor(): MessageProcessorFunction {
|
|
318
318
|
return (originalMessages: Message[], modifiedMessages: Message[]) => {
|
|
319
|
-
// Only process the last (newest) message for hint injection.
|
|
320
|
-
// Processing all historical messages on every call would re-append hints
|
|
321
|
-
// to already-processed messages, causing the hint to multiply and busting
|
|
322
|
-
// Anthropic's prefix cache (which requires byte-identical prior messages).
|
|
323
319
|
const lastIndex = modifiedMessages.length - 1;
|
|
324
320
|
if (lastIndex < 0) return;
|
|
325
321
|
|
|
326
322
|
const lastMessage = modifiedMessages[lastIndex];
|
|
327
323
|
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
324
|
+
// Every request starts with a fresh clone of the persisted thread. Normalize
|
|
325
|
+
// all images: parallel calls can place another tool result after an image,
|
|
326
|
+
// while historical image results remain persisted as JSON strings.
|
|
327
|
+
for (const message of modifiedMessages) {
|
|
328
|
+
if (message.role === "user") {
|
|
329
|
+
this.processMessageContent(message);
|
|
330
|
+
}
|
|
331
|
+
if (message.role === "tool") {
|
|
332
|
+
this.processToolMessageContent(message);
|
|
333
|
+
}
|
|
331
334
|
}
|
|
332
335
|
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
// and converted to proper image content before the agent sees them
|
|
336
|
+
// Add path hints only to the newest message. Re-appending hints to history
|
|
337
|
+
// would multiply them and invalidate the provider's prompt cache.
|
|
336
338
|
if (lastMessage.role === "tool") {
|
|
337
|
-
this.processToolMessageContent(lastMessage);
|
|
338
339
|
// After processing tool content (which may not convert to image if it's plain text
|
|
339
340
|
// describing a screenshot path), add hints for any image file paths found in the text.
|
|
340
341
|
this.applyImagePathHintsToMessage(lastMessage);
|
package/src/tunnel.ts
CHANGED
|
@@ -14,8 +14,9 @@ const PROD_TUNNEL_WORKER_DOMAIN = "worker.tyvm-apps.com";
|
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Resolve the tunnel domain and protocol from the environment and API URL.
|
|
17
|
-
* TUNNEL_WORKER_DOMAIN takes precedence. Otherwise
|
|
18
|
-
* tunnel
|
|
17
|
+
* TUNNEL_WORKER_DOMAIN takes precedence. Otherwise localhost API URLs use the
|
|
18
|
+
* local tunnel proxy, dev API URLs use the dev tunnel domain, and all other API
|
|
19
|
+
* URLs use the production tunnel domain.
|
|
19
20
|
*/
|
|
20
21
|
export function extractTunnelDomain(apiUrl: string): {
|
|
21
22
|
domain: string;
|
|
@@ -26,10 +27,19 @@ export function extractTunnelDomain(apiUrl: string): {
|
|
|
26
27
|
const useHttps = url.protocol === "https:";
|
|
27
28
|
const configuredDomain = process.env.TUNNEL_WORKER_DOMAIN?.trim();
|
|
28
29
|
|
|
30
|
+
const isLocalApi =
|
|
31
|
+
url.hostname === "localhost" ||
|
|
32
|
+
url.hostname.endsWith(".localhost") ||
|
|
33
|
+
url.hostname === "127.0.0.1" ||
|
|
34
|
+
url.hostname === "[::1]";
|
|
29
35
|
const isDevApi = url.hostname.split(".").includes("dev");
|
|
30
36
|
const domain =
|
|
31
37
|
configuredDomain ||
|
|
32
|
-
(
|
|
38
|
+
(isLocalApi
|
|
39
|
+
? `worker.localhost:${url.port || (useHttps ? "443" : "80")}`
|
|
40
|
+
: isDevApi
|
|
41
|
+
? `worker.dev.tyvm-apps.com`
|
|
42
|
+
: PROD_TUNNEL_WORKER_DOMAIN);
|
|
33
43
|
|
|
34
44
|
return { domain, useHttps };
|
|
35
45
|
} catch (err) {
|
|
@@ -220,6 +220,38 @@ describe("Base64ImageDetector", () => {
|
|
|
220
220
|
expect(content[0].image_url.url).toBe(imageDataUrl);
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
it("should process an image result that is not the newest parallel tool result", () => {
|
|
224
|
+
const imageDataUrl =
|
|
225
|
+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
|
|
226
|
+
const modifiedMessages: Message[] = [
|
|
227
|
+
{
|
|
228
|
+
role: "tool",
|
|
229
|
+
content: JSON.stringify({
|
|
230
|
+
type: "image_url",
|
|
231
|
+
image_url: { url: imageDataUrl, detail: "high" },
|
|
232
|
+
}),
|
|
233
|
+
tool_call_id: "call_image",
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
role: "tool",
|
|
237
|
+
content: "A later readFile result",
|
|
238
|
+
tool_call_id: "call_file",
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
role: "user",
|
|
242
|
+
content: "A pending workflow update",
|
|
243
|
+
},
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
detector.createProcessor()([], modifiedMessages);
|
|
247
|
+
|
|
248
|
+
expect(Array.isArray(modifiedMessages[0].content)).toBe(true);
|
|
249
|
+
expect((modifiedMessages[0].content as any[])[0]).toEqual({
|
|
250
|
+
type: "image_url",
|
|
251
|
+
image_url: { url: imageDataUrl, detail: "high" },
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
223
255
|
it("should not promote a compressed image URL placeholder to image content", () => {
|
|
224
256
|
const toolResponseJson = JSON.stringify({
|
|
225
257
|
type: "image_url",
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import {
|
|
5
|
+
loadImageAsBase64,
|
|
6
|
+
loadImageAsBase64Definition,
|
|
7
|
+
} from "../../../../src/agents/tools/loadImageAsBase64";
|
|
8
|
+
import { Base64ImageProcessor } from "../../../../src/processors/Base64ImageDetector";
|
|
9
|
+
import { ToolsService } from "../../../../src/services/Tools";
|
|
10
|
+
|
|
11
|
+
describe("loadImageAsBase64 tool", () => {
|
|
12
|
+
it("returns an image data URL with a MIME type detected from its bytes", async () => {
|
|
13
|
+
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "knowhow-image-"));
|
|
14
|
+
const filePath = path.join(directory, "incorrectly-named.jpg");
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
await fs.promises.writeFile(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
|
18
|
+
const result = JSON.parse(await loadImageAsBase64(filePath));
|
|
19
|
+
|
|
20
|
+
expect(result).toMatchObject({
|
|
21
|
+
type: "image_url",
|
|
22
|
+
image_url: { detail: "auto" },
|
|
23
|
+
});
|
|
24
|
+
expect(result.image_url.url).toMatch(/^data:image\/png;base64,/);
|
|
25
|
+
} finally {
|
|
26
|
+
await fs.promises.rm(directory, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("can also be registered by the image message processor without duplication", () => {
|
|
31
|
+
const toolsService = new ToolsService();
|
|
32
|
+
toolsService.defineTools(
|
|
33
|
+
[loadImageAsBase64Definition],
|
|
34
|
+
{ loadImageAsBase64 }
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
new Base64ImageProcessor(toolsService);
|
|
38
|
+
|
|
39
|
+
expect(
|
|
40
|
+
toolsService.getToolNames().filter((name) => name === "loadImageAsBase64")
|
|
41
|
+
).toHaveLength(1);
|
|
42
|
+
expect(typeof toolsService.getFunction("loadImageAsBase64")).toBe("function");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -24,12 +24,16 @@ jest.mock("../../../src/auth/browserLogin", () => ({
|
|
|
24
24
|
getCliUserAgent: () => "test-agent",
|
|
25
25
|
}));
|
|
26
26
|
|
|
27
|
+
import fs from "fs";
|
|
28
|
+
import os from "os";
|
|
29
|
+
import path from "path";
|
|
27
30
|
import {
|
|
28
31
|
authenticateWithKey,
|
|
29
32
|
exchangeAvailablePublicKeyJwt,
|
|
30
33
|
exchangePublicKeyJwt,
|
|
31
34
|
hasKeyPair,
|
|
32
35
|
registerPublicKey,
|
|
36
|
+
storeJwt,
|
|
33
37
|
} from "../../../src/auth/keyAuth";
|
|
34
38
|
|
|
35
39
|
describe("keyAuth", () => {
|
|
@@ -109,6 +113,23 @@ describe("keyAuth", () => {
|
|
|
109
113
|
expect(signMessage).not.toHaveBeenCalled();
|
|
110
114
|
});
|
|
111
115
|
|
|
116
|
+
it("stores a refreshed JWT in the active isolated project", () => {
|
|
117
|
+
const originalCwd = process.cwd();
|
|
118
|
+
const temporaryProject = fs.mkdtempSync(path.join(os.tmpdir(), "knowhow-key-auth-"));
|
|
119
|
+
try {
|
|
120
|
+
process.chdir(temporaryProject);
|
|
121
|
+
|
|
122
|
+
storeJwt("fresh-project-jwt");
|
|
123
|
+
|
|
124
|
+
const jwtPath = path.join(temporaryProject, ".knowhow", ".jwt");
|
|
125
|
+
expect(fs.readFileSync(jwtPath, "utf8")).toBe("fresh-project-jwt");
|
|
126
|
+
expect(fs.statSync(jwtPath).mode & 0o777).toBe(0o600);
|
|
127
|
+
} finally {
|
|
128
|
+
process.chdir(originalCwd);
|
|
129
|
+
fs.rmSync(temporaryProject, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
112
133
|
it("registers only server-owned Ed25519 key fields in the selected environment", async () => {
|
|
113
134
|
post.mockResolvedValueOnce({ data: {}, status: 201 });
|
|
114
135
|
|
package/tests/unit/login.test.ts
CHANGED
|
@@ -26,7 +26,10 @@ jest.mock("../../src/auth/keyAuth", () => ({
|
|
|
26
26
|
registerPublicKey,
|
|
27
27
|
}));
|
|
28
28
|
|
|
29
|
-
jest.mock("../../src/auth/keyManager", () => ({
|
|
29
|
+
jest.mock("../../src/auth/keyManager", () => ({
|
|
30
|
+
getDefaultPrivateKeyPath: () => "/keys/default",
|
|
31
|
+
getOrCreatePublicKey,
|
|
32
|
+
}));
|
|
30
33
|
jest.mock("../../src/config", () => ({ getConfig, updateConfig }));
|
|
31
34
|
jest.mock("../../src/utils", () => ({ ask: jest.fn() }));
|
|
32
35
|
|
|
@@ -88,6 +91,41 @@ describe("login public-key bootstrap", () => {
|
|
|
88
91
|
);
|
|
89
92
|
});
|
|
90
93
|
|
|
94
|
+
it("does not persist a temporary identity path into project configuration", async () => {
|
|
95
|
+
const temporaryIdentity = path.join(os.tmpdir(), "knowhow-login-test-key");
|
|
96
|
+
getConfig.mockResolvedValue({
|
|
97
|
+
orgId: "org-1",
|
|
98
|
+
cliIdentityPath: "/tmp/knowhow-csrf-e2e-key",
|
|
99
|
+
modelProviders: [],
|
|
100
|
+
});
|
|
101
|
+
registerPublicKey.mockResolvedValueOnce(undefined);
|
|
102
|
+
|
|
103
|
+
await expect(login(false, temporaryIdentity)).resolves.toBeUndefined();
|
|
104
|
+
|
|
105
|
+
expect(getOrCreatePublicKey).toHaveBeenCalledWith(temporaryIdentity);
|
|
106
|
+
expect(updateConfig).toHaveBeenCalledTimes(1);
|
|
107
|
+
expect(updateConfig.mock.calls[0][0]).toEqual(
|
|
108
|
+
expect.not.objectContaining({
|
|
109
|
+
cliIdentityPath: expect.any(String),
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("persists the default identity selected during browser bootstrap", async () => {
|
|
115
|
+
registerPublicKey.mockResolvedValueOnce(undefined);
|
|
116
|
+
|
|
117
|
+
await expect(login(false)).resolves.toBeUndefined();
|
|
118
|
+
|
|
119
|
+
expect(getOrCreatePublicKey).toHaveBeenCalledWith("/keys/default");
|
|
120
|
+
expect(updateConfig).toHaveBeenCalledWith(
|
|
121
|
+
expect.objectContaining({
|
|
122
|
+
orgId: "org-1",
|
|
123
|
+
cliIdentityPath: "/keys/default",
|
|
124
|
+
modelProviders: [{ provider: "knowhow" }],
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
91
129
|
it("safely falls back to browser PKCE when the selected key cannot authenticate", async () => {
|
|
92
130
|
hasKeyPair.mockReturnValue(true);
|
|
93
131
|
getConfig.mockResolvedValue({ orgId: "org-1", modelProviders: [] });
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
interface CapturedTransportOptions {
|
|
6
|
+
requestInit?: { headers?: Record<string, string> };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let capturedOptions: CapturedTransportOptions | undefined;
|
|
10
|
+
|
|
11
|
+
jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
|
12
|
+
StreamableHTTPClientTransport: jest.fn().mockImplementation(
|
|
13
|
+
(_url: URL, options: unknown) => {
|
|
14
|
+
capturedOptions = options as CapturedTransportOptions;
|
|
15
|
+
return { close: jest.fn() };
|
|
16
|
+
}
|
|
17
|
+
),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import { McpService } from "../../../src/services/Mcp";
|
|
21
|
+
|
|
22
|
+
describe("McpService JWT file transport", () => {
|
|
23
|
+
let temporaryDirectory: string;
|
|
24
|
+
let tokenFile: string;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
capturedOptions = undefined;
|
|
28
|
+
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "knowhow-mcp-"));
|
|
29
|
+
tokenFile = path.join(temporaryDirectory, ".jwt");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("reads the refreshed JWT when the transport is created", () => {
|
|
37
|
+
fs.writeFileSync(tokenFile, "expired-jwt");
|
|
38
|
+
fs.writeFileSync(tokenFile, "fresh-jwt");
|
|
39
|
+
|
|
40
|
+
new McpService().createTransport({
|
|
41
|
+
name: "authenticated-remote",
|
|
42
|
+
url: "https://api.example.test/mcp",
|
|
43
|
+
authorization_token_file: tokenFile,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(capturedOptions).toEqual(
|
|
47
|
+
expect.objectContaining({
|
|
48
|
+
requestInit: {
|
|
49
|
+
headers: expect.objectContaining({
|
|
50
|
+
Authorization: "Bearer fresh-jwt",
|
|
51
|
+
}),
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { extractTunnelDomain } from "../../src/tunnel";
|
|
2
|
+
|
|
3
|
+
describe("extractTunnelDomain", () => {
|
|
4
|
+
const originalTunnelWorkerDomain = process.env.TUNNEL_WORKER_DOMAIN;
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
delete process.env.TUNNEL_WORKER_DOMAIN;
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterAll(() => {
|
|
11
|
+
if (originalTunnelWorkerDomain === undefined) {
|
|
12
|
+
delete process.env.TUNNEL_WORKER_DOMAIN;
|
|
13
|
+
} else {
|
|
14
|
+
process.env.TUNNEL_WORKER_DOMAIN = originalTunnelWorkerDomain;
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("uses the local tunnel proxy for a localhost API URL", () => {
|
|
19
|
+
expect(extractTunnelDomain("http://localhost:4000")).toEqual({
|
|
20
|
+
domain: "worker.localhost:4000",
|
|
21
|
+
useHttps: false,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("uses the local tunnel proxy for loopback API URLs", () => {
|
|
26
|
+
expect(extractTunnelDomain("http://127.0.0.1:4000")).toEqual({
|
|
27
|
+
domain: "worker.localhost:4000",
|
|
28
|
+
useHttps: false,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("continues to select dev and production tunnel domains", () => {
|
|
33
|
+
expect(extractTunnelDomain("https://api.dev.knowhow.tyvm.ai")).toEqual({
|
|
34
|
+
domain: "worker.dev.tyvm-apps.com",
|
|
35
|
+
useHttps: true,
|
|
36
|
+
});
|
|
37
|
+
expect(extractTunnelDomain("https://api.knowhow.tyvm.ai")).toEqual({
|
|
38
|
+
domain: "worker.tyvm-apps.com",
|
|
39
|
+
useHttps: true,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("allows an explicit tunnel domain to override localhost detection", () => {
|
|
44
|
+
process.env.TUNNEL_WORKER_DOMAIN = "worker.override.example";
|
|
45
|
+
|
|
46
|
+
expect(extractTunnelDomain("http://localhost:4000")).toEqual({
|
|
47
|
+
domain: "worker.override.example",
|
|
48
|
+
useHttps: false,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
package/ts_build/package.json
CHANGED
|
@@ -29,6 +29,7 @@ __exportStar(require("./readFile"), exports);
|
|
|
29
29
|
__exportStar(require("./scanFile"), exports);
|
|
30
30
|
__exportStar(require("./textSearch"), exports);
|
|
31
31
|
__exportStar(require("./visionTool"), exports);
|
|
32
|
+
__exportStar(require("./loadImageAsBase64"), exports);
|
|
32
33
|
__exportStar(require("./writeFile"), exports);
|
|
33
34
|
__exportStar(require("./fileSearch"), exports);
|
|
34
35
|
__exportStar(require("./language"), exports);
|