cursor-route 0.1.1 → 0.1.4
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 +53 -8
- package/SECURITY.md +3 -1
- package/SUPPORT.md +24 -0
- package/bin/cursor-route +6 -8
- package/bin/cursor-route.js +11 -8
- package/dist/adapters/claude-ds.js +189 -0
- package/dist/adapters/grok.js +57 -0
- package/dist/adapters/index.js +15 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli.js +431 -0
- package/dist/config.js +45 -0
- package/dist/health.js +100 -0
- package/dist/jobs.js +410 -0
- package/dist/mark-complete.js +38 -0
- package/dist/runtime.js +27 -0
- package/dist/secrets.js +39 -0
- package/dist/tmux.js +117 -0
- package/dist/util.js +21 -0
- package/docs/DEMO_GIF.md +9 -2
- package/docs/demo-notes.md +8 -1
- package/docs/fixtures/claude-ds-smoke.log +1 -1
- package/llms.txt +29 -0
- package/package.json +12 -6
- package/src/adapters/claude-ds.ts +68 -22
- package/src/adapters/grok.ts +10 -5
- package/src/adapters/types.ts +2 -0
- package/src/cli.test.ts +24 -9
- package/src/cli.ts +105 -15
- package/src/config.ts +22 -2
- package/src/health.ts +8 -6
- package/src/integration.test.ts +167 -0
- package/src/jobs.ts +85 -11
- package/src/mark-complete.ts +2 -6
- package/src/runtime.ts +9 -4
- package/src/secrets.ts +21 -4
- package/src/tmux.ts +20 -8
- package/src/util.ts +3 -1
- package/docs/audit-2026-08-10-sol-grok-kimi.md +0 -64
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fake-worker integration tests (headless, no tmux needed).
|
|
3
|
+
*
|
|
4
|
+
* Env (set before importing config/jobs so jobsDir + limit take effect):
|
|
5
|
+
* CURSOR_ROUTE_JOBS_DIR → unique tmpdir
|
|
6
|
+
* CURSOR_ROUTE_MAX_JOBS → 2 (so the concurrency refusal is cheap to test)
|
|
7
|
+
* CURSOR_ROUTE_GROK_BIN → absolute path to the fake grok shim (read in-process
|
|
8
|
+
* by the adapter; PATH shadowing alone is unreliable
|
|
9
|
+
* under bun, which snapshots env for child processes)
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, test, afterAll } from "bun:test";
|
|
12
|
+
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync, readdirSync, existsSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import type { Job } from "./jobs.ts";
|
|
16
|
+
|
|
17
|
+
const tmp = mkdtempSync(join(tmpdir(), "cursor-route-int-"));
|
|
18
|
+
const jobsDir = join(tmp, "jobs");
|
|
19
|
+
const shimDir = join(tmp, "bin");
|
|
20
|
+
const shimLog = join(tmp, "shim.log");
|
|
21
|
+
|
|
22
|
+
process.env.CURSOR_ROUTE_JOBS_DIR = jobsDir;
|
|
23
|
+
process.env.CURSOR_ROUTE_MAX_JOBS = "2";
|
|
24
|
+
|
|
25
|
+
mkdirSync(jobsDir, { recursive: true });
|
|
26
|
+
mkdirSync(shimDir, { recursive: true });
|
|
27
|
+
|
|
28
|
+
function writeShim(name: string): void {
|
|
29
|
+
const body = `#!/usr/bin/env sh
|
|
30
|
+
{
|
|
31
|
+
echo "argv=$*"
|
|
32
|
+
echo "pwd=$PWD"
|
|
33
|
+
} >> '${shimLog}'
|
|
34
|
+
sleep "\${FAKE_WORKER_SLEEP:-1}"
|
|
35
|
+
exit "\${FAKE_WORKER_EXIT:-0}"
|
|
36
|
+
`;
|
|
37
|
+
const p = join(shimDir, name);
|
|
38
|
+
writeFileSync(p, body);
|
|
39
|
+
chmodSync(p, 0o755);
|
|
40
|
+
}
|
|
41
|
+
writeShim("grok");
|
|
42
|
+
writeShim("claude-ds");
|
|
43
|
+
process.env.CURSOR_ROUTE_GROK_BIN = join(shimDir, "grok");
|
|
44
|
+
process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = join(shimDir, "claude-ds");
|
|
45
|
+
|
|
46
|
+
const { startJob, readJob, killJob, refreshStatus, countActiveJobs } = await import("./jobs.ts");
|
|
47
|
+
|
|
48
|
+
function sleep(ms: number): Promise<void> {
|
|
49
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function waitForTerminal(jobId: string, timeoutMs = 15000): Promise<Job> {
|
|
53
|
+
const deadline = Date.now() + timeoutMs;
|
|
54
|
+
while (Date.now() < deadline) {
|
|
55
|
+
const job = readJob(jobId);
|
|
56
|
+
if (!job) {
|
|
57
|
+
await sleep(100);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const fresh = refreshStatus(job);
|
|
61
|
+
if (fresh.status === "completed" || fresh.status === "failed" || fresh.status === "killed") {
|
|
62
|
+
return fresh;
|
|
63
|
+
}
|
|
64
|
+
await sleep(200);
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`timed out waiting for job ${jobId} to reach terminal state`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
afterAll(() => {
|
|
70
|
+
try {
|
|
71
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
72
|
+
} catch {
|
|
73
|
+
/* ignore */
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("fake-worker integration (headless, no tmux)", () => {
|
|
78
|
+
test(
|
|
79
|
+
"start → completed with exitCode 0",
|
|
80
|
+
async () => {
|
|
81
|
+
delete process.env.FAKE_WORKER_EXIT;
|
|
82
|
+
delete process.env.FAKE_WORKER_SLEEP;
|
|
83
|
+
const r = startJob({ prompt: "say ok", worker: "grok", noTmux: true });
|
|
84
|
+
expect(r.ok).toBe(true);
|
|
85
|
+
if (!r.ok) return;
|
|
86
|
+
const job = await waitForTerminal(r.job.id);
|
|
87
|
+
expect(job.status).toBe("completed");
|
|
88
|
+
expect(job.exitCode).toBe(0);
|
|
89
|
+
},
|
|
90
|
+
20000,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
test(
|
|
94
|
+
"FAKE_WORKER_EXIT=7 → failed with exitCode 7",
|
|
95
|
+
async () => {
|
|
96
|
+
process.env.FAKE_WORKER_EXIT = "7";
|
|
97
|
+
delete process.env.FAKE_WORKER_SLEEP;
|
|
98
|
+
try {
|
|
99
|
+
const r = startJob({ prompt: "say fail", worker: "grok", noTmux: true });
|
|
100
|
+
expect(r.ok).toBe(true);
|
|
101
|
+
if (!r.ok) return;
|
|
102
|
+
const job = await waitForTerminal(r.job.id);
|
|
103
|
+
expect(job.status).toBe("failed");
|
|
104
|
+
expect(job.exitCode).toBe(7);
|
|
105
|
+
} finally {
|
|
106
|
+
delete process.env.FAKE_WORKER_EXIT;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
20000,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
test(
|
|
113
|
+
"kill running job → killed; second kill refused as terminal",
|
|
114
|
+
async () => {
|
|
115
|
+
process.env.FAKE_WORKER_SLEEP = "30";
|
|
116
|
+
delete process.env.FAKE_WORKER_EXIT;
|
|
117
|
+
try {
|
|
118
|
+
const r = startJob({ prompt: "long job", worker: "grok", noTmux: true });
|
|
119
|
+
expect(r.ok).toBe(true);
|
|
120
|
+
if (!r.ok) return;
|
|
121
|
+
// Give the detached spawn a beat so the pid is recorded and the shim is alive.
|
|
122
|
+
await sleep(300);
|
|
123
|
+
const k = killJob(r.job.id);
|
|
124
|
+
expect(k.ok).toBe(true);
|
|
125
|
+
if (k.ok) expect(k.job.status).toBe("killed");
|
|
126
|
+
|
|
127
|
+
const again = killJob(r.job.id);
|
|
128
|
+
expect(again.ok).toBe(false);
|
|
129
|
+
if (!again.ok) expect(again.error).toContain("terminal");
|
|
130
|
+
} finally {
|
|
131
|
+
delete process.env.FAKE_WORKER_SLEEP;
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
20000,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
test(
|
|
138
|
+
"concurrent limit: refuses start once active jobs reach CURSOR_ROUTE_MAX_JOBS",
|
|
139
|
+
async () => {
|
|
140
|
+
process.env.FAKE_WORKER_SLEEP = "30";
|
|
141
|
+
delete process.env.FAKE_WORKER_EXIT;
|
|
142
|
+
try {
|
|
143
|
+
const a = startJob({ prompt: "job a", worker: "grok", noTmux: true });
|
|
144
|
+
const b = startJob({ prompt: "job b", worker: "grok", noTmux: true });
|
|
145
|
+
expect(a.ok).toBe(true);
|
|
146
|
+
expect(b.ok).toBe(true);
|
|
147
|
+
if (!a.ok || !b.ok) return;
|
|
148
|
+
expect(countActiveJobs()).toBe(2);
|
|
149
|
+
|
|
150
|
+
const c = startJob({ prompt: "job c", worker: "grok", noTmux: true });
|
|
151
|
+
expect(c.ok).toBe(false);
|
|
152
|
+
if (!c.ok) expect(c.error).toContain("Too many active jobs");
|
|
153
|
+
} finally {
|
|
154
|
+
delete process.env.FAKE_WORKER_SLEEP;
|
|
155
|
+
// Cleanup any running jobs so the tmpdir can be removed.
|
|
156
|
+
if (existsSync(jobsDir)) {
|
|
157
|
+
for (const f of readdirSync(jobsDir)) {
|
|
158
|
+
if (!f.endsWith(".json")) continue;
|
|
159
|
+
const job = readJob(f.replace(/\.json$/, ""));
|
|
160
|
+
if (job && (job.status === "running" || job.status === "pending")) killJob(job.id);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
20000,
|
|
166
|
+
);
|
|
167
|
+
});
|
package/src/jobs.ts
CHANGED
|
@@ -98,10 +98,21 @@ export function writeJob(job: Job): void {
|
|
|
98
98
|
function pidAlive(pid: number): boolean {
|
|
99
99
|
try {
|
|
100
100
|
process.kill(pid, 0);
|
|
101
|
-
return true;
|
|
102
101
|
} catch {
|
|
103
102
|
return false;
|
|
104
103
|
}
|
|
104
|
+
// kill(pid, 0) stays true for zombies, and terminatePid's sync waits block the
|
|
105
|
+
// event loop so children can sit unreaped as zombies — a zombie is not a worker.
|
|
106
|
+
try {
|
|
107
|
+
const r = spawnSync("ps", ["-o", "state=", "-p", String(pid)], {
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
110
|
+
});
|
|
111
|
+
const state = (r.stdout || "").trim();
|
|
112
|
+
return state !== "" && !state.startsWith("Z");
|
|
113
|
+
} catch {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
105
116
|
}
|
|
106
117
|
|
|
107
118
|
function sleepMs(ms: number): void {
|
|
@@ -169,6 +180,25 @@ export function refreshStatus(job: Job): Job {
|
|
|
169
180
|
return job;
|
|
170
181
|
}
|
|
171
182
|
|
|
183
|
+
export function countActiveJobs(): number {
|
|
184
|
+
ensureJobsDir();
|
|
185
|
+
let n = 0;
|
|
186
|
+
for (const f of readdirSync(config.jobsDir)) {
|
|
187
|
+
if (!f.endsWith(".json")) continue;
|
|
188
|
+
const id = f.replace(/\.json$/, "");
|
|
189
|
+
if (!JOB_ID_RE.test(id)) continue;
|
|
190
|
+
try {
|
|
191
|
+
const job = JSON.parse(readFileSync(join(config.jobsDir, f), "utf8")) as Job;
|
|
192
|
+
if (job?.schema === "cursor-route.job.v1" && (job.status === "running" || job.status === "pending")) {
|
|
193
|
+
n++;
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
/* skip */
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return n;
|
|
200
|
+
}
|
|
201
|
+
|
|
172
202
|
export function listJobs(limit = config.jobsListLimit): Job[] {
|
|
173
203
|
ensureJobsDir();
|
|
174
204
|
const files = readdirSync(config.jobsDir).filter(
|
|
@@ -211,10 +241,21 @@ export function startJob(opts: StartOptions): {
|
|
|
211
241
|
} | { ok: false; error: string } {
|
|
212
242
|
const worker = resolveWorker(opts);
|
|
213
243
|
const cwd = opts.cwd || process.cwd();
|
|
244
|
+
// CURSOR_ROUTE_ASK applies to all workers; CLAUDE_DS_ASK is mid-lane only
|
|
214
245
|
const alwaysApprove =
|
|
215
246
|
opts.alwaysApprove !== false &&
|
|
216
247
|
process.env.CURSOR_ROUTE_ASK !== "1" &&
|
|
217
|
-
process.env.CLAUDE_DS_ASK
|
|
248
|
+
!(worker === "claude-ds" && process.env.CLAUDE_DS_ASK === "1");
|
|
249
|
+
|
|
250
|
+
if (!opts.dryRun) {
|
|
251
|
+
const active = countActiveJobs();
|
|
252
|
+
if (active >= config.maxConcurrentJobs) {
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
error: `Too many active jobs (${active} >= ${config.maxConcurrentJobs}) — wait for jobs to finish or raise CURSOR_ROUTE_MAX_JOBS`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
218
259
|
|
|
219
260
|
// Preflight: requested worker must be healthy
|
|
220
261
|
const adapter = getAdapter(worker);
|
|
@@ -235,6 +276,11 @@ export function startJob(opts: StartOptions): {
|
|
|
235
276
|
alwaysApprove,
|
|
236
277
|
});
|
|
237
278
|
} catch (e) {
|
|
279
|
+
try {
|
|
280
|
+
unlinkSync(paths.prompt);
|
|
281
|
+
} catch {
|
|
282
|
+
/* ignore */
|
|
283
|
+
}
|
|
238
284
|
return { ok: false, error: (e as Error).message };
|
|
239
285
|
}
|
|
240
286
|
|
|
@@ -258,7 +304,15 @@ export function startJob(opts: StartOptions): {
|
|
|
258
304
|
} catch {
|
|
259
305
|
/* ignore */
|
|
260
306
|
}
|
|
261
|
-
|
|
307
|
+
const envNote = plan.env
|
|
308
|
+
? ` (+env: ${Object.keys(plan.env).join(",")})`
|
|
309
|
+
: "";
|
|
310
|
+
return {
|
|
311
|
+
ok: true,
|
|
312
|
+
job,
|
|
313
|
+
dryRun: true,
|
|
314
|
+
command: plan.command + envNote,
|
|
315
|
+
};
|
|
262
316
|
}
|
|
263
317
|
|
|
264
318
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -293,6 +347,7 @@ export function startJob(opts: StartOptions): {
|
|
|
293
347
|
cwd,
|
|
294
348
|
detached: true,
|
|
295
349
|
stdio: ["ignore", logFd, logFd],
|
|
350
|
+
env: plan.env ? { ...process.env, ...plan.env } : process.env,
|
|
296
351
|
});
|
|
297
352
|
try {
|
|
298
353
|
closeSync(logFd);
|
|
@@ -308,8 +363,13 @@ export function startJob(opts: StartOptions): {
|
|
|
308
363
|
return { ok: false, error: job.error };
|
|
309
364
|
}
|
|
310
365
|
|
|
311
|
-
|
|
312
|
-
|
|
366
|
+
// Merge-on-write: only set pid if still running (avoid clobbering mark-complete)
|
|
367
|
+
const latest = readJob(id) || job;
|
|
368
|
+
if (latest.status === "running" || latest.status === "pending") {
|
|
369
|
+
latest.pid = child.pid;
|
|
370
|
+
writeJob(latest);
|
|
371
|
+
Object.assign(job, latest);
|
|
372
|
+
}
|
|
313
373
|
child.unref();
|
|
314
374
|
return { ok: true, job };
|
|
315
375
|
}
|
|
@@ -321,6 +381,7 @@ export function startJob(opts: StartOptions): {
|
|
|
321
381
|
logFile: paths.log,
|
|
322
382
|
jobFile: paths.json,
|
|
323
383
|
markCompleteScript: markComplete,
|
|
384
|
+
env: plan.env,
|
|
324
385
|
});
|
|
325
386
|
|
|
326
387
|
if (!created.ok) {
|
|
@@ -331,14 +392,22 @@ export function startJob(opts: StartOptions): {
|
|
|
331
392
|
return { ok: false, error: created.error };
|
|
332
393
|
}
|
|
333
394
|
|
|
334
|
-
|
|
335
|
-
|
|
395
|
+
// Re-read before final write — mark-complete may have already finished
|
|
396
|
+
const after = readJob(id) || job;
|
|
397
|
+
if (after.status === "running" || after.status === "pending") {
|
|
398
|
+
writeJob(after);
|
|
399
|
+
}
|
|
400
|
+
return { ok: true, job: after };
|
|
336
401
|
}
|
|
337
402
|
|
|
338
403
|
export function killJob(id: string): { ok: true; job: Job } | { ok: false; error: string } {
|
|
339
404
|
const job = readJob(id);
|
|
340
405
|
if (!job) return { ok: false, error: `Job not found: ${id}` };
|
|
341
406
|
|
|
407
|
+
if (job.status === "completed" || job.status === "failed" || job.status === "killed") {
|
|
408
|
+
return { ok: false, error: `Job already terminal (${job.status}) — refuse kill rewrite` };
|
|
409
|
+
}
|
|
410
|
+
|
|
342
411
|
if (job.tmuxSession.startsWith("headless-")) {
|
|
343
412
|
if (job.pid) {
|
|
344
413
|
const ok = terminatePid(job.pid);
|
|
@@ -358,10 +427,15 @@ export function killJob(id: string): { ok: true; job: Job } | { ok: false; error
|
|
|
358
427
|
}
|
|
359
428
|
}
|
|
360
429
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
430
|
+
// Merge-on-write: do not clobber a completion that landed mid-kill
|
|
431
|
+
const latest = readJob(id) || job;
|
|
432
|
+
if (latest.status === "completed" || latest.status === "failed") {
|
|
433
|
+
return { ok: false, error: `Job finished during kill (${latest.status})` };
|
|
434
|
+
}
|
|
435
|
+
latest.status = "killed";
|
|
436
|
+
latest.completedAt = new Date().toISOString();
|
|
437
|
+
writeJob(latest);
|
|
438
|
+
return { ok: true, job: latest };
|
|
365
439
|
}
|
|
366
440
|
|
|
367
441
|
export function cleanJobs(olderThanDays = 7): number {
|
package/src/mark-complete.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Invoked from tmux / headless completion hooks — keep dep-free.
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { redactSecrets } from "./secrets.ts";
|
|
7
8
|
|
|
8
9
|
const jobPath = process.argv[2];
|
|
9
10
|
const exitCode = Number(process.argv[3] ?? "1");
|
|
@@ -24,12 +25,7 @@ try {
|
|
|
24
25
|
try {
|
|
25
26
|
const log = readFileSync(logPath, "utf8");
|
|
26
27
|
job.logBytes = Buffer.byteLength(log);
|
|
27
|
-
|
|
28
|
-
const redacted = log
|
|
29
|
-
.replace(/\bsk-[a-zA-Z0-9]{20,}\b/g, "[REDACTED]")
|
|
30
|
-
.replace(/\bghp_[A-Za-z0-9]{20,}\b/g, "[REDACTED]")
|
|
31
|
-
.replace(/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/gi, "[REDACTED]");
|
|
32
|
-
job.logTail = redacted.slice(-2000);
|
|
28
|
+
job.logTail = redactSecrets(log).slice(-2000);
|
|
33
29
|
} catch {
|
|
34
30
|
/* ignore */
|
|
35
31
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
3
4
|
import { commandExists, shellQuote } from "./util.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* Resolve how to re-invoke
|
|
7
|
-
* Prefer
|
|
7
|
+
* Resolve how to re-invoke the completion hook (mark-complete) from shell hooks.
|
|
8
|
+
* Prefer compiled dist via node (no loader); else Bun on src. No npx/tsx on the
|
|
9
|
+
* happy path — surface a clear failure if neither is available.
|
|
8
10
|
*/
|
|
9
11
|
export function markCompleteInvoker(scriptPath: string): string {
|
|
12
|
+
const compiled = scriptPath.replace(/\.ts$/, ".js");
|
|
13
|
+
if (existsSync(compiled)) {
|
|
14
|
+
return `node ${shellQuote(compiled)}`;
|
|
15
|
+
}
|
|
10
16
|
if (commandExists("bun")) {
|
|
11
17
|
return `bun ${shellQuote(scriptPath)}`;
|
|
12
18
|
}
|
|
13
|
-
|
|
14
|
-
return `npx --yes tsx@4.19.4 ${shellQuote(scriptPath)}`;
|
|
19
|
+
return `sh -c 'echo "cursor-route: completion hook needs a compiled dist or Bun (run bun run build)" >&2; exit 1'`;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
/** XDG-ish default away from git-clone install dir (~/.cursor-route). */
|
package/src/secrets.ts
CHANGED
|
@@ -1,19 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Secret material detectors — match key *contents*, not the phrase "API key".
|
|
3
|
-
* Applied to `start` and `send
|
|
3
|
+
* Applied to `start` and `send`, and reused for log-tail redaction.
|
|
4
4
|
*/
|
|
5
|
-
const
|
|
5
|
+
export const SECRET_PATTERNS: RegExp[] = [
|
|
6
|
+
// OpenAI / DeepSeek legacy + project / Anthropic hyphenated keys
|
|
6
7
|
/\bsk-[a-zA-Z0-9]{20,}\b/,
|
|
8
|
+
/\bsk-(?:proj|ant|oai)-[A-Za-z0-9_-]{16,}\b/,
|
|
9
|
+
/\bsk-[a-zA-Z0-9-]{20,}\b/,
|
|
7
10
|
/\bghp_[A-Za-z0-9]{20,}\b/,
|
|
8
11
|
/\bgho_[A-Za-z0-9]{20,}\b/,
|
|
12
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
|
|
9
13
|
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/i,
|
|
14
|
+
/\bxai-[A-Za-z0-9_-]{20,}\b/,
|
|
10
15
|
/\bAIza[0-9A-Za-z_-]{20,}\b/,
|
|
11
|
-
/-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/,
|
|
16
|
+
/-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/,
|
|
12
17
|
/\bAKIA[0-9A-Z]{16}\b/,
|
|
18
|
+
/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]+/i,
|
|
13
19
|
];
|
|
14
20
|
|
|
15
21
|
export function looksLikeSecretMaterial(text: string): boolean {
|
|
16
|
-
return
|
|
22
|
+
return SECRET_PATTERNS.some((re) => re.test(text));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Redact secret-looking spans for job metadata / log tails. */
|
|
26
|
+
export function redactSecrets(text: string): string {
|
|
27
|
+
let out = text;
|
|
28
|
+
for (const re of SECRET_PATTERNS) {
|
|
29
|
+
// Clone with global flag so we replace all matches
|
|
30
|
+
const flags = re.flags.includes("g") ? re.flags : `${re.flags}g`;
|
|
31
|
+
out = out.replace(new RegExp(re.source, flags), "[REDACTED]");
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
17
34
|
}
|
|
18
35
|
|
|
19
36
|
export const JOB_ID_RE = /^[a-f0-9]{8}$/;
|
package/src/tmux.ts
CHANGED
|
@@ -49,10 +49,14 @@ export function capturePane(name: string, lines = 50): string {
|
|
|
49
49
|
|
|
50
50
|
export function sendKeys(name: string, message: string): boolean {
|
|
51
51
|
if (!sessionExists(name)) return false;
|
|
52
|
+
// Reject embedded newlines — they would submit early even with -l
|
|
53
|
+
if (/[\r\n]/.test(message)) return false;
|
|
52
54
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
// -l = literal keys (so "C-c" types text, does not SIGINT the worker)
|
|
56
|
+
execSync(
|
|
57
|
+
`tmux send-keys -l -t ${shellQuote(name)} -- ${shellQuote(message)}`,
|
|
58
|
+
{ stdio: "ignore" },
|
|
59
|
+
);
|
|
56
60
|
spawnSync("sleep", ["0.25"]);
|
|
57
61
|
execSync(`tmux send-keys -t ${shellQuote(name)} Enter`, { stdio: "ignore" });
|
|
58
62
|
return true;
|
|
@@ -86,6 +90,7 @@ export function createWorkerSession(options: {
|
|
|
86
90
|
logFile: string;
|
|
87
91
|
jobFile: string;
|
|
88
92
|
markCompleteScript: string;
|
|
93
|
+
env?: Record<string, string>;
|
|
89
94
|
}): { ok: true; session: string } | { ok: false; error: string } {
|
|
90
95
|
const name = sessionName(options.jobId);
|
|
91
96
|
const isLinux = process.platform === "linux";
|
|
@@ -107,11 +112,18 @@ export function createWorkerSession(options: {
|
|
|
107
112
|
? `script -q -e -c ${shellQuote(`/bin/sh -c ${shellQuote(options.workerCmd)}`)} ${shellQuote(options.logFile)}; ${completion}`
|
|
108
113
|
: `script -q ${shellQuote(options.logFile)} /bin/sh -c ${shellQuote(options.workerCmd)}; ${completion}`;
|
|
109
114
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
const args = ["new-session", "-d", "-s", name, "-c", options.cwd];
|
|
116
|
+
if (options.env) {
|
|
117
|
+
for (const [k, v] of Object.entries(options.env)) {
|
|
118
|
+
args.push("-e", `${k}=${v}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
args.push(wrapped);
|
|
122
|
+
|
|
123
|
+
const r = spawnSync("tmux", args, {
|
|
124
|
+
encoding: "utf8",
|
|
125
|
+
cwd: options.cwd,
|
|
126
|
+
});
|
|
115
127
|
|
|
116
128
|
if (r.status !== 0) {
|
|
117
129
|
return {
|
package/src/util.ts
CHANGED
|
@@ -13,7 +13,9 @@ export function newJobId(): string {
|
|
|
13
13
|
|
|
14
14
|
export function commandExists(cmd: string): boolean {
|
|
15
15
|
try {
|
|
16
|
-
const r = spawnSync("sh", ["-c", `command -v ${cmd}`], {
|
|
16
|
+
const r = spawnSync("sh", ["-c", `command -v ${shellQuote(cmd)}`], {
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
});
|
|
17
19
|
return r.status === 0 && Boolean(r.stdout?.trim());
|
|
18
20
|
} catch {
|
|
19
21
|
return false;
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
# Cursor audit — cursor-route v0.1.0→0.1.1
|
|
2
|
-
|
|
3
|
-
**Mode:** `code-debug` · **Roles → models:** code-implementation→`gpt-5.6-sol-medium`, adversarial→`cursor-grok-4.5-high-fast`, third-lens→`kimi-k3-max`
|
|
4
|
-
|
|
5
|
-
Auditors: [Sol](fad461d6-9be9-421e-b42e-081cd7888a56) · [Grok](79e70632-1de7-456e-871b-cdb66b7b72f1) · [Kimi](9a0b224f-eacf-4a2f-969c-6854db1d6876)
|
|
6
|
-
|
|
7
|
-
## Verdict rollup (pre-fix)
|
|
8
|
-
|
|
9
|
-
| Model | Verdict |
|
|
10
|
-
|-------|---------|
|
|
11
|
-
| GPTSOL | FAIL |
|
|
12
|
-
| Cursor Grok | FAIL |
|
|
13
|
-
| Kimi 3 | WARN |
|
|
14
|
-
|
|
15
|
-
**Overall (pre-fix):** REWORK — then SHIP-WITH-FIXES after 0.1.1.
|
|
16
|
-
|
|
17
|
-
## Consensus (≥2 auditors)
|
|
18
|
-
|
|
19
|
-
1. Headless `kill` did not kill (no PID) — phantom killed
|
|
20
|
-
2. `refreshStatus` inferred `completed` from missing tmux — lies on `--no-tmux`
|
|
21
|
-
3. Completion hooks hard-coded `bun` vs Node fallback
|
|
22
|
-
4. macOS `script` left `cd && …` unquoted
|
|
23
|
-
5. Stock `claude` silent fallback for “claude-ds”
|
|
24
|
-
6. Secret deny false-positive on prose “API key”; `send` bypass
|
|
25
|
-
7. Missing DeepSeek setup docs / mid-lane dead end for public users
|
|
26
|
-
8. Skill `/route` collision with federation `/route`
|
|
27
|
-
|
|
28
|
-
## Unique
|
|
29
|
-
|
|
30
|
-
- [Sol] Job-id path traversal; dry-run side effects; race before `running` write
|
|
31
|
-
- [Grok] Always-approve + skill contract burns private SIP/verify; Grok sandbox missing
|
|
32
|
-
- [Kimi] No hero.gif; typecheck red; jobsDir collided with clone path; no CI
|
|
33
|
-
|
|
34
|
-
## Conflicts
|
|
35
|
-
|
|
36
|
-
| Topic | Sol | Grok | Kimi | Resolution |
|
|
37
|
-
|-------|-----|------|------|------------|
|
|
38
|
-
| Overall ship | FAIL | FAIL | WARN | Fix criticals then soft-launch; GIF still deferred |
|
|
39
|
-
| Skill name `/route` | — | critical rename | — | Renamed triggers to `/route-orch` |
|
|
40
|
-
|
|
41
|
-
## Fixes shipped in `20b1580` (v0.1.1)
|
|
42
|
-
|
|
43
|
-
- PID-tracked headless spawn + polled terminate on `kill`
|
|
44
|
-
- No phantom `completed` from session absence; exit via mark-complete / failed unknown
|
|
45
|
-
- `markCompleteInvoker` (bun or pinned `tsx@4.19.4`)
|
|
46
|
-
- macOS/Linux `script` always via `/bin/sh -c`
|
|
47
|
-
- Stock Claude only with `CURSOR_ROUTE_ALLOW_STOCK_CLAUDE=1`
|
|
48
|
-
- Material-only secret deny on `start` + `send`
|
|
49
|
-
- XDG jobs dir `~/.local/share/cursor-route/jobs`
|
|
50
|
-
- README DeepSeek setup; skill deconflict; CI workflow; `--version`; typecheck green
|
|
51
|
-
|
|
52
|
-
## Verify after fix
|
|
53
|
-
|
|
54
|
-
- 11/11 tests · `tsc --noEmit` clean
|
|
55
|
-
- Headless claude-ds → `CURSOR_ROUTE_SMOKE_OK2` · `exitCode: 0`
|
|
56
|
-
- Headless `kill` → `status: killed`
|
|
57
|
-
- Prose “API key” dry-run allowed; `sk-…` refused exit 3
|
|
58
|
-
|
|
59
|
-
## Still open (not tweet-blockers for soft launch)
|
|
60
|
-
|
|
61
|
-
- Real tmux attach/send GIF (`docs/DEMO_GIF.md`) — needs `brew install tmux` (sudo)
|
|
62
|
-
- npm publish
|
|
63
|
-
- Grok Build balance for live grok demos
|
|
64
|
-
- Optional: grok `--sandbox`, `wait` command, remove skill file duplicate via symlink
|