@velum-labs/routekit-tool-cursor 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/dist/driver.js +5 -1
- package/dist/index.d.ts +1 -6
- package/dist/index.js +10 -12
- package/dist/launch.d.ts +10 -2
- package/dist/launch.js +16 -104
- package/dist/test/instructions.test.js +39 -1
- package/package.json +7 -8
- package/dist/acp.d.ts +0 -20
- package/dist/acp.js +0 -178
- package/dist/bridge-config.d.ts +0 -40
- package/dist/bridge-config.js +0 -95
- package/dist/bridge.d.ts +0 -18
- package/dist/bridge.js +0 -37
- package/dist/cursorkit-path.d.ts +0 -5
- package/dist/cursorkit-path.js +0 -10
- package/dist/test/bridge-config.test.d.ts +0 -1
- package/dist/test/bridge-config.test.js +0 -57
- package/dist/test/ide-launch.test.d.ts +0 -1
- package/dist/test/ide-launch.test.js +0 -218
package/README.md
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# @velum-labs/routekit-tool-cursor
|
|
2
2
|
|
|
3
|
-
Product-neutral Cursor
|
|
3
|
+
Product-neutral Cursor custom-endpoint setup and canonical ACP driver.
|
|
4
4
|
|
|
5
5
|
## Architecture
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Cursor is supported through its own bring-your-own-key setting: Cursor Settings
|
|
8
|
+
-> Models -> Override OpenAI Base URL, pointed at the gateway's `/v1/cursor`
|
|
9
|
+
door. RouteKit does not proxy or emulate Cursor's backend protocol, so
|
|
10
|
+
`cursor-agent` model calls stay on the logged-in Cursor account.
|
|
8
11
|
|
|
9
12
|
## Usage
|
|
10
13
|
|
package/dist/driver.js
CHANGED
|
@@ -8,7 +8,11 @@ const DEFAULT_COMMAND = "cursor-agent";
|
|
|
8
8
|
const AUTH_METHOD_ID = "cursor_login";
|
|
9
9
|
export const cursorDriverConfigSchema = z.object({
|
|
10
10
|
command: z.string().default(DEFAULT_COMMAND),
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Caller-supplied `cursor-agent --endpoint`. This is a Cursor backend URL,
|
|
13
|
+
* not an OpenAI base URL, so the RouteKit gateway cannot serve it; model
|
|
14
|
+
* calls otherwise use the logged-in Cursor account.
|
|
15
|
+
*/
|
|
12
16
|
endpoint: z.string().optional(),
|
|
13
17
|
model: z.string().optional()
|
|
14
18
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import type { ToolIntegration } from "@velum-labs/routekit-tools";
|
|
2
2
|
export declare const cursorTool: ToolIntegration;
|
|
3
|
-
export {
|
|
4
|
-
export { startCursorBridge } from "./bridge.js";
|
|
5
|
-
export { CURSOR_AGENT_TOOL_MAX_ITERATIONS, CURSOR_AGENT_TOOL_POLICY, cursorBridgeEnv, cursorBridgeModelEnv, cursorIdeEnv, cursorIdeModelsJson } from "./bridge-config.js";
|
|
6
|
-
export { resolveCursorkitCli } from "./cursorkit-path.js";
|
|
7
|
-
export type { CursorkitCli } from "./cursorkit-path.js";
|
|
8
|
-
export { cursorIdeInstructions, cursorInstructions, launchCursor } from "./launch.js";
|
|
3
|
+
export { cursorByokBaseUrl, cursorInstructions, launchCursor } from "./launch.js";
|
|
9
4
|
export { CURSOR_AGENTS_DIRNAME, cursorSubagentMarkdown, scaffoldCursorSubagents } from "./subagents.js";
|
|
10
5
|
export { createCursorDriver, cursorDriverConfigSchema } from "./driver.js";
|
|
11
6
|
export type { CursorDriverConfig } from "./driver.js";
|
package/dist/index.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
|
+
import { cursorModelName } from "@velum-labs/routekit-contracts";
|
|
1
2
|
import { createCursorDriver, cursorDriverConfigSchema } from "./driver.js";
|
|
2
|
-
import { launchCursor } from "./launch.js";
|
|
3
|
+
import { cursorByokBaseUrl, launchCursor } from "./launch.js";
|
|
3
4
|
const driver = createCursorDriver();
|
|
4
5
|
export const cursorTool = {
|
|
5
6
|
id: "cursor",
|
|
6
7
|
displayName: "Cursor",
|
|
7
|
-
pickerHint: "Cursor
|
|
8
|
-
binary: "cursor-agent",
|
|
8
|
+
pickerHint: "Cursor editor via a custom OpenAI endpoint",
|
|
9
9
|
packageName: "@velum-labs/routekit-tool-cursor",
|
|
10
|
-
installHint: "install
|
|
11
|
-
authSummary: "Cursor uses
|
|
12
|
-
setupSnippet: ({ gatewayUrl, model = "gateway-model"
|
|
10
|
+
installHint: "install Cursor: https://cursor.com",
|
|
11
|
+
authSummary: "Cursor uses its own login plus the gateway's OpenAI-compatible /v1/cursor endpoint.",
|
|
12
|
+
setupSnippet: ({ gatewayUrl, model = "gateway-model" }) => `Cursor Settings -> Models -> Override OpenAI Base URL: ${cursorByokBaseUrl(gatewayUrl)} (model name: ${cursorModelName(model)})`,
|
|
13
13
|
launch: launchCursor,
|
|
14
14
|
driver: {
|
|
15
15
|
kind: driver.kind,
|
|
16
16
|
driver,
|
|
17
|
-
|
|
17
|
+
// cursor-agent talks to Cursor's own backend, not the gateway, so a
|
|
18
|
+
// RouteKit route contributes no endpoint here.
|
|
19
|
+
configForRoute: (route) => cursorDriverConfigSchema.parse({ model: route.model })
|
|
18
20
|
},
|
|
19
21
|
capabilities: {
|
|
20
22
|
streaming: "full",
|
|
@@ -23,10 +25,6 @@ export const cursorTool = {
|
|
|
23
25
|
reasoning_controls: "degraded"
|
|
24
26
|
}
|
|
25
27
|
};
|
|
26
|
-
export {
|
|
27
|
-
export { startCursorBridge } from "./bridge.js";
|
|
28
|
-
export { CURSOR_AGENT_TOOL_MAX_ITERATIONS, CURSOR_AGENT_TOOL_POLICY, cursorBridgeEnv, cursorBridgeModelEnv, cursorIdeEnv, cursorIdeModelsJson } from "./bridge-config.js";
|
|
29
|
-
export { resolveCursorkitCli } from "./cursorkit-path.js";
|
|
30
|
-
export { cursorIdeInstructions, cursorInstructions, launchCursor } from "./launch.js";
|
|
28
|
+
export { cursorByokBaseUrl, cursorInstructions, launchCursor } from "./launch.js";
|
|
31
29
|
export { CURSOR_AGENTS_DIRNAME, cursorSubagentMarkdown, scaffoldCursorSubagents } from "./subagents.js";
|
|
32
30
|
export { createCursorDriver, cursorDriverConfigSchema } from "./driver.js";
|
package/dist/launch.d.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import type { ToolLaunchContext } from "@velum-labs/routekit-tools";
|
|
2
|
-
|
|
3
|
-
export declare function
|
|
2
|
+
/** Base URL for Cursor's "Override OpenAI Base URL" setting. */
|
|
3
|
+
export declare function cursorByokBaseUrl(gatewayUrl: string): string;
|
|
4
|
+
export declare function cursorInstructions(gatewayUrl: string, model: string, apiKey?: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Print the endpoint Cursor must be pointed at, then return.
|
|
7
|
+
*
|
|
8
|
+
* Unlike the other tools RouteKit launches, there is no child process to
|
|
9
|
+
* supervise: Cursor is configured once in its own settings and connects from
|
|
10
|
+
* its own process against a gateway this command does not own.
|
|
11
|
+
*/
|
|
4
12
|
export declare function launchCursor(ctx: ToolLaunchContext): Promise<number>;
|
package/dist/launch.js
CHANGED
|
@@ -1,124 +1,36 @@
|
|
|
1
|
-
import { mkdirSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
1
|
import { cursorModelName } from "@velum-labs/routekit-contracts";
|
|
4
|
-
import {
|
|
5
|
-
import { cursorIdeEnv } from "./bridge-config.js";
|
|
6
|
-
import { startCursorBridge } from "./bridge.js";
|
|
7
|
-
import { resolveCursorkitCli } from "./cursorkit-path.js";
|
|
2
|
+
import { normalizeApiBaseUrl } from "@velum-labs/routekit-runtime";
|
|
8
3
|
import { scaffoldCursorSubagents } from "./subagents.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
id: model.id,
|
|
13
|
-
...(model.label !== undefined ? { displayName: model.label } : {}),
|
|
14
|
-
...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {})
|
|
15
|
-
},
|
|
16
|
-
...(model.aliases ?? []).map((alias) => ({
|
|
17
|
-
id: alias,
|
|
18
|
-
...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {})
|
|
19
|
-
}))
|
|
20
|
-
]);
|
|
4
|
+
/** Base URL for Cursor's "Override OpenAI Base URL" setting. */
|
|
5
|
+
export function cursorByokBaseUrl(gatewayUrl) {
|
|
6
|
+
return `${normalizeApiBaseUrl(gatewayUrl)}/cursor`;
|
|
21
7
|
}
|
|
22
|
-
export function
|
|
23
|
-
return `Cursor IDE is connected to the gateway. Choose "${model}" in the Agent model picker.`;
|
|
24
|
-
}
|
|
25
|
-
export function cursorInstructions(publicUrl, model, apiKey) {
|
|
8
|
+
export function cursorInstructions(gatewayUrl, model, apiKey) {
|
|
26
9
|
// Cursor routes BYOK by model-name prefix (`claude-*` → Anthropic,
|
|
27
10
|
// `gemini-*` → Google). The gateway's /v1/cursor mirror namespaces every
|
|
28
11
|
// id under `routekit/` so the pasted name always uses the OpenAI key +
|
|
29
12
|
// base-URL override.
|
|
30
13
|
return [
|
|
31
14
|
"In Cursor Settings -> Models, enable Override OpenAI Base URL and set:",
|
|
32
|
-
` Override OpenAI Base URL : ${
|
|
15
|
+
` Override OpenAI Base URL : ${cursorByokBaseUrl(gatewayUrl)}`,
|
|
33
16
|
` Model name : ${cursorModelName(model)}`,
|
|
34
17
|
` OpenAI API Key : ${apiKey ?? "routekit-local"}`,
|
|
35
18
|
"Names are namespaced under routekit/ so Cursor does not route them to Anthropic/Google keys."
|
|
36
19
|
].join("\n");
|
|
37
20
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
: undefined
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
async function launchCursorCli(ctx) {
|
|
49
|
-
const started = await startCursorBridge({
|
|
50
|
-
gatewayUrl: ctx.spec.gatewayUrl,
|
|
51
|
-
modelLabel: ctx.spec.defaultModel,
|
|
52
|
-
models: bridgeModels(ctx),
|
|
53
|
-
...(ctx.spec.auth?.token !== undefined
|
|
54
|
-
? { apiKey: ctx.spec.auth.token }
|
|
55
|
-
: {}),
|
|
56
|
-
...(ctx.spec.logsDir !== undefined
|
|
57
|
-
? { logFile: join(ctx.spec.logsDir, "cursor-bridge.log") }
|
|
58
|
-
: {}),
|
|
59
|
-
...(ctx.spec.tls?.caCertPath !== undefined
|
|
60
|
-
? { caCertPath: ctx.spec.tls.caCertPath }
|
|
61
|
-
: {}),
|
|
62
|
-
log: ctx.log
|
|
63
|
-
});
|
|
64
|
-
const bridgeUrl = ctx.registerPort("cursor", started.port);
|
|
65
|
-
ctx.registerDisposer(() => {
|
|
66
|
-
ctx.unregisterPort("cursor");
|
|
67
|
-
terminate(started.child);
|
|
68
|
-
});
|
|
69
|
-
ctx.prepareForPassthrough();
|
|
70
|
-
return await spawnTool("cursor-agent", ["--endpoint", bridgeUrl, "--model", ctx.spec.defaultModel, ...ctx.spec.args], cursorCliAuthEnv(), ctx.spec.cwd);
|
|
71
|
-
}
|
|
72
|
-
async function launchCursorRemote(ctx) {
|
|
73
|
-
const publicUrl = ctx.spec.publicUrl;
|
|
74
|
-
if (publicUrl === undefined) {
|
|
75
|
-
throw new Error("Cursor remote configuration requires a public gateway URL");
|
|
76
|
-
}
|
|
77
|
-
ctx.log(cursorInstructions(publicUrl, ctx.spec.defaultModel, ctx.spec.auth?.token));
|
|
78
|
-
await new Promise(() => { });
|
|
79
|
-
return 0;
|
|
80
|
-
}
|
|
81
|
-
async function launchCursorIde(ctx) {
|
|
82
|
-
const { serveCli } = resolveCursorkitCli();
|
|
83
|
-
const repo = ctx.spec.cwd ?? process.cwd();
|
|
84
|
-
const stateDir = ctx.spec.logsDir !== undefined
|
|
85
|
-
? join(ctx.spec.logsDir, "cursor-ide")
|
|
86
|
-
: join(repo, ".cursor-rpc-ide");
|
|
87
|
-
mkdirSync(stateDir, { recursive: true });
|
|
88
|
-
const proc = spawnLogged(process.execPath, [serveCli, "ck"], {
|
|
89
|
-
cwd: stateDir,
|
|
90
|
-
env: cursorIdeEnv({
|
|
91
|
-
repo,
|
|
92
|
-
gatewayUrl: ctx.spec.gatewayUrl,
|
|
93
|
-
modelLabel: ctx.spec.defaultModel,
|
|
94
|
-
models: bridgeModels(ctx),
|
|
95
|
-
...(ctx.spec.auth?.token !== undefined ? { apiKey: ctx.spec.auth.token } : {}),
|
|
96
|
-
...(ctx.spec.tls?.caCertPath !== undefined
|
|
97
|
-
? { caCertPath: ctx.spec.tls.caCertPath }
|
|
98
|
-
: {})
|
|
99
|
-
}),
|
|
100
|
-
...(ctx.spec.logsDir !== undefined
|
|
101
|
-
? { logFile: join(ctx.spec.logsDir, "cursor-ide.log") }
|
|
102
|
-
: {})
|
|
103
|
-
});
|
|
104
|
-
await waitForOutput(proc, /ck ready|bridge listening/, {
|
|
105
|
-
timeoutMs: 60_000,
|
|
106
|
-
label: "Cursor desktop bridge"
|
|
107
|
-
});
|
|
108
|
-
ctx.registerDisposer(() => terminate(proc.child));
|
|
109
|
-
ctx.log(cursorIdeInstructions(ctx.spec.defaultModel));
|
|
110
|
-
return await new Promise((resolve) => {
|
|
111
|
-
proc.child.once("exit", (code) => resolve(code ?? 0));
|
|
112
|
-
});
|
|
113
|
-
}
|
|
21
|
+
/**
|
|
22
|
+
* Print the endpoint Cursor must be pointed at, then return.
|
|
23
|
+
*
|
|
24
|
+
* Unlike the other tools RouteKit launches, there is no child process to
|
|
25
|
+
* supervise: Cursor is configured once in its own settings and connects from
|
|
26
|
+
* its own process against a gateway this command does not own.
|
|
27
|
+
*/
|
|
114
28
|
export async function launchCursor(ctx) {
|
|
115
29
|
const profiles = ctx.spec.agentProfiles ?? [];
|
|
116
30
|
if (profiles.length > 0) {
|
|
117
31
|
scaffoldCursorSubagents(ctx.spec.cwd ?? process.cwd(), profiles, ctx.log);
|
|
118
32
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return launchCursorRemote(ctx);
|
|
123
|
-
return launchCursorCli(ctx);
|
|
33
|
+
ctx.log(cursorInstructions(ctx.spec.publicUrl ?? ctx.spec.gatewayUrl, ctx.spec.defaultModel, ctx.spec.auth?.token));
|
|
34
|
+
ctx.log("The gateway keeps serving this endpoint; Cursor connects on its own.");
|
|
35
|
+
return 0;
|
|
124
36
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import { cursorModelName } from "@velum-labs/routekit-contracts";
|
|
4
|
-
import { cursorInstructions } from "../launch.js";
|
|
4
|
+
import { cursorByokBaseUrl, cursorInstructions, launchCursor } from "../launch.js";
|
|
5
5
|
test("cursorInstructions print the gateway's routekit/-namespaced spelling", () => {
|
|
6
6
|
const model = "claude-code/claude-fable-5";
|
|
7
7
|
const text = cursorInstructions("https://example.ts.net", model, "token");
|
|
@@ -10,3 +10,41 @@ test("cursorInstructions print the gateway's routekit/-namespaced spelling", ()
|
|
|
10
10
|
assert.doesNotMatch(text, /claude-code-claude-fable-5/);
|
|
11
11
|
assert.match(text, /namespaced under routekit\//);
|
|
12
12
|
});
|
|
13
|
+
test("cursorByokBaseUrl normalizes the gateway origin onto the /v1/cursor door", () => {
|
|
14
|
+
assert.equal(cursorByokBaseUrl("http://127.0.0.1:8080"), "http://127.0.0.1:8080/v1/cursor");
|
|
15
|
+
assert.equal(cursorByokBaseUrl("http://127.0.0.1:8080/"), "http://127.0.0.1:8080/v1/cursor");
|
|
16
|
+
assert.equal(cursorByokBaseUrl("https://gateway.test/v1"), "https://gateway.test/v1/cursor");
|
|
17
|
+
});
|
|
18
|
+
function launchContext(spec, log) {
|
|
19
|
+
return {
|
|
20
|
+
spec: {
|
|
21
|
+
gatewayUrl: "http://127.0.0.1:8080",
|
|
22
|
+
defaultModel: "openai/gpt-5.5",
|
|
23
|
+
models: [],
|
|
24
|
+
args: [],
|
|
25
|
+
...spec
|
|
26
|
+
},
|
|
27
|
+
log,
|
|
28
|
+
prepareForPassthrough: () => {
|
|
29
|
+
throw new Error("cursor must not take over the terminal for a spawned tool");
|
|
30
|
+
},
|
|
31
|
+
registerPort: () => {
|
|
32
|
+
throw new Error("cursor must not register a local bridge port");
|
|
33
|
+
},
|
|
34
|
+
unregisterPort: () => { },
|
|
35
|
+
registerDisposer: () => { }
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
test("launchCursor prints BYOK setup for the local gateway without spawning a bridge", async () => {
|
|
39
|
+
const lines = [];
|
|
40
|
+
assert.equal(await launchCursor(launchContext({}, (line) => lines.push(line))), 0);
|
|
41
|
+
assert.match(lines[0], /Override OpenAI Base URL : http:\/\/127\.0\.0\.1:8080\/v1\/cursor/);
|
|
42
|
+
assert.match(lines[0], new RegExp(`Model name\\s+: ${cursorModelName("openai/gpt-5.5")}`));
|
|
43
|
+
assert.match(lines[0], /OpenAI API Key\s+: routekit-local/);
|
|
44
|
+
});
|
|
45
|
+
test("launchCursor prefers a public gateway URL and the gateway token", async () => {
|
|
46
|
+
const lines = [];
|
|
47
|
+
await launchCursor(launchContext({ publicUrl: "https://gateway.ts.net", auth: { token: "gateway-token" } }, (line) => lines.push(line)));
|
|
48
|
+
assert.match(lines[0], /Override OpenAI Base URL : https:\/\/gateway\.ts\.net\/v1\/cursor/);
|
|
49
|
+
assert.match(lines[0], /OpenAI API Key\s+: gateway-token/);
|
|
50
|
+
});
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velum-labs/routekit-tool-cursor",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.11.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
|
-
"url": "git+https://github.com/velum-labs/
|
|
7
|
+
"url": "git+https://github.com/velum-labs/routekit.git",
|
|
8
8
|
"directory": "packages/tool-cursor"
|
|
9
9
|
},
|
|
10
|
-
"description": "Product-neutral Cursor
|
|
10
|
+
"description": "Product-neutral Cursor custom-endpoint setup and canonical ACP driver.",
|
|
11
11
|
"license": "Apache-2.0",
|
|
12
12
|
"type": "module",
|
|
13
13
|
"exports": {
|
|
@@ -26,13 +26,12 @@
|
|
|
26
26
|
"provenance": true
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@velum-labs/cursorkit": "0.2.0",
|
|
30
29
|
"@zed-industries/agent-client-protocol": "0.4.5",
|
|
31
30
|
"zod": "4.4.3",
|
|
32
|
-
"@velum-labs/routekit-
|
|
33
|
-
"@velum-labs/routekit-
|
|
34
|
-
"@velum-labs/routekit-
|
|
35
|
-
"@velum-labs/routekit-
|
|
31
|
+
"@velum-labs/routekit-harness-core": "0.11.0",
|
|
32
|
+
"@velum-labs/routekit-contracts": "0.11.0",
|
|
33
|
+
"@velum-labs/routekit-runtime": "0.11.0",
|
|
34
|
+
"@velum-labs/routekit-tools": "0.11.0"
|
|
36
35
|
},
|
|
37
36
|
"keywords": [
|
|
38
37
|
"llm",
|
package/dist/acp.d.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
type CursorFrontDoorOutcome = {
|
|
2
|
-
id: string;
|
|
3
|
-
status: "passed" | "failed";
|
|
4
|
-
reason?: string;
|
|
5
|
-
request_path?: string;
|
|
6
|
-
evidence: string[];
|
|
7
|
-
};
|
|
8
|
-
type CursorFrontDoorOutcomeProducer = () => Promise<CursorFrontDoorOutcome>;
|
|
9
|
-
export type CursorAcpProducerInput = {
|
|
10
|
-
gatewayUrl: string;
|
|
11
|
-
sentinel: string;
|
|
12
|
-
repo: string;
|
|
13
|
-
command?: string;
|
|
14
|
-
modelName?: string;
|
|
15
|
-
providerModel?: string;
|
|
16
|
-
timeoutMs?: number;
|
|
17
|
-
enabled?: boolean;
|
|
18
|
-
};
|
|
19
|
-
export declare function buildCursorAcpProducer(input: CursorAcpProducerInput): CursorFrontDoorOutcomeProducer | undefined;
|
|
20
|
-
export {};
|
package/dist/acp.js
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { createInterface } from "node:readline";
|
|
3
|
-
import { commandOnPath, reservePort, terminate } from "@velum-labs/routekit-runtime";
|
|
4
|
-
import { cursorBridgeEnv } from "./bridge-config.js";
|
|
5
|
-
import { resolveCursorkitCli } from "./cursorkit-path.js";
|
|
6
|
-
export function buildCursorAcpProducer(input) {
|
|
7
|
-
const command = input.command ?? "cursor-agent";
|
|
8
|
-
// The live Cursor ACP probe drives the real cursor-agent CLI through the
|
|
9
|
-
// bundled Cursorkit bridge, so it stays opt-in: without the live flag the
|
|
10
|
-
// acceptance suite reports this door as `blocked` rather than spawning live
|
|
11
|
-
// tooling (keeping deterministic runs free of credential/CLI dependencies).
|
|
12
|
-
if (input.enabled === false) {
|
|
13
|
-
return undefined;
|
|
14
|
-
}
|
|
15
|
-
if (!commandOnPath(command)) {
|
|
16
|
-
return undefined;
|
|
17
|
-
}
|
|
18
|
-
return () => runCursorAcpOutcome({ ...input, command });
|
|
19
|
-
}
|
|
20
|
-
async function runCursorAcpOutcome(input) {
|
|
21
|
-
const modelName = input.modelName ?? "routekit";
|
|
22
|
-
// Hold a real free loopback port until the bridge is about to bind it, so
|
|
23
|
-
// parallel probes cannot collide on (or steal) it.
|
|
24
|
-
const reservation = await reservePort();
|
|
25
|
-
const bridgePort = reservation.port;
|
|
26
|
-
const bridgeEnv = cursorBridgeEnv({
|
|
27
|
-
port: bridgePort,
|
|
28
|
-
gatewayUrl: input.gatewayUrl,
|
|
29
|
-
modelName,
|
|
30
|
-
providerModel: input.providerModel ?? modelName
|
|
31
|
-
});
|
|
32
|
-
const { serveCli } = resolveCursorkitCli();
|
|
33
|
-
let bridgeOut = "";
|
|
34
|
-
await reservation.release();
|
|
35
|
-
// detached: the bridge may spawn children; teardown kills the whole group.
|
|
36
|
-
const bridge = spawn(process.execPath, [serveCli, "serve"], {
|
|
37
|
-
env: bridgeEnv,
|
|
38
|
-
detached: true,
|
|
39
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
40
|
-
});
|
|
41
|
-
bridge.stdout.on("data", (chunk) => {
|
|
42
|
-
bridgeOut += chunk.toString("utf8");
|
|
43
|
-
});
|
|
44
|
-
bridge.stderr.on("data", (chunk) => {
|
|
45
|
-
bridgeOut += chunk.toString("utf8");
|
|
46
|
-
});
|
|
47
|
-
const evidence = [];
|
|
48
|
-
try {
|
|
49
|
-
const deadline = Date.now() + 20_000;
|
|
50
|
-
while (!/bridge listening/.test(bridgeOut) && Date.now() < deadline) {
|
|
51
|
-
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
52
|
-
}
|
|
53
|
-
if (!/bridge listening/.test(bridgeOut)) {
|
|
54
|
-
return {
|
|
55
|
-
id: "cursor-acp",
|
|
56
|
-
status: "failed",
|
|
57
|
-
reason: "cursorkit_bridge_did_not_start",
|
|
58
|
-
evidence
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
const acpText = await driveCursorAgentSentinel({
|
|
62
|
-
command: input.command,
|
|
63
|
-
bridgePort,
|
|
64
|
-
modelName,
|
|
65
|
-
cwd: input.repo,
|
|
66
|
-
sentinel: input.sentinel,
|
|
67
|
-
timeoutMs: input.timeoutMs ?? 120_000
|
|
68
|
-
});
|
|
69
|
-
if (acpText.includes(input.sentinel)) {
|
|
70
|
-
evidence.push(input.sentinel);
|
|
71
|
-
return {
|
|
72
|
-
id: "cursor-acp",
|
|
73
|
-
status: "passed",
|
|
74
|
-
request_path: "/agent.v1.AgentService/Run",
|
|
75
|
-
evidence
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
return {
|
|
79
|
-
id: "cursor-acp",
|
|
80
|
-
status: "failed",
|
|
81
|
-
reason: "sentinel_not_observed_in_cursor_session_update",
|
|
82
|
-
evidence
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
catch (error) {
|
|
86
|
-
return {
|
|
87
|
-
id: "cursor-acp",
|
|
88
|
-
status: "failed",
|
|
89
|
-
reason: error instanceof Error ? error.message : String(error),
|
|
90
|
-
evidence
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
finally {
|
|
94
|
-
// Process-group SIGTERM with SIGKILL escalation, not a bare child kill.
|
|
95
|
-
terminate(bridge);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
async function driveCursorAgentSentinel(input) {
|
|
99
|
-
const acp = spawn(input.command, [
|
|
100
|
-
"--endpoint",
|
|
101
|
-
`http://127.0.0.1:${input.bridgePort}`,
|
|
102
|
-
"--model",
|
|
103
|
-
input.modelName,
|
|
104
|
-
"--mode",
|
|
105
|
-
"ask",
|
|
106
|
-
"acp"
|
|
107
|
-
], { cwd: input.cwd, detached: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
108
|
-
let acpText = "";
|
|
109
|
-
let nextId = 1;
|
|
110
|
-
const pending = new Map();
|
|
111
|
-
const rl = createInterface({ input: acp.stdout });
|
|
112
|
-
const send = (method, params) => {
|
|
113
|
-
const id = nextId++;
|
|
114
|
-
acp.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
|
|
115
|
-
return new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
|
|
116
|
-
};
|
|
117
|
-
rl.on("line", (line) => {
|
|
118
|
-
let message;
|
|
119
|
-
try {
|
|
120
|
-
message = JSON.parse(line);
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
if (message.id !== undefined && message.method === undefined) {
|
|
126
|
-
const waiter = pending.get(Number(message.id));
|
|
127
|
-
if (waiter === undefined)
|
|
128
|
-
return;
|
|
129
|
-
pending.delete(Number(message.id));
|
|
130
|
-
if (message.error !== undefined)
|
|
131
|
-
waiter.reject(message.error);
|
|
132
|
-
else
|
|
133
|
-
waiter.resolve(message.result);
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
if (message.method !== undefined) {
|
|
137
|
-
if (message.method === "session/update")
|
|
138
|
-
acpText += JSON.stringify(message.params);
|
|
139
|
-
if (message.id !== undefined) {
|
|
140
|
-
acp.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { outcome: { outcome: "skipped", reason: "acceptance" } } })}\n`);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
});
|
|
144
|
-
const withTimeout = (promise, ms) => Promise.race([
|
|
145
|
-
promise,
|
|
146
|
-
new Promise((_resolve, reject) => setTimeout(() => reject(new Error("ACP step timed out")), ms))
|
|
147
|
-
]);
|
|
148
|
-
try {
|
|
149
|
-
await withTimeout(send("initialize", {
|
|
150
|
-
protocolVersion: 1,
|
|
151
|
-
clientCapabilities: {
|
|
152
|
-
fs: { readTextFile: false, writeTextFile: false },
|
|
153
|
-
terminal: false
|
|
154
|
-
},
|
|
155
|
-
clientInfo: { name: "routekit-acp", version: "0.1.0" }
|
|
156
|
-
}), 60_000);
|
|
157
|
-
await withTimeout(send("authenticate", { methodId: "cursor_login" }), 60_000);
|
|
158
|
-
const session = (await withTimeout(send("session/new", { cwd: input.cwd, mcpServers: [] }), 60_000));
|
|
159
|
-
const sessionId = session.sessionId ?? session.session?.id;
|
|
160
|
-
if (sessionId === undefined)
|
|
161
|
-
return acpText;
|
|
162
|
-
await withTimeout(send("session/prompt", {
|
|
163
|
-
sessionId,
|
|
164
|
-
prompt: [
|
|
165
|
-
{
|
|
166
|
-
type: "text",
|
|
167
|
-
text: `Reply with exactly this token and nothing else: ${input.sentinel}`
|
|
168
|
-
}
|
|
169
|
-
]
|
|
170
|
-
}), input.timeoutMs);
|
|
171
|
-
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
172
|
-
return acpText;
|
|
173
|
-
}
|
|
174
|
-
finally {
|
|
175
|
-
rl.close();
|
|
176
|
-
terminate(acp);
|
|
177
|
-
}
|
|
178
|
-
}
|
package/dist/bridge-config.d.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import type { ModelReasoningCapabilities } from "@velum-labs/routekit-contracts";
|
|
2
|
-
export declare const CURSOR_AGENT_TOOL_POLICY = "all";
|
|
3
|
-
export declare const CURSOR_AGENT_TOOL_MAX_ITERATIONS = 24;
|
|
4
|
-
export declare const CURSOR_BRIDGE_SCRUB_PREFIXES: readonly ["BRIDGE_", "MODEL_", "E2E_", "CURSOR_UPSTREAM"];
|
|
5
|
-
export declare const CURSOR_IDE_SCRUB_PREFIXES: readonly ["BRIDGE_", "MODEL_", "E2E_", "CURSOR_UPSTREAM", "CK_"];
|
|
6
|
-
export type CursorBridgeModelEnvInput = {
|
|
7
|
-
gatewayUrl: string;
|
|
8
|
-
modelName: string;
|
|
9
|
-
providerModel?: string;
|
|
10
|
-
apiKey?: string;
|
|
11
|
-
upstreamBaseUrl?: string;
|
|
12
|
-
contextTokenLimit?: number;
|
|
13
|
-
};
|
|
14
|
-
export type CursorBridgeEnvInput = CursorBridgeModelEnvInput & {
|
|
15
|
-
port: number;
|
|
16
|
-
baseEnv?: NodeJS.ProcessEnv;
|
|
17
|
-
caCertPath?: string;
|
|
18
|
-
routeInventory?: boolean;
|
|
19
|
-
models?: readonly CursorBridgeModelDescriptor[];
|
|
20
|
-
};
|
|
21
|
-
export type CursorBridgeModelDescriptor = {
|
|
22
|
-
id: string;
|
|
23
|
-
displayName?: string;
|
|
24
|
-
reasoning?: ModelReasoningCapabilities;
|
|
25
|
-
};
|
|
26
|
-
export type CursorIdeModelsInput = {
|
|
27
|
-
gatewayUrl: string;
|
|
28
|
-
modelLabel: string;
|
|
29
|
-
models?: readonly CursorBridgeModelDescriptor[];
|
|
30
|
-
apiKey?: string;
|
|
31
|
-
contextTokenLimit?: number;
|
|
32
|
-
};
|
|
33
|
-
export declare function cursorBridgeBaseUrl(gatewayUrl: string): string;
|
|
34
|
-
export declare function cursorBridgeModelEnv(input: CursorBridgeModelEnvInput): Record<string, string>;
|
|
35
|
-
export declare function cursorBridgeEnv(input: CursorBridgeEnvInput): Record<string, string>;
|
|
36
|
-
export declare function cursorIdeEnv(input: CursorIdeModelsInput & {
|
|
37
|
-
repo: string;
|
|
38
|
-
caCertPath?: string;
|
|
39
|
-
}): Record<string, string>;
|
|
40
|
-
export declare function cursorIdeModelsJson(input: CursorIdeModelsInput, legacyArray?: boolean): string;
|
package/dist/bridge-config.js
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { normalizeApiBaseUrl, scrubBridgeEnv } from "@velum-labs/routekit-runtime";
|
|
2
|
-
const DEFAULT_CONTEXT_TOKEN_LIMIT = 128000;
|
|
3
|
-
const DEFAULT_LOCAL_API_KEY = "local";
|
|
4
|
-
const DEFAULT_UPSTREAM_BASE_URL = "https://api2.cursor.sh";
|
|
5
|
-
export const CURSOR_AGENT_TOOL_POLICY = "all";
|
|
6
|
-
export const CURSOR_AGENT_TOOL_MAX_ITERATIONS = 24;
|
|
7
|
-
export const CURSOR_BRIDGE_SCRUB_PREFIXES = [
|
|
8
|
-
"BRIDGE_",
|
|
9
|
-
"MODEL_",
|
|
10
|
-
"E2E_",
|
|
11
|
-
"CURSOR_UPSTREAM"
|
|
12
|
-
];
|
|
13
|
-
export const CURSOR_IDE_SCRUB_PREFIXES = [...CURSOR_BRIDGE_SCRUB_PREFIXES, "CK_"];
|
|
14
|
-
export function cursorBridgeBaseUrl(gatewayUrl) {
|
|
15
|
-
return normalizeApiBaseUrl(gatewayUrl);
|
|
16
|
-
}
|
|
17
|
-
export function cursorBridgeModelEnv(input) {
|
|
18
|
-
return {
|
|
19
|
-
CURSOR_UPSTREAM_BASE_URL: input.upstreamBaseUrl ?? DEFAULT_UPSTREAM_BASE_URL,
|
|
20
|
-
MODEL_BASE_URL: cursorBridgeBaseUrl(input.gatewayUrl),
|
|
21
|
-
MODEL_API_KEY: input.apiKey ?? DEFAULT_LOCAL_API_KEY,
|
|
22
|
-
MODEL_NAME: input.modelName,
|
|
23
|
-
MODEL_PROVIDER_MODEL: input.providerModel ?? input.modelName,
|
|
24
|
-
MODEL_CONTEXT_TOKEN_LIMIT: String(input.contextTokenLimit ?? DEFAULT_CONTEXT_TOKEN_LIMIT)
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
export function cursorBridgeEnv(input) {
|
|
28
|
-
const env = scrubBridgeEnv(input.baseEnv ?? process.env, CURSOR_BRIDGE_SCRUB_PREFIXES);
|
|
29
|
-
return {
|
|
30
|
-
...env,
|
|
31
|
-
...(input.caCertPath !== undefined
|
|
32
|
-
? { NODE_EXTRA_CA_CERTS: env.NODE_EXTRA_CA_CERTS ?? input.caCertPath }
|
|
33
|
-
: {}),
|
|
34
|
-
BRIDGE_PORT: String(input.port),
|
|
35
|
-
BRIDGE_ROUTE_INVENTORY: input.routeInventory === false ? "false" : "true",
|
|
36
|
-
...(input.models !== undefined && input.models.length > 1
|
|
37
|
-
? {
|
|
38
|
-
BRIDGE_MODELS_JSON: cursorIdeModelsJson({
|
|
39
|
-
gatewayUrl: input.gatewayUrl,
|
|
40
|
-
modelLabel: input.modelName,
|
|
41
|
-
models: input.models,
|
|
42
|
-
...(input.apiKey !== undefined ? { apiKey: input.apiKey } : {}),
|
|
43
|
-
...(input.contextTokenLimit !== undefined
|
|
44
|
-
? { contextTokenLimit: input.contextTokenLimit }
|
|
45
|
-
: {})
|
|
46
|
-
}, true)
|
|
47
|
-
}
|
|
48
|
-
: {}),
|
|
49
|
-
...cursorBridgeModelEnv(input)
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
export function cursorIdeEnv(input) {
|
|
53
|
-
const env = scrubBridgeEnv(process.env, CURSOR_IDE_SCRUB_PREFIXES);
|
|
54
|
-
return {
|
|
55
|
-
...env,
|
|
56
|
-
CK_WORKSPACE_PATH: input.repo,
|
|
57
|
-
BRIDGE_MODELS_JSON: cursorIdeModelsJson(input, true),
|
|
58
|
-
...cursorBridgeModelEnv({
|
|
59
|
-
gatewayUrl: input.gatewayUrl,
|
|
60
|
-
modelName: input.modelLabel,
|
|
61
|
-
providerModel: input.modelLabel,
|
|
62
|
-
...(input.apiKey !== undefined ? { apiKey: input.apiKey } : {})
|
|
63
|
-
}),
|
|
64
|
-
...(input.caCertPath !== undefined
|
|
65
|
-
? { NODE_EXTRA_CA_CERTS: env.NODE_EXTRA_CA_CERTS ?? input.caCertPath }
|
|
66
|
-
: {})
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
export function cursorIdeModelsJson(input, legacyArray = false) {
|
|
70
|
-
const baseUrl = cursorBridgeBaseUrl(input.gatewayUrl);
|
|
71
|
-
const apiKey = input.apiKey ?? DEFAULT_LOCAL_API_KEY;
|
|
72
|
-
const contextTokenLimit = input.contextTokenLimit ?? DEFAULT_CONTEXT_TOKEN_LIMIT;
|
|
73
|
-
const supplied = input.models ?? [];
|
|
74
|
-
const byId = new Map(supplied.map((model) => [model.id, model]));
|
|
75
|
-
if (!byId.has(input.modelLabel)) {
|
|
76
|
-
byId.set(input.modelLabel, { id: input.modelLabel });
|
|
77
|
-
}
|
|
78
|
-
const models = [...byId.values()].map((model) => ({
|
|
79
|
-
id: model.id,
|
|
80
|
-
displayName: model.displayName ?? model.id,
|
|
81
|
-
providerModel: model.id,
|
|
82
|
-
baseUrl,
|
|
83
|
-
apiKey,
|
|
84
|
-
contextTokenLimit,
|
|
85
|
-
...(model.reasoning !== undefined
|
|
86
|
-
? { reasoning: model.reasoning }
|
|
87
|
-
: {})
|
|
88
|
-
}));
|
|
89
|
-
return JSON.stringify(legacyArray
|
|
90
|
-
? models
|
|
91
|
-
: {
|
|
92
|
-
version: 2,
|
|
93
|
-
models
|
|
94
|
-
});
|
|
95
|
-
}
|
package/dist/bridge.d.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import type { ChildProcess } from "node:child_process";
|
|
2
|
-
import type { CursorBridgeModelDescriptor } from "./bridge-config.js";
|
|
3
|
-
/**
|
|
4
|
-
* Start the Cursorkit bridge with its local-model backend pointed at the
|
|
5
|
-
* configured gateway. Resolves once it is listening.
|
|
6
|
-
*/
|
|
7
|
-
export declare function startCursorBridge(input: {
|
|
8
|
-
gatewayUrl: string;
|
|
9
|
-
modelLabel: string;
|
|
10
|
-
models?: readonly CursorBridgeModelDescriptor[];
|
|
11
|
-
logFile?: string;
|
|
12
|
-
caCertPath?: string;
|
|
13
|
-
apiKey?: string;
|
|
14
|
-
log: (line: string) => void;
|
|
15
|
-
}): Promise<{
|
|
16
|
-
child: ChildProcess;
|
|
17
|
-
port: number;
|
|
18
|
-
}>;
|
package/dist/bridge.js
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { reservePort, spawnLogged, terminate, waitForOutput } from "@velum-labs/routekit-runtime";
|
|
2
|
-
import { cursorBridgeEnv } from "./bridge-config.js";
|
|
3
|
-
import { resolveCursorkitCli } from "./cursorkit-path.js";
|
|
4
|
-
/**
|
|
5
|
-
* Start the Cursorkit bridge with its local-model backend pointed at the
|
|
6
|
-
* configured gateway. Resolves once it is listening.
|
|
7
|
-
*/
|
|
8
|
-
export async function startCursorBridge(input) {
|
|
9
|
-
// Hold the port until the bridge is about to bind it, so a concurrent picker
|
|
10
|
-
// cannot steal it in the gap between choosing and spawning.
|
|
11
|
-
const reservation = await reservePort();
|
|
12
|
-
const port = reservation.port;
|
|
13
|
-
const env = cursorBridgeEnv({
|
|
14
|
-
port,
|
|
15
|
-
gatewayUrl: input.gatewayUrl,
|
|
16
|
-
modelName: input.modelLabel,
|
|
17
|
-
providerModel: input.modelLabel,
|
|
18
|
-
...(input.apiKey !== undefined ? { apiKey: input.apiKey } : {}),
|
|
19
|
-
...(input.models !== undefined ? { models: input.models } : {}),
|
|
20
|
-
...(input.caCertPath !== undefined ? { caCertPath: input.caCertPath } : {})
|
|
21
|
-
});
|
|
22
|
-
const { serveCli } = resolveCursorkitCli();
|
|
23
|
-
await reservation.release();
|
|
24
|
-
const proc = spawnLogged(process.execPath, [serveCli, "serve"], {
|
|
25
|
-
...(input.logFile !== undefined ? { logFile: input.logFile } : {}),
|
|
26
|
-
env
|
|
27
|
-
});
|
|
28
|
-
try {
|
|
29
|
-
await waitForOutput(proc, /bridge listening/, { timeoutMs: 20_000, label: "Cursorkit bridge" });
|
|
30
|
-
}
|
|
31
|
-
catch (error) {
|
|
32
|
-
terminate(proc.child);
|
|
33
|
-
throw error instanceof Error ? error : new Error(String(error));
|
|
34
|
-
}
|
|
35
|
-
input.log(`Cursorkit bridge listening on http://127.0.0.1:${port}`);
|
|
36
|
-
return { child: proc.child, port };
|
|
37
|
-
}
|
package/dist/cursorkit-path.d.ts
DELETED
package/dist/cursorkit-path.js
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
-
const require = createRequire(import.meta.url);
|
|
4
|
-
export function resolveCursorkitCli() {
|
|
5
|
-
const override = process.env.ROUTEKIT_CURSORKIT_SERVE_CLI;
|
|
6
|
-
const serveCli = override !== undefined && override.length > 0
|
|
7
|
-
? override
|
|
8
|
-
: require.resolve("@velum-labs/cursorkit");
|
|
9
|
-
return { serveCli, harnessCli: join(dirname(serveCli), "testing", "cli.js") };
|
|
10
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { test } from "node:test";
|
|
3
|
-
import { cursorBridgeEnv, cursorIdeModelsJson } from "../bridge-config.js";
|
|
4
|
-
test("cursorIdeModelsJson preserves opaque model order and removes duplicates", () => {
|
|
5
|
-
const parsed = JSON.parse(cursorIdeModelsJson({
|
|
6
|
-
gatewayUrl: "http://127.0.0.1:9999",
|
|
7
|
-
modelLabel: "opaque-primary",
|
|
8
|
-
models: [
|
|
9
|
-
{
|
|
10
|
-
id: "opaque-primary",
|
|
11
|
-
reasoning: {
|
|
12
|
-
status: "supported",
|
|
13
|
-
efforts: [{ id: "quick" }, { id: "deep" }],
|
|
14
|
-
provenance: "provider"
|
|
15
|
-
}
|
|
16
|
-
},
|
|
17
|
-
{ id: "opaque-secondary" },
|
|
18
|
-
{ id: "native-model" },
|
|
19
|
-
{ id: "opaque-secondary" }
|
|
20
|
-
]
|
|
21
|
-
}));
|
|
22
|
-
assert.equal(parsed.version, 2);
|
|
23
|
-
assert.deepEqual(parsed.models.map((entry) => entry.id), ["opaque-primary", "opaque-secondary", "native-model"]);
|
|
24
|
-
assert.ok(parsed.models.every((entry) => entry.baseUrl.startsWith("http://127.0.0.1:9999")));
|
|
25
|
-
assert.ok(parsed.models.every((entry) => entry.providerModel === entry.id));
|
|
26
|
-
assert.deepEqual(parsed.models[0]?.reasoning?.efforts, [
|
|
27
|
-
{ id: "quick" },
|
|
28
|
-
{ id: "deep" }
|
|
29
|
-
]);
|
|
30
|
-
});
|
|
31
|
-
test("cursorBridgeEnv seeds BRIDGE_MODELS_JSON for multiple opaque models", () => {
|
|
32
|
-
const env = cursorBridgeEnv({
|
|
33
|
-
port: 4321,
|
|
34
|
-
gatewayUrl: "http://127.0.0.1:9999",
|
|
35
|
-
modelName: "opaque-primary",
|
|
36
|
-
models: [
|
|
37
|
-
{ id: "opaque-primary" },
|
|
38
|
-
{ id: "opaque-secondary" },
|
|
39
|
-
{ id: "native-model" }
|
|
40
|
-
],
|
|
41
|
-
baseEnv: {}
|
|
42
|
-
});
|
|
43
|
-
// MODEL_NAME stays the session default for single-model bridges.
|
|
44
|
-
assert.equal(env.MODEL_NAME, "opaque-primary");
|
|
45
|
-
const models = JSON.parse(env.BRIDGE_MODELS_JSON ?? "[]");
|
|
46
|
-
assert.deepEqual(models.map((entry) => entry.id), ["opaque-primary", "opaque-secondary", "native-model"]);
|
|
47
|
-
});
|
|
48
|
-
test("cursorBridgeEnv omits BRIDGE_MODELS_JSON for one model", () => {
|
|
49
|
-
const env = cursorBridgeEnv({
|
|
50
|
-
port: 4321,
|
|
51
|
-
gatewayUrl: "http://127.0.0.1:9999",
|
|
52
|
-
modelName: "opaque-primary",
|
|
53
|
-
models: [{ id: "opaque-primary" }],
|
|
54
|
-
baseEnv: {}
|
|
55
|
-
});
|
|
56
|
-
assert.equal(env.BRIDGE_MODELS_JSON, undefined);
|
|
57
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
-
import { test } from "node:test";
|
|
7
|
-
import { launchCursor } from "../launch.js";
|
|
8
|
-
/**
|
|
9
|
-
* A stub for `cursorkit ck`: records the env + cwd it was launched with, prints
|
|
10
|
-
* the readiness line `launchCursorIde` waits for, then idles until SIGTERM so
|
|
11
|
-
* the disposer-driven teardown path is exercised.
|
|
12
|
-
*/
|
|
13
|
-
function writeCkStub(path, outFile) {
|
|
14
|
-
writeFileSync(path, [
|
|
15
|
-
"const fs = require('node:fs');",
|
|
16
|
-
`fs.writeFileSync(${JSON.stringify(outFile)}, JSON.stringify({`,
|
|
17
|
-
" argv: process.argv.slice(2),",
|
|
18
|
-
" cwd: process.cwd(),",
|
|
19
|
-
" workspace: process.env.CK_WORKSPACE_PATH,",
|
|
20
|
-
" models: process.env.BRIDGE_MODELS_JSON,",
|
|
21
|
-
" modelBaseUrl: process.env.MODEL_BASE_URL,",
|
|
22
|
-
" modelApiKey: process.env.MODEL_API_KEY,",
|
|
23
|
-
" caCerts: process.env.NODE_EXTRA_CA_CERTS,",
|
|
24
|
-
" leakedBridge: process.env.BRIDGE_PORT",
|
|
25
|
-
"}));",
|
|
26
|
-
"process.stdout.write('ck ready\\n');",
|
|
27
|
-
"const timer = setInterval(() => {}, 1000);",
|
|
28
|
-
"process.on('SIGTERM', () => { clearInterval(timer); process.exit(0); });"
|
|
29
|
-
].join("\n"));
|
|
30
|
-
}
|
|
31
|
-
test("launchCursor CLI forwards only supported Cursor auth inputs", async () => {
|
|
32
|
-
const workdir = mkdtempSync(join(tmpdir(), "cursor-cli-auth-"));
|
|
33
|
-
const bridgeStub = join(workdir, "bridge.cjs");
|
|
34
|
-
const agentStub = join(workdir, "cursor-agent");
|
|
35
|
-
const recorder = join(workdir, "record-agent.cjs");
|
|
36
|
-
const observations = join(workdir, "agent-observations.ndjson");
|
|
37
|
-
writeFileSync(bridgeStub, [
|
|
38
|
-
"process.stdout.write('bridge listening\\n');",
|
|
39
|
-
"const timer = setInterval(() => {}, 1000);",
|
|
40
|
-
"process.on('SIGTERM', () => { clearInterval(timer); process.exit(0); });"
|
|
41
|
-
].join("\n"));
|
|
42
|
-
writeFileSync(recorder, [
|
|
43
|
-
'const { appendFileSync } = require("node:fs");',
|
|
44
|
-
`appendFileSync(${JSON.stringify(observations)}, JSON.stringify({`,
|
|
45
|
-
" auth: process.env.CURSOR_API_KEY ?? null,",
|
|
46
|
-
" config: process.env.CURSOR_CONFIG_DIR ?? null,",
|
|
47
|
-
" unrelated: process.env.UNRELATED_SECRET ?? null",
|
|
48
|
-
"}) + '\\n');"
|
|
49
|
-
].join("\n"));
|
|
50
|
-
writeFileSync(agentStub, `#!/bin/sh\nexec "${process.execPath}" "${recorder}" "$@"\n`);
|
|
51
|
-
chmodSync(agentStub, 0o755);
|
|
52
|
-
const previous = {
|
|
53
|
-
path: process.env.PATH,
|
|
54
|
-
serveCli: process.env.ROUTEKIT_CURSORKIT_SERVE_CLI,
|
|
55
|
-
apiKey: process.env.CURSOR_API_KEY,
|
|
56
|
-
configDirectory: process.env.CURSOR_CONFIG_DIR,
|
|
57
|
-
unrelated: process.env.UNRELATED_SECRET
|
|
58
|
-
};
|
|
59
|
-
process.env.PATH = `${workdir}:${process.env.PATH ?? ""}`;
|
|
60
|
-
process.env.ROUTEKIT_CURSORKIT_SERVE_CLI = bridgeStub;
|
|
61
|
-
process.env.UNRELATED_SECRET = "must-not-leak";
|
|
62
|
-
try {
|
|
63
|
-
const stagedConfig = join(workdir, "staged-config");
|
|
64
|
-
for (const [, apiKey, configDirectory, expected] of [
|
|
65
|
-
[
|
|
66
|
-
"env-key",
|
|
67
|
-
"cursor-test-key",
|
|
68
|
-
undefined,
|
|
69
|
-
{ auth: "cursor-test-key", config: null }
|
|
70
|
-
],
|
|
71
|
-
[
|
|
72
|
-
"staged-config",
|
|
73
|
-
undefined,
|
|
74
|
-
stagedConfig,
|
|
75
|
-
{ auth: null, config: stagedConfig }
|
|
76
|
-
],
|
|
77
|
-
["absent", undefined, undefined, { auth: null, config: null }]
|
|
78
|
-
]) {
|
|
79
|
-
if (apiKey === undefined)
|
|
80
|
-
delete process.env.CURSOR_API_KEY;
|
|
81
|
-
else
|
|
82
|
-
process.env.CURSOR_API_KEY = apiKey;
|
|
83
|
-
if (configDirectory === undefined)
|
|
84
|
-
delete process.env.CURSOR_CONFIG_DIR;
|
|
85
|
-
else
|
|
86
|
-
process.env.CURSOR_CONFIG_DIR = configDirectory;
|
|
87
|
-
const disposers = [];
|
|
88
|
-
const ctx = {
|
|
89
|
-
spec: {
|
|
90
|
-
gatewayUrl: "http://127.0.0.1:9999",
|
|
91
|
-
defaultModel: "primary",
|
|
92
|
-
models: [{ id: "primary" }],
|
|
93
|
-
args: [],
|
|
94
|
-
cwd: workdir
|
|
95
|
-
},
|
|
96
|
-
log: () => undefined,
|
|
97
|
-
prepareForPassthrough: () => undefined,
|
|
98
|
-
registerPort: (_name, port) => `http://127.0.0.1:${port}`,
|
|
99
|
-
unregisterPort: () => undefined,
|
|
100
|
-
registerDisposer: (dispose) => disposers.push(dispose)
|
|
101
|
-
};
|
|
102
|
-
try {
|
|
103
|
-
assert.equal(await launchCursor(ctx), 0);
|
|
104
|
-
const observed = readFileSync(observations, "utf8")
|
|
105
|
-
.trim()
|
|
106
|
-
.split("\n")
|
|
107
|
-
.map((line) => JSON.parse(line))
|
|
108
|
-
.at(-1);
|
|
109
|
-
assert.deepEqual(observed, {
|
|
110
|
-
...expected,
|
|
111
|
-
unrelated: null
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
finally {
|
|
115
|
-
for (const dispose of disposers)
|
|
116
|
-
await dispose();
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
finally {
|
|
121
|
-
for (const [name, value] of [
|
|
122
|
-
["PATH", previous.path],
|
|
123
|
-
["ROUTEKIT_CURSORKIT_SERVE_CLI", previous.serveCli],
|
|
124
|
-
["CURSOR_API_KEY", previous.apiKey],
|
|
125
|
-
["CURSOR_CONFIG_DIR", previous.configDirectory],
|
|
126
|
-
["UNRELATED_SECRET", previous.unrelated]
|
|
127
|
-
]) {
|
|
128
|
-
if (value === undefined)
|
|
129
|
-
delete process.env[name];
|
|
130
|
-
else
|
|
131
|
-
process.env[name] = value;
|
|
132
|
-
}
|
|
133
|
-
rmSync(workdir, { recursive: true, force: true });
|
|
134
|
-
}
|
|
135
|
-
});
|
|
136
|
-
test("launchCursor --ide drives the desktop launcher with the gateway-wired model", async () => {
|
|
137
|
-
const workdir = mkdtempSync(join(tmpdir(), "cursor-ide-"));
|
|
138
|
-
const repo = mkdtempSync(join(tmpdir(), "cursor-ide-repo-"));
|
|
139
|
-
const logsDir = join(workdir, "logs");
|
|
140
|
-
const stub = join(workdir, "ck-stub.cjs");
|
|
141
|
-
const outFile = join(workdir, "ck-invocation.json");
|
|
142
|
-
writeCkStub(stub, outFile);
|
|
143
|
-
const previousOverride = process.env.ROUTEKIT_CURSORKIT_SERVE_CLI;
|
|
144
|
-
// A leftover BRIDGE_* var that must be scrubbed before spawning the launcher.
|
|
145
|
-
const previousLeak = process.env.BRIDGE_PORT;
|
|
146
|
-
process.env.ROUTEKIT_CURSORKIT_SERVE_CLI = stub;
|
|
147
|
-
process.env.BRIDGE_PORT = "59999";
|
|
148
|
-
const disposers = [];
|
|
149
|
-
const logs = [];
|
|
150
|
-
const ctx = {
|
|
151
|
-
spec: {
|
|
152
|
-
gatewayUrl: "http://127.0.0.1:9999",
|
|
153
|
-
defaultModel: "primary",
|
|
154
|
-
models: [{ id: "primary", aliases: ["primary-alias"] }, { id: "gpt" }, { id: "sonnet" }],
|
|
155
|
-
args: [],
|
|
156
|
-
cwd: repo,
|
|
157
|
-
tls: { caCertPath: "/tmp/portless-ca.pem" },
|
|
158
|
-
logsDir,
|
|
159
|
-
ide: true
|
|
160
|
-
},
|
|
161
|
-
log: (line) => logs.push(line),
|
|
162
|
-
prepareForPassthrough: () => undefined,
|
|
163
|
-
registerPort: (_name, port) => `http://127.0.0.1:${port}`,
|
|
164
|
-
unregisterPort: () => undefined,
|
|
165
|
-
registerDisposer: (dispose) => disposers.push(dispose)
|
|
166
|
-
};
|
|
167
|
-
try {
|
|
168
|
-
const launched = launchCursor(ctx);
|
|
169
|
-
try {
|
|
170
|
-
// Wait until the launcher is fully up: a registered disposer means it
|
|
171
|
-
// passed the readiness gate and recorded its teardown (the stub wrote its
|
|
172
|
-
// invocation file before announcing readiness).
|
|
173
|
-
for (let i = 0; i < 200 && disposers.length === 0; i++) {
|
|
174
|
-
await delay(50);
|
|
175
|
-
}
|
|
176
|
-
assert.equal(disposers.length, 1);
|
|
177
|
-
assert.ok(existsSync(outFile), "the ck stub should have been invoked");
|
|
178
|
-
const invocation = JSON.parse(readFileSync(outFile, "utf8"));
|
|
179
|
-
assert.deepEqual(invocation.argv, ["ck"]);
|
|
180
|
-
// Opens the user's repo but keeps state out of it (cwd is the scratch dir).
|
|
181
|
-
assert.equal(invocation.workspace, repo);
|
|
182
|
-
assert.notEqual(invocation.cwd, repo);
|
|
183
|
-
assert.equal(invocation.modelBaseUrl, "http://127.0.0.1:9999/v1");
|
|
184
|
-
assert.equal(invocation.modelApiKey, "local");
|
|
185
|
-
assert.equal(invocation.caCerts, "/tmp/portless-ca.pem");
|
|
186
|
-
// A parent's BRIDGE_* env is scrubbed; only our seeded models flow through.
|
|
187
|
-
assert.equal(invocation.leakedBridge, undefined);
|
|
188
|
-
const models = JSON.parse(invocation.models ?? "[]");
|
|
189
|
-
assert.deepEqual(models.map((entry) => entry.id), ["primary", "primary-alias", "gpt", "sonnet"]);
|
|
190
|
-
assert.ok(models.every((entry) => entry.baseUrl === "http://127.0.0.1:9999/v1"));
|
|
191
|
-
}
|
|
192
|
-
finally {
|
|
193
|
-
// Always tear the desktop launcher down so the launch promise resolves and
|
|
194
|
-
// the test process can exit even when an assertion fails.
|
|
195
|
-
for (const dispose of disposers) {
|
|
196
|
-
await dispose();
|
|
197
|
-
}
|
|
198
|
-
const code = await launched;
|
|
199
|
-
assert.equal(code, 0);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
finally {
|
|
203
|
-
if (previousOverride === undefined) {
|
|
204
|
-
delete process.env.ROUTEKIT_CURSORKIT_SERVE_CLI;
|
|
205
|
-
}
|
|
206
|
-
else {
|
|
207
|
-
process.env.ROUTEKIT_CURSORKIT_SERVE_CLI = previousOverride;
|
|
208
|
-
}
|
|
209
|
-
if (previousLeak === undefined) {
|
|
210
|
-
delete process.env.BRIDGE_PORT;
|
|
211
|
-
}
|
|
212
|
-
else {
|
|
213
|
-
process.env.BRIDGE_PORT = previousLeak;
|
|
214
|
-
}
|
|
215
|
-
rmSync(workdir, { recursive: true, force: true });
|
|
216
|
-
rmSync(repo, { recursive: true, force: true });
|
|
217
|
-
}
|
|
218
|
-
});
|