mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 18:52:18 +03:00
Builds the renewal orchestrator in src/lib/cursor/renewal.ts: a bounded, unattended-safe --list-models nudge, a side-effect-free status availability check, an in-flight spawn lock keyed by command, and renewCursorConnection() which nudges cursor-agent then independently re-scrapes the IDE and cursor-agent credential sources to detect whichever refreshed. Extends cursorAgent.ts's binary resolution and spawn helper with fixed-paths-only mode and a SIGKILL follow-up for background use. Adds a generic keyed-mutex utility (src/shared/utils/keyedMutex.ts) for serializing a connection's renew-then-persist cycle, and forwards a busy-timeout through driverFactory's node:sqlite fallback path.
301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
import test, { describe, it, beforeEach, afterEach } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
humanizeCursorModelId,
|
|
parseCursorAgentModels,
|
|
resolveCursorAgentBinary,
|
|
runCursorAgent,
|
|
} from "../../src/lib/providerModels/cursorAgent";
|
|
|
|
test("parseCursorAgentModels returns every reported id including auto and composer-*", () => {
|
|
const text =
|
|
"Cannot use this model: --help. Available models: auto, composer-2, composer-2-fast, gpt-5.3-codex-low, claude-opus-4-7-thinking-high, kimi-k2.5";
|
|
assert.deepEqual(parseCursorAgentModels(text), [
|
|
"auto",
|
|
"composer-2",
|
|
"composer-2-fast",
|
|
"gpt-5.3-codex-low",
|
|
"claude-opus-4-7-thinking-high",
|
|
"kimi-k2.5",
|
|
]);
|
|
});
|
|
|
|
test("parseCursorAgentModels deduplicates and trims", () => {
|
|
assert.deepEqual(parseCursorAgentModels("Available models: a, a , b"), ["a", "b"]);
|
|
});
|
|
|
|
test("parseCursorAgentModels parses the multiline output from the models command", () => {
|
|
const text = `Available models
|
|
|
|
auto - Auto (default)
|
|
gpt-5.3-codex - Codex 5.3
|
|
claude-opus-4-8-thinking-high-fast - Opus 4.8 1M Thinking Fast
|
|
|
|
Tip: use --model <id> to switch.`;
|
|
assert.deepEqual(parseCursorAgentModels(text), [
|
|
"auto",
|
|
"gpt-5.3-codex",
|
|
"claude-opus-4-8-thinking-high-fast",
|
|
]);
|
|
});
|
|
|
|
test("parseCursorAgentModels returns [] when the marker is missing", () => {
|
|
assert.deepEqual(parseCursorAgentModels("nothing here"), []);
|
|
});
|
|
|
|
test("humanizeCursorModelId pretty-prints common patterns", () => {
|
|
assert.equal(humanizeCursorModelId("auto"), "Auto (Server Picks)");
|
|
assert.equal(humanizeCursorModelId("composer-2-fast"), "Composer 2 Fast");
|
|
assert.equal(humanizeCursorModelId("gpt-5.3-codex-low"), "GPT 5.3 Codex Low");
|
|
assert.equal(humanizeCursorModelId("gpt-5.5-extra-high-fast"), "GPT 5.5 Extra High Fast");
|
|
// Collapses claude-opus-4-7-* version pattern into 4.7
|
|
assert.equal(
|
|
humanizeCursorModelId("claude-opus-4-7-thinking-high"),
|
|
"Claude Opus 4.7 Thinking High"
|
|
);
|
|
assert.equal(
|
|
humanizeCursorModelId("claude-opus-4-8-thinking-high-fast"),
|
|
"Claude Opus 4.8 Thinking High Fast"
|
|
);
|
|
assert.equal(
|
|
humanizeCursorModelId("claude-fable-5-thinking-xhigh"),
|
|
"Claude Fable 5 Thinking XHigh"
|
|
);
|
|
assert.equal(humanizeCursorModelId("claude-sonnet-5-max"), "Claude Sonnet 5 Max");
|
|
assert.equal(humanizeCursorModelId("kimi-k2.5"), "Kimi K2.5");
|
|
assert.equal(humanizeCursorModelId("gemini-3.1-pro"), "Gemini 3.1 Pro");
|
|
assert.equal(humanizeCursorModelId("claude-4-sonnet-thinking"), "Claude 4 Sonnet Thinking");
|
|
// Grok 4.5 uses infix -fast- (unlike GPT's trailing -fast)
|
|
assert.equal(humanizeCursorModelId("grok-4.5-medium"), "Grok 4.5 Medium");
|
|
assert.equal(humanizeCursorModelId("grok-4.5-fast-medium"), "Grok 4.5 Fast Medium");
|
|
assert.equal(humanizeCursorModelId("grok-4.5-xhigh"), "Grok 4.5 XHigh");
|
|
assert.equal(humanizeCursorModelId("grok-4.5-fast-xhigh"), "Grok 4.5 Fast XHigh");
|
|
});
|
|
|
|
// --- Cursor renewal plan, Task 2 Step 1: resolveCursorAgentBinary()/runCursorAgent() ---
|
|
//
|
|
// resolveCursorAgentBinary() checks a fixed list of absolute candidate paths
|
|
// (one of which is HOME-relative: `~/.local/bin/cursor-agent`) before an
|
|
// optional PATH scan. It has no injectable candidate list, so the 4 other
|
|
// hardcoded absolute paths (/root/.local/bin, /usr/local/bin, /usr/bin,
|
|
// /opt/homebrew/bin) are outside test control. On a host that genuinely has
|
|
// cursor-agent installed at one of those paths (true on at least one dev
|
|
// machine, via Homebrew Cask `cursor-cli`), the "nothing found" branches
|
|
// cannot be made hermetic without either touching real system files (out of
|
|
// scope — that's someone's actual local install) or a DI seam in production
|
|
// code (out of this test-writer's scope). Those specific cases are guarded
|
|
// with a runtime-computed `skip` reason instead of being silently omitted.
|
|
function ambientFixedCursorAgentPath(): string | null {
|
|
const candidates = [
|
|
"/root/.local/bin/cursor-agent",
|
|
"/usr/local/bin/cursor-agent",
|
|
"/usr/bin/cursor-agent",
|
|
"/opt/homebrew/bin/cursor-agent",
|
|
];
|
|
return candidates.find((c) => fs.existsSync(c)) ?? null;
|
|
}
|
|
|
|
function writeFakeBinary(destPath: string, script: string): void {
|
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
fs.writeFileSync(destPath, script, { mode: 0o755 });
|
|
fs.chmodSync(destPath, 0o755);
|
|
}
|
|
|
|
const NOOP_SCRIPT = "#!/usr/bin/env node\n";
|
|
|
|
describe("resolveCursorAgentBinary", () => {
|
|
const ORIGINAL_HOME = process.env.HOME;
|
|
const ORIGINAL_USERPROFILE = process.env.USERPROFILE;
|
|
const ORIGINAL_PATH = process.env.PATH;
|
|
let tmpHome: string;
|
|
|
|
beforeEach(() => {
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-resolve-cursor-agent-"));
|
|
process.env.HOME = tmpHome;
|
|
process.env.USERPROFILE = tmpHome;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env.HOME = ORIGINAL_HOME;
|
|
if (ORIGINAL_USERPROFILE !== undefined) process.env.USERPROFILE = ORIGINAL_USERPROFILE;
|
|
else delete process.env.USERPROFILE;
|
|
process.env.PATH = ORIGINAL_PATH;
|
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
});
|
|
|
|
it("finds the HOME-relative fixed candidate (~/.local/bin/cursor-agent) with allowPathFallback:false", () => {
|
|
const binary = path.join(tmpHome, ".local", "bin", "cursor-agent");
|
|
writeFakeBinary(binary, NOOP_SCRIPT);
|
|
assert.equal(resolveCursorAgentBinary({ allowPathFallback: false }), binary);
|
|
});
|
|
|
|
it("finds the HOME-relative fixed candidate with allowPathFallback:true (byte-identical default caller behavior)", () => {
|
|
const binary = path.join(tmpHome, ".local", "bin", "cursor-agent");
|
|
writeFakeBinary(binary, NOOP_SCRIPT);
|
|
assert.equal(resolveCursorAgentBinary(), binary);
|
|
assert.equal(resolveCursorAgentBinary({ allowPathFallback: true }), binary);
|
|
});
|
|
|
|
it("a fixed-path match wins over a PATH-only decoy, regardless of allowPathFallback", () => {
|
|
const fixedBinary = path.join(tmpHome, ".local", "bin", "cursor-agent");
|
|
writeFakeBinary(fixedBinary, NOOP_SCRIPT);
|
|
|
|
const pathDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-decoy-path-"));
|
|
const decoyBinary = path.join(pathDir, "cursor-agent");
|
|
writeFakeBinary(decoyBinary, NOOP_SCRIPT);
|
|
process.env.PATH = `${pathDir}${path.delimiter}${ORIGINAL_PATH ?? ""}`;
|
|
|
|
try {
|
|
assert.equal(resolveCursorAgentBinary({ allowPathFallback: false }), fixedBinary);
|
|
assert.equal(resolveCursorAgentBinary({ allowPathFallback: true }), fixedBinary);
|
|
} finally {
|
|
fs.rmSync(pathDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("allowPathFallback:true finds a PATH-only binary when no fixed candidate matches", () => {
|
|
const ambient = ambientFixedCursorAgentPath();
|
|
if (ambient) {
|
|
// The HOME-relative candidate is clean (fresh tmp dir), but an ambient
|
|
// real install at one of the OTHER 4 hardcoded absolute paths would be
|
|
// found first regardless of PATH — not hermetically testable here.
|
|
console.log(
|
|
`SKIP: ambient cursor-agent install detected at ${ambient} (see file-level note above)`
|
|
);
|
|
return;
|
|
}
|
|
const pathDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-real-path-only-"));
|
|
const pathOnlyBinary = path.join(pathDir, "cursor-agent");
|
|
writeFakeBinary(pathOnlyBinary, NOOP_SCRIPT);
|
|
process.env.PATH = `${pathDir}${path.delimiter}${ORIGINAL_PATH ?? ""}`;
|
|
try {
|
|
assert.equal(resolveCursorAgentBinary({ allowPathFallback: true }), pathOnlyBinary);
|
|
assert.equal(resolveCursorAgentBinary(), pathOnlyBinary);
|
|
} finally {
|
|
fs.rmSync(pathDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
const ambient = ambientFixedCursorAgentPath();
|
|
|
|
it(
|
|
"allowPathFallback:false returns null when only a PATH-resolvable binary exists (no fixed-path match)",
|
|
{
|
|
skip: ambient
|
|
? `ambient cursor-agent install detected at ${ambient} — resolveCursorAgentBinary has no ` +
|
|
"DI seam for its other hardcoded absolute candidates, so this host can never observe a " +
|
|
'true "nothing fixed matches" state; passes in a clean CI container without cursor-agent installed'
|
|
: false,
|
|
},
|
|
() => {
|
|
const pathDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-path-only-no-fallback-"));
|
|
const pathOnlyBinary = path.join(pathDir, "cursor-agent");
|
|
writeFakeBinary(pathOnlyBinary, NOOP_SCRIPT);
|
|
process.env.PATH = `${pathDir}${path.delimiter}${ORIGINAL_PATH ?? ""}`;
|
|
try {
|
|
assert.equal(resolveCursorAgentBinary({ allowPathFallback: false }), null);
|
|
} finally {
|
|
fs.rmSync(pathDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
);
|
|
|
|
it(
|
|
"returns null when nothing matches and allowPathFallback is false",
|
|
{
|
|
skip: ambient
|
|
? `ambient cursor-agent install detected at ${ambient} — see note above`
|
|
: false,
|
|
},
|
|
() => {
|
|
assert.equal(resolveCursorAgentBinary({ allowPathFallback: false }), null);
|
|
}
|
|
);
|
|
|
|
it(
|
|
"finds the real /opt/homebrew/bin/cursor-agent candidate on hosts that have it installed there, without a PATH scan",
|
|
{
|
|
skip:
|
|
fs.existsSync("/opt/homebrew/bin/cursor-agent") &&
|
|
!fs.existsSync("/root/.local/bin/cursor-agent") &&
|
|
!fs.existsSync("/usr/local/bin/cursor-agent") &&
|
|
!fs.existsSync("/usr/bin/cursor-agent")
|
|
? false
|
|
: "no ambient /opt/homebrew/bin/cursor-agent (or an earlier fixed candidate shadows it) on this host",
|
|
},
|
|
() => {
|
|
// Read-only, non-destructive: asserts against whatever is already
|
|
// installed on this host (e.g. via `brew install --cask cursor-cli`) —
|
|
// never creates/modifies anything at this real system path.
|
|
process.env.PATH = "";
|
|
assert.equal(
|
|
resolveCursorAgentBinary({ allowPathFallback: false }),
|
|
"/opt/homebrew/bin/cursor-agent"
|
|
);
|
|
}
|
|
);
|
|
});
|
|
|
|
describe("runCursorAgent — sigkillFollowupMs (Task 2 Step 1/2 unattended hardening)", () => {
|
|
let tmpDir: string;
|
|
let binary: string;
|
|
|
|
const HANG_IGNORE_SIGTERM_SCRIPT = `#!/usr/bin/env node
|
|
process.on("SIGTERM", () => {});
|
|
const selfExitMs = process.env.FAKE_BIN_SELF_EXIT_MS;
|
|
if (selfExitMs) {
|
|
setTimeout(() => process.exit(0), Number(selfExitMs));
|
|
} else {
|
|
setInterval(() => {}, 60000);
|
|
}
|
|
`;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-run-cursor-agent-"));
|
|
binary = path.join(tmpDir, "fake-cursor-agent");
|
|
writeFakeBinary(binary, HANG_IGNORE_SIGTERM_SCRIPT);
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete process.env.FAKE_BIN_SELF_EXIT_MS;
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("sends SIGKILL after the follow-up window when the process ignores SIGTERM", async () => {
|
|
// A freshly spawned node process (via the #!/usr/bin/env node shebang) needs
|
|
// real wall-clock time to start up and register its SIGTERM handler before
|
|
// the handler can take effect — empirically, 150-200ms was flaky in this
|
|
// sandboxed environment (SIGTERM won the race and killed the process before
|
|
// the handler was installed). 600ms/200ms gives a comfortable margin.
|
|
const start = Date.now();
|
|
const result = await runCursorAgent(binary, [], 600, { sigkillFollowupMs: 200 });
|
|
const elapsed = Date.now() - start;
|
|
assert.equal(result.signal, "SIGKILL");
|
|
assert.ok(
|
|
elapsed >= 600,
|
|
`SIGKILL cannot fire before the SIGTERM timeout (600ms), got ${elapsed}ms`
|
|
);
|
|
assert.ok(elapsed < 10_000, `expected the SIGKILL follow-up well under 10s, got ${elapsed}ms`);
|
|
});
|
|
|
|
it("does NOT force-kill when sigkillFollowupMs is omitted (byte-identical to the existing fetchCursorAgentModels caller)", async () => {
|
|
process.env.FAKE_BIN_SELF_EXIT_MS = "1000";
|
|
const start = Date.now();
|
|
const result = await runCursorAgent(binary, [], 600);
|
|
const elapsed = Date.now() - start;
|
|
// No sigkillFollowupMs -> SIGTERM at 600ms is ignored, process exits on
|
|
// its own at ~1000ms via process.exit(0) — proves no automatic SIGKILL
|
|
// follow-up fired (that would have resolved near 600ms with signal
|
|
// "SIGKILL" instead).
|
|
assert.equal(result.signal, null);
|
|
assert.equal(result.code, 0);
|
|
assert.ok(
|
|
elapsed >= 950,
|
|
`expected the process to exit on its own near 1000ms, got ${elapsed}ms`
|
|
);
|
|
});
|
|
});
|