pi-cursor-sdk 0.1.31 → 0.1.32
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/CHANGELOG.md +15 -0
- package/docs/crabbox-platform-testing-lessons.md +1 -1
- package/docs/platform-smoke.md +8 -7
- package/package.json +1 -1
- package/platform-smoke.config.mjs +2 -2
- package/scripts/platform-smoke/doctor.mjs +18 -9
- package/src/context-window-cache.ts +23 -7
- package/src/cursor-state.ts +21 -12
- package/src/model-list-cache.ts +74 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.32 - 2026-06-02
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Add a production typing-safety regression test that blocks broad TypeScript escape hatches such as `as unknown as`, `as any`, `as never`, explicit `any`, and production `@ts-ignore` / `@ts-expect-error` usage.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Replace repeated native replay render-test `as never` casts with typed test render fixture helpers.
|
|
14
|
+
- Use the maintained Homebrew Crabbox binary on `PATH` for platform smoke with a `0.24.0` minimum version, keeping `PLATFORM_SMOKE_CRABBOX` as an explicit override only.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Harden local Cursor cache/config JSON parsing so model-list, context-window, and fast-default files are validated from `unknown` before trusted values are used.
|
|
19
|
+
|
|
5
20
|
## 0.1.31 - 2026-06-01
|
|
6
21
|
|
|
7
22
|
### Added
|
|
@@ -154,7 +154,7 @@ PLATFORM_SMOKE_WINDOWS_USER=<windows-ssh-user>
|
|
|
154
154
|
PLATFORM_SMOKE_WINDOWS_WORK_ROOT=C:\crabbox\<project>
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
-
Pin Crabbox deliberately. Exact pins are best for release-critical harnesses whose parsing depends on CLI output. Minimum version checks are fine for simpler gates. Current local baseline is Crabbox `0.24.0
|
|
157
|
+
Pin Crabbox deliberately. Exact pins are best for release-critical harnesses whose parsing depends on CLI output. Minimum version checks are fine for simpler gates. Current local baseline is Crabbox `0.24.0` or newer from the Homebrew package.
|
|
158
158
|
|
|
159
159
|
## Target setup best practices
|
|
160
160
|
|
package/docs/platform-smoke.md
CHANGED
|
@@ -52,12 +52,12 @@ The runner uses one supported Crabbox build.
|
|
|
52
52
|
Current baseline:
|
|
53
53
|
|
|
54
54
|
```text
|
|
55
|
-
install: brew install crabbox
|
|
56
|
-
version: 0.24.0
|
|
57
|
-
binary:
|
|
55
|
+
install: brew install openclaw/tap/crabbox
|
|
56
|
+
version: 0.24.0 or newer
|
|
57
|
+
binary: Homebrew `crabbox` on PATH (`/opt/homebrew/bin/crabbox` on Apple Silicon Homebrew installs)
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
Use the Homebrew Crabbox binary on PATH for normal release gates. `PLATFORM_SMOKE_CRABBOX=/path/to/crabbox` is only an explicit override for testing a non-default binary. `smoke:platform:doctor` verifies the configured binary and fails when it is older than the configured minimum version.
|
|
61
61
|
|
|
62
62
|
Required Crabbox providers:
|
|
63
63
|
|
|
@@ -188,8 +188,8 @@ export default {
|
|
|
188
188
|
"cursor-abort-cleanup",
|
|
189
189
|
],
|
|
190
190
|
requiredCrabbox: {
|
|
191
|
-
install: "
|
|
192
|
-
|
|
191
|
+
install: "Homebrew package or PLATFORM_SMOKE_CRABBOX override",
|
|
192
|
+
minVersion: "0.24.0",
|
|
193
193
|
},
|
|
194
194
|
ubuntuContainerImage: "cimg/node:24.16",
|
|
195
195
|
nodeValidationMajor: 24,
|
|
@@ -203,6 +203,7 @@ export default {
|
|
|
203
203
|
The doctor fails if any required value is missing.
|
|
204
204
|
|
|
205
205
|
```bash
|
|
206
|
+
# Optional override; by default the gate uses Homebrew `crabbox` from PATH.
|
|
206
207
|
PLATFORM_SMOKE_CRABBOX=/opt/homebrew/bin/crabbox
|
|
207
208
|
|
|
208
209
|
PLATFORM_SMOKE_MAC_HOST=localhost
|
|
@@ -313,7 +314,7 @@ tar --version
|
|
|
313
314
|
Doctor checks:
|
|
314
315
|
|
|
315
316
|
1. Required env vars exist.
|
|
316
|
-
2. `PLATFORM_SMOKE_CRABBOX`
|
|
317
|
+
2. Homebrew `crabbox` is available on PATH, or `PLATFORM_SMOKE_CRABBOX` points at an executable override.
|
|
317
318
|
3. Crabbox build matches the configured baseline.
|
|
318
319
|
4. Crabbox provider registry includes `local-container`, `ssh`, and `parallels`.
|
|
319
320
|
5. `crabbox doctor --provider local-container --json` passes.
|
package/package.json
CHANGED
|
@@ -13,8 +13,8 @@ export default {
|
|
|
13
13
|
"cursor-abort-cleanup",
|
|
14
14
|
],
|
|
15
15
|
requiredCrabbox: {
|
|
16
|
-
install: "
|
|
17
|
-
|
|
16
|
+
install: "Homebrew package or PLATFORM_SMOKE_CRABBOX override",
|
|
17
|
+
minVersion: "0.24.0",
|
|
18
18
|
},
|
|
19
19
|
ubuntuContainerImage: "cimg/node:24.16",
|
|
20
20
|
nodeValidationMajor: 24,
|
|
@@ -45,6 +45,10 @@ function shell(cmd, opts = {}) {
|
|
|
45
45
|
catch { return null; }
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function commandPath(command) {
|
|
49
|
+
return shell(`command -v ${command}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
48
52
|
function parseLeaseId(output) {
|
|
49
53
|
return output.match(/\bleased\s+(\S+)/)?.[1]
|
|
50
54
|
?? output.match(/\blease=(\S+)/)?.[1]
|
|
@@ -126,7 +130,6 @@ function runChecks(config) {
|
|
|
126
130
|
// ── Phase 1: environment variables ──
|
|
127
131
|
console.log("\n── Environment variables ──");
|
|
128
132
|
const requiredVars = [
|
|
129
|
-
"PLATFORM_SMOKE_CRABBOX",
|
|
130
133
|
"CURSOR_API_KEY",
|
|
131
134
|
"PLATFORM_SMOKE_WINDOWS_VM",
|
|
132
135
|
"PLATFORM_SMOKE_WINDOWS_SNAPSHOT",
|
|
@@ -134,6 +137,7 @@ function runChecks(config) {
|
|
|
134
137
|
"PLATFORM_SMOKE_WINDOWS_NATIVE_WORK_ROOT",
|
|
135
138
|
];
|
|
136
139
|
const optionalVars = [
|
|
140
|
+
"PLATFORM_SMOKE_CRABBOX",
|
|
137
141
|
"PLATFORM_SMOKE_MAC_HOST",
|
|
138
142
|
"PLATFORM_SMOKE_MAC_USER",
|
|
139
143
|
"PLATFORM_SMOKE_MAC_WORK_ROOT",
|
|
@@ -151,12 +155,17 @@ function runChecks(config) {
|
|
|
151
155
|
|
|
152
156
|
// ── Phase 2: Crabbox binary ──
|
|
153
157
|
console.log("\n── Crabbox binary ──");
|
|
154
|
-
const cbox = env("PLATFORM_SMOKE_CRABBOX");
|
|
155
|
-
|
|
156
|
-
|
|
158
|
+
const cbox = env("PLATFORM_SMOKE_CRABBOX") || "crabbox";
|
|
159
|
+
const cboxPath = env("PLATFORM_SMOKE_CRABBOX") || commandPath("crabbox");
|
|
160
|
+
if (!cboxPath) {
|
|
161
|
+
fail(`crabbox not found on PATH; install with ${config.requiredCrabbox?.install ?? "Homebrew"} or set PLATFORM_SMOKE_CRABBOX`);
|
|
157
162
|
} else {
|
|
158
|
-
|
|
159
|
-
|
|
163
|
+
if (env("PLATFORM_SMOKE_CRABBOX")) {
|
|
164
|
+
try { accessSync(cboxPath, constants.X_OK); ok(`binary: ${cboxPath} (env override)`); }
|
|
165
|
+
catch { fail(`${cboxPath} not executable`); }
|
|
166
|
+
} else {
|
|
167
|
+
ok(`binary: ${cboxPath} (PATH)`);
|
|
168
|
+
}
|
|
160
169
|
const ver = silent(cbox, ["--version"]);
|
|
161
170
|
const actualVersion = ver?.split("\n")[0]?.trim();
|
|
162
171
|
if (actualVersion) ok(`version: ${actualVersion}`);
|
|
@@ -174,9 +183,9 @@ function runChecks(config) {
|
|
|
174
183
|
}
|
|
175
184
|
const requiredCommit = config.requiredCrabbox?.commit;
|
|
176
185
|
if (!requiredVersion && !minimumVersion && requiredCommit) {
|
|
177
|
-
const gitRoot = findGitRoot(dirname(
|
|
186
|
+
const gitRoot = findGitRoot(dirname(cboxPath));
|
|
178
187
|
const actualCommit = gitRoot ? silent("git", ["-C", gitRoot, "rev-parse", "HEAD"]) : null;
|
|
179
|
-
if (!actualCommit) fail(`could not verify Crabbox source commit for ${
|
|
188
|
+
if (!actualCommit) fail(`could not verify Crabbox source commit for ${cboxPath}`);
|
|
180
189
|
else if (actualCommit !== requiredCommit) fail(`Crabbox commit mismatch: expected ${requiredCommit}, got ${actualCommit}`);
|
|
181
190
|
else ok(`commit: ${actualCommit}`);
|
|
182
191
|
}
|
|
@@ -184,7 +193,7 @@ function runChecks(config) {
|
|
|
184
193
|
|
|
185
194
|
// ── Phase 3: Crabbox providers ──
|
|
186
195
|
console.log("\n── Crabbox providers ──");
|
|
187
|
-
if (
|
|
196
|
+
if (cboxPath) {
|
|
188
197
|
const providerList = silent(cbox, ["providers"]);
|
|
189
198
|
if (providerList) {
|
|
190
199
|
for (const provider of ["ssh", "local-container", "parallels"]) {
|
|
@@ -18,15 +18,31 @@ function isPositiveInteger(value: unknown): value is number {
|
|
|
18
18
|
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
22
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseContextWindowCacheFile(value: unknown): ContextWindowCacheFile | undefined {
|
|
26
|
+
if (!isRecord(value)) return undefined;
|
|
27
|
+
const { contextWindows } = value;
|
|
28
|
+
if (contextWindows === undefined) return {};
|
|
29
|
+
if (!isRecord(contextWindows)) return undefined;
|
|
30
|
+
return {
|
|
31
|
+
contextWindows: Object.fromEntries(
|
|
32
|
+
Object.entries(contextWindows).filter((entry): entry is [string, number] => isPositiveInteger(entry[1])),
|
|
33
|
+
),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
function loadUserContextWindowOverrides(): Map<string, number> {
|
|
22
38
|
userContextWindowOverrideLoadCount += 1;
|
|
23
39
|
const path = getCachePath();
|
|
24
40
|
const overrides = new Map<string, number>();
|
|
25
41
|
if (!existsSync(path)) return overrides;
|
|
26
42
|
try {
|
|
27
|
-
const parsed = JSON.parse(readFileSync(path, "utf-8"))
|
|
28
|
-
for (const [modelId, contextWindow] of Object.entries(parsed
|
|
29
|
-
|
|
43
|
+
const parsed = parseContextWindowCacheFile(JSON.parse(readFileSync(path, "utf-8")));
|
|
44
|
+
for (const [modelId, contextWindow] of Object.entries(parsed?.contextWindows ?? {})) {
|
|
45
|
+
overrides.set(modelId, contextWindow);
|
|
30
46
|
}
|
|
31
47
|
} catch {
|
|
32
48
|
return overrides;
|
|
@@ -52,10 +68,10 @@ export function getCachedContextWindow(modelId: string): number | undefined {
|
|
|
52
68
|
}
|
|
53
69
|
|
|
54
70
|
export function getCheckpointContextWindow(checkpoint: unknown): number | undefined {
|
|
55
|
-
if (checkpoint
|
|
56
|
-
const tokenDetails =
|
|
57
|
-
if (tokenDetails
|
|
58
|
-
const maxTokens =
|
|
71
|
+
if (!isRecord(checkpoint)) return undefined;
|
|
72
|
+
const { tokenDetails } = checkpoint;
|
|
73
|
+
if (!isRecord(tokenDetails)) return undefined;
|
|
74
|
+
const { maxTokens } = tokenDetails;
|
|
59
75
|
if (!isPositiveInteger(maxTokens)) return undefined;
|
|
60
76
|
return maxTokens;
|
|
61
77
|
}
|
package/src/cursor-state.ts
CHANGED
|
@@ -69,10 +69,13 @@ export function parseCursorAgentMode(raw: unknown): AgentModeOption | undefined
|
|
|
69
69
|
return isCursorAgentMode(mode) ? mode : undefined;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
73
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
72
76
|
function isCursorFastEntryData(value: unknown): value is CursorFastEntryData {
|
|
73
|
-
if (!value
|
|
74
|
-
|
|
75
|
-
return (typeof data.modelId === "string" || typeof data.baseModelId === "string") && typeof data.fast === "boolean";
|
|
77
|
+
if (!isRecord(value)) return false;
|
|
78
|
+
return (typeof value.modelId === "string" || typeof value.baseModelId === "string") && typeof value.fast === "boolean";
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
function getCursorFastEntryModelId(data: CursorFastEntryData): string {
|
|
@@ -80,9 +83,19 @@ function getCursorFastEntryModelId(data: CursorFastEntryData): string {
|
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
function isCursorModeEntryData(value: unknown): value is CursorModeEntryData {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
return isRecord(value) && isCursorAgentMode(value.mode);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseCursorGlobalConfig(value: unknown): CursorGlobalConfig | undefined {
|
|
90
|
+
if (!isRecord(value)) return undefined;
|
|
91
|
+
const { fastDefaults } = value;
|
|
92
|
+
if (fastDefaults === undefined) return {};
|
|
93
|
+
if (!isRecord(fastDefaults)) return undefined;
|
|
94
|
+
return {
|
|
95
|
+
fastDefaults: Object.fromEntries(
|
|
96
|
+
Object.entries(fastDefaults).filter((entry): entry is [string, boolean] => typeof entry[1] === "boolean"),
|
|
97
|
+
),
|
|
98
|
+
};
|
|
86
99
|
}
|
|
87
100
|
|
|
88
101
|
function getConfigPath(): string {
|
|
@@ -93,12 +106,8 @@ function loadGlobalFastPreferences(): Map<string, boolean> {
|
|
|
93
106
|
const path = getConfigPath();
|
|
94
107
|
if (!existsSync(path)) return new Map();
|
|
95
108
|
try {
|
|
96
|
-
const parsed = JSON.parse(readFileSync(path, "utf-8"))
|
|
97
|
-
return new Map(
|
|
98
|
-
Object.entries(parsed.fastDefaults ?? {}).filter(
|
|
99
|
-
(entry): entry is [string, boolean] => typeof entry[1] === "boolean",
|
|
100
|
-
),
|
|
101
|
-
);
|
|
109
|
+
const parsed = parseCursorGlobalConfig(JSON.parse(readFileSync(path, "utf-8")));
|
|
110
|
+
return new Map(Object.entries(parsed?.fastDefaults ?? {}));
|
|
102
111
|
} catch {
|
|
103
112
|
return new Map();
|
|
104
113
|
}
|
package/src/model-list-cache.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { parseEnvBoolean } from "./cursor-env-boolean.js";
|
|
|
8
8
|
const MODEL_LIST_CACHE_FILE = "cursor-sdk-model-list.json";
|
|
9
9
|
const MODEL_LIST_CACHE_VERSION = 1;
|
|
10
10
|
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
const MAX_CACHE_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
11
12
|
const DISABLE_ENV_VAR = "PI_CURSOR_SDK_DISABLE_MODEL_CACHE";
|
|
12
13
|
const TTL_ENV_VAR = "PI_CURSOR_SDK_MODEL_CACHE_TTL_MS";
|
|
13
14
|
|
|
@@ -45,20 +46,83 @@ export function fingerprintApiKey(apiKey: string): string {
|
|
|
45
46
|
return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
50
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isStringArray(value: unknown): value is string[] {
|
|
54
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isModelParameterValue(value: unknown): value is NonNullable<ModelListItem["variants"]>[number]["params"][number] {
|
|
58
|
+
return isRecord(value) && typeof value.id === "string" && typeof value.value === "string";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isModelParameterDefinitionValue(value: unknown): value is NonNullable<ModelListItem["parameters"]>[number]["values"][number] {
|
|
62
|
+
return isRecord(value) && typeof value.value === "string" && (value.displayName === undefined || typeof value.displayName === "string");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isModelParameterDefinition(value: unknown): value is NonNullable<ModelListItem["parameters"]>[number] {
|
|
66
|
+
if (!isRecord(value)) return false;
|
|
67
|
+
return (
|
|
68
|
+
typeof value.id === "string" &&
|
|
69
|
+
(value.displayName === undefined || typeof value.displayName === "string") &&
|
|
70
|
+
Array.isArray(value.values) &&
|
|
71
|
+
value.values.every(isModelParameterDefinitionValue)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isModelVariant(value: unknown): value is NonNullable<ModelListItem["variants"]>[number] {
|
|
76
|
+
if (!isRecord(value)) return false;
|
|
77
|
+
return (
|
|
78
|
+
Array.isArray(value.params) &&
|
|
79
|
+
value.params.every(isModelParameterValue) &&
|
|
80
|
+
typeof value.displayName === "string" &&
|
|
81
|
+
(value.description === undefined || typeof value.description === "string") &&
|
|
82
|
+
(value.isDefault === undefined || typeof value.isDefault === "boolean")
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isModelListItem(value: unknown): value is ModelListItem {
|
|
87
|
+
if (!isRecord(value)) return false;
|
|
88
|
+
return (
|
|
89
|
+
typeof value.id === "string" &&
|
|
90
|
+
typeof value.displayName === "string" &&
|
|
91
|
+
(value.description === undefined || typeof value.description === "string") &&
|
|
92
|
+
(value.aliases === undefined || isStringArray(value.aliases)) &&
|
|
93
|
+
(value.parameters === undefined || (Array.isArray(value.parameters) && value.parameters.every(isModelParameterDefinition))) &&
|
|
94
|
+
(value.variants === undefined || (Array.isArray(value.variants) && value.variants.every(isModelVariant)))
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isValidFetchedAt(value: unknown): value is number {
|
|
99
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= Date.now() + MAX_CACHE_CLOCK_SKEW_MS;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseModelListCacheFile(value: unknown): ModelListCacheFile | undefined {
|
|
103
|
+
if (!isRecord(value)) return undefined;
|
|
104
|
+
if (
|
|
105
|
+
value.version !== MODEL_LIST_CACHE_VERSION ||
|
|
106
|
+
!isValidFetchedAt(value.fetchedAt) ||
|
|
107
|
+
typeof value.keyFingerprint !== "string" ||
|
|
108
|
+
!Array.isArray(value.models) ||
|
|
109
|
+
!value.models.every(isModelListItem)
|
|
110
|
+
) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
version: value.version,
|
|
115
|
+
fetchedAt: value.fetchedAt,
|
|
116
|
+
keyFingerprint: value.keyFingerprint,
|
|
117
|
+
models: value.models,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
48
121
|
function readCacheFile(): ModelListCacheFile | undefined {
|
|
49
122
|
const path = getCachePath();
|
|
50
123
|
if (!existsSync(path)) return undefined;
|
|
51
124
|
try {
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
parsed.version !== MODEL_LIST_CACHE_VERSION ||
|
|
55
|
-
typeof parsed.fetchedAt !== "number" ||
|
|
56
|
-
typeof parsed.keyFingerprint !== "string" ||
|
|
57
|
-
!Array.isArray(parsed.models)
|
|
58
|
-
) {
|
|
59
|
-
return undefined;
|
|
60
|
-
}
|
|
61
|
-
return parsed;
|
|
125
|
+
return parseModelListCacheFile(JSON.parse(readFileSync(path, "utf-8")));
|
|
62
126
|
} catch {
|
|
63
127
|
return undefined;
|
|
64
128
|
}
|