impel-cli 0.18.15-beta.9 → 0.18.15
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 +0 -49
- package/package.json +1 -2
- package/src/agents.js +173 -8
- package/src/apps.js +43 -211
- package/src/commands/apps.js +8 -43
- package/src/commands/converge.js +3 -18
- package/src/commands/experimental.js +2 -8
- package/src/commands/launch.js +2 -11
- package/src/commands/status.js +2 -3
- package/src/commands/update.js +11 -23
- package/src/macSetup.js +38 -20
- package/src/provisioning.js +4 -10
- package/src/skills.js +8 -24
- package/src/updates.js +10 -57
- package/src/windowsApps.js +17 -61
- package/src/windowsSetup.js +4 -7
- package/docs/experimental-managed-cursor.md +0 -205
- package/src/commands/cursorExperimental.js +0 -192
- package/src/cursorLocal.js +0 -1554
- package/src/vendorCliBinaries.js +0 -121
- package/src/vendorCliVersions.js +0 -7
package/src/windowsApps.js
CHANGED
|
@@ -11,10 +11,9 @@ import { environmentValue, nativeCommandInvocation } from "./nativeProcess.js";
|
|
|
11
11
|
import { normalizeTenantId } from "./tenants.js";
|
|
12
12
|
import { brandedEnvironmentName, RUNTIME_BRAND } from "./runtimeBrand.js";
|
|
13
13
|
|
|
14
|
-
const
|
|
15
|
-
export const
|
|
16
|
-
export const
|
|
17
|
-
export const WINDOWS_CHATGPT_PUBLISHER_ID = WINDOWS_CHATGPT_PIN.publisherId;
|
|
14
|
+
export const WINDOWS_CHATGPT_PACKAGE = "9PLM9XGG6VKS";
|
|
15
|
+
export const WINDOWS_CHATGPT_PACKAGE_NAME = "OpenAI.Codex";
|
|
16
|
+
export const WINDOWS_CHATGPT_PUBLISHER_ID = "2p2nqsd0c76g0";
|
|
18
17
|
const WINDOWS_CLAUDE_DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000;
|
|
19
18
|
const WINDOWS_WINGET_UPDATE_NOT_APPLICABLE = 0x8A15002B;
|
|
20
19
|
|
|
@@ -61,26 +60,20 @@ function versionedSquirrelCandidates(root, readDirectory = fs.readdirSync) {
|
|
|
61
60
|
}
|
|
62
61
|
}
|
|
63
62
|
|
|
64
|
-
function installedMsixChatGPT(environment, run = spawnSync
|
|
63
|
+
function installedMsixChatGPT(environment, run = spawnSync) {
|
|
65
64
|
const script = [
|
|
66
65
|
`$package = Get-AppxPackage -Name '${WINDOWS_CHATGPT_PACKAGE_NAME}' -ErrorAction SilentlyContinue`,
|
|
67
|
-
`$package = $package | Where-Object
|
|
66
|
+
`$package = $package | Where-Object PublisherId -eq '${WINDOWS_CHATGPT_PUBLISHER_ID}' | Sort-Object Version -Descending | Select-Object -First 1`,
|
|
68
67
|
"if ($package) {",
|
|
69
68
|
" $manifest = Get-AppxPackageManifest -Package $package",
|
|
70
69
|
" $relative = $manifest.Package.Applications.Application | Where-Object Id -eq 'App' | Select-Object -First 1 -ExpandProperty Executable",
|
|
71
|
-
"
|
|
72
|
-
" $expected = ([string]$env:IMPEL_CHATGPT_PACKAGE_EXECUTABLE).Replace('/', '\\')",
|
|
73
|
-
" if ($relative -and (-not $expected -or $normalized -ieq $expected)) { Join-Path $package.InstallLocation $relative }",
|
|
70
|
+
" if ($relative) { Join-Path $package.InstallLocation $relative }",
|
|
74
71
|
"}",
|
|
75
72
|
].join("; ");
|
|
76
73
|
try {
|
|
77
74
|
const result = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
78
75
|
encoding: "utf8",
|
|
79
|
-
env:
|
|
80
|
-
...windowsPowerShellEnvironment(environment),
|
|
81
|
-
IMPEL_CHATGPT_PACKAGE_VERSION: pin?.packageVersion || "",
|
|
82
|
-
IMPEL_CHATGPT_PACKAGE_EXECUTABLE: pin?.executable || "",
|
|
83
|
-
},
|
|
76
|
+
env: windowsPowerShellEnvironment(environment),
|
|
84
77
|
stdio: ["ignore", "pipe", "ignore"],
|
|
85
78
|
windowsHide: true,
|
|
86
79
|
});
|
|
@@ -148,23 +141,9 @@ export function findWindowsChatGPTApp(environment = process.env, dependencies =
|
|
|
148
141
|
return msix && exists(msix) ? path.win32.normalize(msix) : null;
|
|
149
142
|
}
|
|
150
143
|
|
|
151
|
-
function
|
|
152
|
-
const exists = dependencies.isFile || isFile;
|
|
153
|
-
const pin = dependencies.pin || WINDOWS_CHATGPT_PIN;
|
|
154
|
-
const msix = (dependencies.queryMsix || installedMsixChatGPT)(environment, dependencies.run, pin);
|
|
155
|
-
return msix && exists(msix) ? path.win32.normalize(msix) : null;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function wingetInvocation(action, environment, {
|
|
159
|
-
packageId,
|
|
160
|
-
source = "winget",
|
|
161
|
-
scope = "user",
|
|
162
|
-
}) {
|
|
144
|
+
function wingetInvocation(action, environment, { packageId, source = "winget", scope = "user" }) {
|
|
163
145
|
const verb = action === "update" ? "upgrade" : "install";
|
|
164
146
|
const scopeArgs = scope ? ["--scope", scope] : [];
|
|
165
|
-
// Microsoft Store packages commonly report Version "Unknown" to winget.
|
|
166
|
-
// Without this switch winget refuses to consider their available update.
|
|
167
|
-
const updateArgs = action === "update" ? ["--include-unknown"] : [];
|
|
168
147
|
return nativeCommandInvocation(
|
|
169
148
|
"winget",
|
|
170
149
|
[
|
|
@@ -175,7 +154,6 @@ function wingetInvocation(action, environment, {
|
|
|
175
154
|
"--source",
|
|
176
155
|
source,
|
|
177
156
|
...scopeArgs,
|
|
178
|
-
...updateArgs,
|
|
179
157
|
"--silent",
|
|
180
158
|
"--disable-interactivity",
|
|
181
159
|
"--accept-package-agreements",
|
|
@@ -394,31 +372,21 @@ export async function ensureWindowsClaudeApp(_options = {}, dependencies = {}) {
|
|
|
394
372
|
|
|
395
373
|
/** Install/update OpenAI's official ChatGPT/Codex Store package. */
|
|
396
374
|
export function ensureWindowsChatGPTApp({ update = false } = {}, dependencies = {}) {
|
|
397
|
-
const find = dependencies.find || findWindowsChatGPTApp;
|
|
398
|
-
const findPinned = dependencies.findPinned || dependencies.find || pinnedWindowsChatGPTApp;
|
|
399
375
|
const io = {
|
|
400
376
|
environment: process.env,
|
|
401
|
-
find,
|
|
402
|
-
findPinned,
|
|
377
|
+
find: findWindowsChatGPTApp,
|
|
403
378
|
logger: console,
|
|
404
|
-
pin: WINDOWS_CHATGPT_PIN,
|
|
405
379
|
run: spawnSync,
|
|
406
380
|
...dependencies,
|
|
407
381
|
};
|
|
408
382
|
const before = io.find(io.environment);
|
|
409
|
-
|
|
410
|
-
if (pinnedBefore && !update) {
|
|
411
|
-
return { binary: pinnedBefore, action: "existing", result: null };
|
|
412
|
-
}
|
|
383
|
+
if (before && !update) return { binary: before, action: "existing", result: null };
|
|
413
384
|
|
|
414
385
|
// `impel update` also repairs missing surfaces. A missing Store package must
|
|
415
386
|
// be installed, not sent to `winget upgrade` (which returns 0x8A150014).
|
|
416
387
|
const shouldUpdate = Boolean(update && before);
|
|
417
|
-
// The msstore source rejects its AppX package version as a winget --version
|
|
418
|
-
// selector. Install the current Store package, then fail closed below unless
|
|
419
|
-
// Windows registered the exact reviewed package version and executable.
|
|
420
388
|
const invocation = wingetInvocation(shouldUpdate ? "update" : "install", io.environment, {
|
|
421
|
-
packageId:
|
|
389
|
+
packageId: WINDOWS_CHATGPT_PACKAGE,
|
|
422
390
|
source: "msstore",
|
|
423
391
|
scope: null,
|
|
424
392
|
});
|
|
@@ -432,36 +400,26 @@ export function ensureWindowsChatGPTApp({ update = false } = {}, dependencies =
|
|
|
432
400
|
} catch (error) {
|
|
433
401
|
result = { status: null, error };
|
|
434
402
|
}
|
|
435
|
-
|
|
403
|
+
const binary = io.find(io.environment) || before;
|
|
436
404
|
// winget uses an error HRESULT when `upgrade` finds an installed package
|
|
437
405
|
// with no newer Store release. That is a healthy idempotent update result,
|
|
438
406
|
// not an installation failure (APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE).
|
|
439
407
|
const alreadyUpToDate = Boolean(
|
|
440
408
|
shouldUpdate
|
|
441
|
-
&&
|
|
409
|
+
&& binary
|
|
442
410
|
&& !result?.error
|
|
443
411
|
&& Number.isInteger(result?.status)
|
|
444
412
|
&& (result.status >>> 0) === WINDOWS_WINGET_UPDATE_NOT_APPLICABLE,
|
|
445
413
|
);
|
|
446
414
|
if (alreadyUpToDate) {
|
|
447
|
-
io.logger.log(
|
|
448
|
-
}
|
|
449
|
-
const commandSucceeded = (result?.status === 0 && !result?.error) || alreadyUpToDate;
|
|
450
|
-
if (commandSucceeded && !resultBinary) {
|
|
451
|
-
result = {
|
|
452
|
-
...result,
|
|
453
|
-
error: new Error(
|
|
454
|
-
`ChatGPT/Codex Store package ${io.pin.packageVersion} did not appear after winget completed`,
|
|
455
|
-
),
|
|
456
|
-
};
|
|
415
|
+
io.logger.log("ChatGPT/Codex: Microsoft Store package already up to date.");
|
|
457
416
|
}
|
|
458
|
-
const succeeded =
|
|
459
|
-
if (!succeeded) resultBinary = null;
|
|
417
|
+
const succeeded = (result?.status === 0 && !result?.error) || alreadyUpToDate;
|
|
460
418
|
return {
|
|
461
|
-
binary
|
|
419
|
+
binary,
|
|
462
420
|
action: alreadyUpToDate
|
|
463
421
|
? "existing"
|
|
464
|
-
: succeeded ? (shouldUpdate ? "updated" : "installed") : "install-failed",
|
|
422
|
+
: succeeded ? (shouldUpdate ? "updated" : "installed") : (before ? "existing" : "install-failed"),
|
|
465
423
|
result,
|
|
466
424
|
};
|
|
467
425
|
}
|
|
@@ -659,7 +617,6 @@ export function launchWindowsChatGPTApp(binary, {
|
|
|
659
617
|
"CODEX_AUTHAPI_BASE_URL",
|
|
660
618
|
"CODEX_ELECTRON_USER_DATA_PATH",
|
|
661
619
|
"CODEX_HOME",
|
|
662
|
-
"CODEX_SPARKLE_ENABLED",
|
|
663
620
|
"OPENAI_API_KEY",
|
|
664
621
|
"OPENAI_BASE_URL",
|
|
665
622
|
]);
|
|
@@ -668,7 +625,6 @@ export function launchWindowsChatGPTApp(binary, {
|
|
|
668
625
|
}
|
|
669
626
|
environment.CODEX_HOME = codexHome;
|
|
670
627
|
environment.CODEX_AUTHAPI_BASE_URL = `${gatewayUrl}/chatgpt_passthrough/backend-api`;
|
|
671
|
-
environment.CODEX_SPARKLE_ENABLED = "false";
|
|
672
628
|
// Codex Desktop resolves this before acquiring Electron's single-instance
|
|
673
629
|
// lock. Keep --user-data-dir below as a compatibility fallback for builds
|
|
674
630
|
// that predate the dedicated desktop override.
|
package/src/windowsSetup.js
CHANGED
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
nativeSpawnInvocation,
|
|
9
9
|
} from "./nativeProcess.js";
|
|
10
10
|
import { syncSkillsSafe } from "./skills.js";
|
|
11
|
-
import { PINNED_VENDOR_CLI_VERSIONS } from "./vendorCliVersions.js";
|
|
12
11
|
import { provisionWindowsGit } from "./windowsGit.js";
|
|
13
12
|
|
|
14
13
|
const POWERSHELL_ARGS = [
|
|
@@ -28,14 +27,12 @@ const POWERSHELL_ARGS = [
|
|
|
28
27
|
*/
|
|
29
28
|
export const WINDOWS_CLI_INSTALLERS = Object.freeze({
|
|
30
29
|
claude: Object.freeze({
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
script: `& ([scriptblock]::Create((Invoke-RestMethod -Uri 'https://claude.ai/install.ps1'))) '${PINNED_VENDOR_CLI_VERSIONS.claude}'`,
|
|
30
|
+
command: "powershell -ExecutionPolicy Bypass -NoProfile -Command \"irm https://claude.ai/install.ps1 | iex\"",
|
|
31
|
+
script: "Invoke-RestMethod -Uri 'https://claude.ai/install.ps1' | Invoke-Expression",
|
|
34
32
|
}),
|
|
35
33
|
codex: Object.freeze({
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
script: `[Environment]::SetEnvironmentVariable('CODEX_NON_INTERACTIVE', '1', 'Process'); & ([scriptblock]::Create((Invoke-RestMethod -Uri 'https://chatgpt.com/codex/install.ps1'))) -Release '${PINNED_VENDOR_CLI_VERSIONS.codex}'`,
|
|
34
|
+
command: "powershell -ExecutionPolicy Bypass -NoProfile -Command \"$env:CODEX_NON_INTERACTIVE='1'; irm https://chatgpt.com/codex/install.ps1 | iex\"",
|
|
35
|
+
script: "$env:CODEX_NON_INTERACTIVE = '1'; Invoke-RestMethod -Uri 'https://chatgpt.com/codex/install.ps1' | Invoke-Expression",
|
|
39
36
|
}),
|
|
40
37
|
});
|
|
41
38
|
|
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
# Experimental managed Cursor
|
|
2
|
-
|
|
3
|
-
This hidden macOS experiment runs the stable Cursor desktop Agent against the
|
|
4
|
-
selected Impel tenant without a real Cursor account or Cursor-routed BYOK. It is
|
|
5
|
-
validated against Cursor 3.13.25 and remains separate from the public
|
|
6
|
-
`impel setup` / `impel update` state machine:
|
|
7
|
-
|
|
8
|
-
```sh
|
|
9
|
-
impel experimental cursor prepare
|
|
10
|
-
impel experimental cursor status
|
|
11
|
-
impel experimental cursor open [workspace-or-Cursor-args...]
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
## Why standard BYOK is insufficient
|
|
15
|
-
|
|
16
|
-
Cursor's supported BYOK mode still sends provider keys and requests through
|
|
17
|
-
Cursor's backend, applies only to chat models, and keeps Tab on Cursor-hosted
|
|
18
|
-
models. It cannot provide direct, tenant-scoped Impel inference.
|
|
19
|
-
|
|
20
|
-
The stable desktop bundle also contains a dormant local-agent runtime with
|
|
21
|
-
OpenAI Responses, Chat Completions, Anthropic Messages, `/models` discovery,
|
|
22
|
-
tools, streaming, reasoning, vision, and MCP support. Impel enables only the
|
|
23
|
-
exact reviewed local-mode signatures and locks provider resolution to its
|
|
24
|
-
process environment.
|
|
25
|
-
|
|
26
|
-
## Managed authentication
|
|
27
|
-
|
|
28
|
-
Cursor's packaged Agent UI considers a user logged in only when both an access
|
|
29
|
-
token and refresh token exist. The same stable bundle contains an unconditional
|
|
30
|
-
test command that creates:
|
|
31
|
-
|
|
32
|
-
- an unsigned JWT with issuer `cursor-smoke-test`, audience `cursor`, and
|
|
33
|
-
scope `smoke-test`;
|
|
34
|
-
- the literal refresh token `fake-refresh-token-for-testing`.
|
|
35
|
-
|
|
36
|
-
Impel reproduces that vendor-owned, non-secret identity in the tenant-only
|
|
37
|
-
`state.vscdb` and also passes the JWT through Cursor's
|
|
38
|
-
`--override-cursor-auth-token` process flag. It never asks for, reads, or
|
|
39
|
-
stores a real Cursor credential. The source markers, storage keys, JWT contract,
|
|
40
|
-
and command-line flags are part of the fail-closed vendor compatibility check.
|
|
41
|
-
|
|
42
|
-
The fake identity unlocks the local Agent UI. Cursor-hosted account, model,
|
|
43
|
-
analytics, and hosted-feature requests remain unauthenticated and cannot use
|
|
44
|
-
the fake token for service access. The Impel PAT-derived tenant credential is
|
|
45
|
-
given only to the bundled local provider:
|
|
46
|
-
|
|
47
|
-
```text
|
|
48
|
-
CURSOR_LOCAL_AGENT_BASE_URL=<gateway>/experimental/openai/v1
|
|
49
|
-
CURSOR_LOCAL_AGENT_API_KEY=<tenant-scoped Impel credential>
|
|
50
|
-
IMPEL_TENANT_ID=<tenant>
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
## Update and isolation contract
|
|
54
|
-
|
|
55
|
-
For every prepare or open, Impel:
|
|
56
|
-
|
|
57
|
-
1. Finds the installed stable `Cursor.app`.
|
|
58
|
-
2. Verifies its complete Apple signature, Cursor bundle identifier, and vendor
|
|
59
|
-
Developer ID team.
|
|
60
|
-
3. Reads the exact version and commit from `product.json`.
|
|
61
|
-
4. Verifies every reviewed local-mode, authentication, provider-priority,
|
|
62
|
-
model-transport, and profile-import source contract.
|
|
63
|
-
5. Verifies Cursor's extension-integrity table, then updates exactly the hashes
|
|
64
|
-
affected by the reviewed fixed-width patches.
|
|
65
|
-
6. Recomputes Cursor's `product.json` checksum table after every reviewed
|
|
66
|
-
managed patch, so Cursor's own installation-integrity service verifies the
|
|
67
|
-
resulting managed bundle.
|
|
68
|
-
7. Assigns a tenant-specific packaged application name and uses tenant-only
|
|
69
|
-
`HOME`, XDG, user-data, and extension roots. This isolates Cursor's
|
|
70
|
-
hardcoded `~/.cursor` agents, rules, plugins, worktrees, and logs in addition
|
|
71
|
-
to the normal VS Code profile state.
|
|
72
|
-
8. Disables local-mode personal-data import so the managed app cannot inspect,
|
|
73
|
-
copy, or relaunch from the native Cursor profile.
|
|
74
|
-
9. Clears inherited OpenAI, Anthropic, Google, AWS/Bedrock, Azure OpenAI,
|
|
75
|
-
Cursor, Node, Electron, and portable-profile overrides.
|
|
76
|
-
10. Uses Cursor's built-in process-lifetime secret store. The managed runtime
|
|
77
|
-
has no real Cursor login or persistent provider key, while its gateway
|
|
78
|
-
bearer remains process-scoped in the launch environment.
|
|
79
|
-
11. Re-signs every Electron process bundle with the reviewed entitlements and
|
|
80
|
-
verifies the complete managed copy.
|
|
81
|
-
12. Atomically replaces the tenant generation only after every check passes.
|
|
82
|
-
|
|
83
|
-
Tenant state lives below:
|
|
84
|
-
|
|
85
|
-
```text
|
|
86
|
-
~/.config/impel/apps/tenants/<tenant>/cursor/
|
|
87
|
-
runtime/current/Cursor.app
|
|
88
|
-
runtime/current/manifest.json
|
|
89
|
-
home/
|
|
90
|
-
user-data/
|
|
91
|
-
extensions/
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
The launcher rejects user-supplied `--user-data-dir`, `--extensions-dir`,
|
|
95
|
-
and `--override-cursor-auth-token`. The native Cursor profile and the
|
|
96
|
-
operator's `~/.cursor`, `~/.claude`, and `~/.codex` roots are never read or
|
|
97
|
-
modified. The isolated `HOME` means shell, Git, and extension state that
|
|
98
|
-
normally lives in a home directory must be configured separately for this
|
|
99
|
-
tenant runtime.
|
|
100
|
-
|
|
101
|
-
The managed copy disables self-updates. The signed native installation remains
|
|
102
|
-
Cursor's update authority, and every `open` re-runs `prepare`, adopting a
|
|
103
|
-
compatible stable vendor update immediately. If an update changes any reviewed
|
|
104
|
-
contract, Impel preserves the previous generation and refuses the new build
|
|
105
|
-
until its compatibility signatures and tests are reviewed.
|
|
106
|
-
|
|
107
|
-
## Gateway contract
|
|
108
|
-
|
|
109
|
-
The gateway must enable `IMPEL_EXPERIMENTAL_CROSS_APP_MODELS` and serve:
|
|
110
|
-
|
|
111
|
-
```text
|
|
112
|
-
GET /experimental/openai/v1/models
|
|
113
|
-
POST /experimental/openai/v1/chat/completions
|
|
114
|
-
POST /experimental/openai/v1/responses
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
Cursor 3.12 initially submits Agent turns with its logical model `default`
|
|
118
|
-
over Chat Completions, independently of the discovered catalog. The gateway
|
|
119
|
-
maps that alias only on the experimental Cursor route to the exact registered
|
|
120
|
-
Cursor default (`gpt-5.6-sol` today). Bifrost then performs its typed
|
|
121
|
-
Chat-to-Responses conversion, including streaming response conversion, tools,
|
|
122
|
-
reasoning, and multimodal messages. The converted request follows the normal
|
|
123
|
-
pooled Codex path with `store:false`, tenant attribution, usage metering, and
|
|
124
|
-
server-owned provider authentication.
|
|
125
|
-
|
|
126
|
-
Explicit local-provider model requests can use Responses directly. The
|
|
127
|
-
authenticated catalog is filtered by PAT scopes and live tenant pool readiness
|
|
128
|
-
and advertises tools, streaming, reasoning, vision, context, modalities, and
|
|
129
|
-
both protocol markers required by Cursor's bundled parser.
|
|
130
|
-
|
|
131
|
-
Reasoning effort is a per-model, per-selection parameter rather than a global
|
|
132
|
-
Cursor setting. The gateway catalog owns each model's allowed levels and
|
|
133
|
-
default. Impel projects those values into Cursor's `Reasoning` selector, and the
|
|
134
|
-
local Responses transport sends the selected value as `reasoning.effort`.
|
|
135
|
-
Bifrost then converts that typed field for the selected Codex or Claude
|
|
136
|
-
subscription provider. Models that advertise no reasoning levels receive no
|
|
137
|
-
effort selector.
|
|
138
|
-
|
|
139
|
-
## Battle-test result and parity boundary
|
|
140
|
-
|
|
141
|
-
The gated packaged-GUI contract now proves all of the following against the
|
|
142
|
-
real signed stable app and patched managed copy:
|
|
143
|
-
|
|
144
|
-
- the native Cursor profile is unreadable and its sentinel remains unchanged;
|
|
145
|
-
- the onboarding and hosted-login wall are absent;
|
|
146
|
-
- the real `agent-exec` and local-agent runtime extensions activate;
|
|
147
|
-
- authenticated `GET /models` reaches the loopback gateway;
|
|
148
|
-
- the tenant catalog is reconciled into Cursor's named-model picker without
|
|
149
|
-
overwriting non-Impel model preferences;
|
|
150
|
-
- a real Agent submission sends `model: "default"` to Chat Completions with
|
|
151
|
-
the tenant bearer;
|
|
152
|
-
- the unique streamed gateway result is rendered in the Agent UI;
|
|
153
|
-
- no personal provider credential or native profile is used.
|
|
154
|
-
|
|
155
|
-
The direct runtime contract separately proves model discovery and a Responses
|
|
156
|
-
stream with the intended model and bearer.
|
|
157
|
-
|
|
158
|
-
The gateway conversion contract separately pins tool-call IDs across the next
|
|
159
|
-
turn's tool result, fragmented parallel argument streams, reasoning deltas,
|
|
160
|
-
`tool_calls` termination, vision input, and `store:false` after conversion.
|
|
161
|
-
|
|
162
|
-
| Cursor surface | Managed local mode |
|
|
163
|
-
| --- | --- |
|
|
164
|
-
| Editor, terminal, source control, extensions | Retained with tenant-isolated state |
|
|
165
|
-
| Agent chat, edits, terminal tools, MCP | Works through the Impel gateway |
|
|
166
|
-
| Streaming, reasoning, vision, tool calls | Preserved by Cursor and Bifrost's typed conversion |
|
|
167
|
-
| Model selection | Gateway owns `default`; concrete tenant models are projected into Cursor's named-model picker |
|
|
168
|
-
| Tab completion | Unavailable; Cursor disables CPP/Tab in local mode |
|
|
169
|
-
| Cloud Background Agents | Unavailable; hosted Cursor service |
|
|
170
|
-
| Shared chats/canvases, Bugbot, code tour | Unavailable; hosted Cursor services |
|
|
171
|
-
| Server-side repository indexing/search | Unavailable; hosted Cursor service |
|
|
172
|
-
|
|
173
|
-
This provides direct Impel inference for the local Agent with normal Cursor
|
|
174
|
-
editor updates. It cannot provide complete hosted-Cursor parity because Tab,
|
|
175
|
-
cloud agents, collaboration, Bugbot, and hosted indexing use unpublished
|
|
176
|
-
Cursor services rather than the bundled local-provider protocol. The app may
|
|
177
|
-
still contact Cursor domains for updates, extensions, and unavailable hosted
|
|
178
|
-
features; model inference is the gateway-owned boundary.
|
|
179
|
-
|
|
180
|
-
The tenant gateway bearer is process-scoped because Cursor's current local
|
|
181
|
-
provider contract resolves it from the launch environment. Treat extensions
|
|
182
|
-
installed into this isolated Cursor profile as trusted: a same-user extension
|
|
183
|
-
or process can inspect or reuse that gateway-only bearer. A short-lived,
|
|
184
|
-
route-bound desktop capability would be a future hardening, but it is not part
|
|
185
|
-
of the current PAT contract.
|
|
186
|
-
|
|
187
|
-
## Validation
|
|
188
|
-
|
|
189
|
-
The ordinary suite uses fake bundles and credentials. Validate the installed
|
|
190
|
-
stable bundle and the direct local-provider transport with:
|
|
191
|
-
|
|
192
|
-
```sh
|
|
193
|
-
IMPEL_VALIDATE_INSTALLED_CURSOR_LOCAL=1 node --test --test-name-pattern='installed stable Cursor' test/cursor-local.test.js
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
The packaged GUI battle test uses only a disposable HOME, user-data directory,
|
|
197
|
-
extension directory, workspace, process-lifetime secret store, loopback HTTP
|
|
198
|
-
server, and loopback debugger:
|
|
199
|
-
|
|
200
|
-
```sh
|
|
201
|
-
IMPEL_VALIDATE_INSTALLED_CURSOR_GUI=1 node --test --test-name-pattern='managed Cursor GUI' test/cursor-local.test.js
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
Neither test reads or writes the operator's native Cursor profile or uses live
|
|
205
|
-
provider credentials.
|
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import os from "node:os";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
cursorLaunchSpec,
|
|
5
|
-
ensureCursorManagedAuthentication,
|
|
6
|
-
ensureCursorManagedModels,
|
|
7
|
-
launchManagedCursor,
|
|
8
|
-
managedCursorStatus,
|
|
9
|
-
prepareManagedCursor,
|
|
10
|
-
reusePreparedManagedCursor,
|
|
11
|
-
} from "../cursorLocal.js";
|
|
12
|
-
import { fetchHttp1 } from "../http1.js";
|
|
13
|
-
import { loadConfig, normalizeGatewayUrl, redactSecretText, resolveDefaultGateway } from "../config.js";
|
|
14
|
-
import { assertProviderScopes, ensureTenantSelection, tenantCredential } from "../tenants.js";
|
|
15
|
-
|
|
16
|
-
export const CURSOR_EXPERIMENT_HELP = `impel experimental cursor prepare|open|status
|
|
17
|
-
|
|
18
|
-
Prepare, open, and inspect a tenant-isolated Cursor desktop runtime experiment.
|
|
19
|
-
Agent inference uses the selected tenant's Impel gateway and an isolated,
|
|
20
|
-
non-secret Cursor smoke identity. The signed stable Cursor.app in /Applications
|
|
21
|
-
keeps updating normally; Impel mirrors and revalidates each compatible update
|
|
22
|
-
without modifying the vendor app or the user's personal Cursor profile.
|
|
23
|
-
|
|
24
|
-
Usage:
|
|
25
|
-
impel experimental cursor prepare
|
|
26
|
-
impel experimental cursor open [workspace-or-Cursor-args...]
|
|
27
|
-
impel experimental cursor status`;
|
|
28
|
-
|
|
29
|
-
async function cursorGatewayCatalog(options, dependencies = {}) {
|
|
30
|
-
const fetchImpl = dependencies.fetch || fetchHttp1;
|
|
31
|
-
let response;
|
|
32
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
33
|
-
const controller = new AbortController();
|
|
34
|
-
const timeout = setTimeout(() => controller.abort(), 7_000);
|
|
35
|
-
try {
|
|
36
|
-
response = await fetchImpl(
|
|
37
|
-
`${options.gatewayUrl}/experimental/openai/v1/models`,
|
|
38
|
-
{
|
|
39
|
-
headers: {
|
|
40
|
-
accept: "application/json",
|
|
41
|
-
authorization: `Bearer ${options.credential}`,
|
|
42
|
-
},
|
|
43
|
-
signal: controller.signal,
|
|
44
|
-
},
|
|
45
|
-
);
|
|
46
|
-
break;
|
|
47
|
-
} catch (error) {
|
|
48
|
-
if (error?.name === "AbortError" && attempt === 0) continue;
|
|
49
|
-
const detail = error?.name === "AbortError" ? "request timed out" : error?.message || error;
|
|
50
|
-
throw new Error(`could not read the Cursor model catalog: ${redactSecretText(detail)}`);
|
|
51
|
-
} finally {
|
|
52
|
-
clearTimeout(timeout);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
let payload;
|
|
56
|
-
try {
|
|
57
|
-
payload = await response.json();
|
|
58
|
-
} catch {
|
|
59
|
-
throw new Error(
|
|
60
|
-
`Cursor model catalog endpoint returned non-JSON (HTTP ${response.status}); deploy the matching impel-gateway Cursor catalog route`,
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
if (!response.ok) {
|
|
64
|
-
const detail = payload?.error?.message || payload?.error || `Cursor model catalog returned HTTP ${response.status}`;
|
|
65
|
-
throw new Error(redactSecretText(typeof detail === "string" ? detail : JSON.stringify(detail)));
|
|
66
|
-
}
|
|
67
|
-
if (payload?.org_id !== options.tenantId) {
|
|
68
|
-
throw new Error("Cursor model catalog tenant did not match the selected tenant");
|
|
69
|
-
}
|
|
70
|
-
if (!Array.isArray(payload.data) || payload.data.length === 0) {
|
|
71
|
-
throw new Error("Cursor model catalog returned no routable models");
|
|
72
|
-
}
|
|
73
|
-
const invalid = payload.data.find((model) => (
|
|
74
|
-
typeof model?.id !== "string"
|
|
75
|
-
|| !Array.isArray(model.api_types)
|
|
76
|
-
|| !model.api_types.includes("responses")
|
|
77
|
-
|| model?.capabilities?.supports_tool_use !== true
|
|
78
|
-
|| model?.capabilities?.supports_streaming !== true
|
|
79
|
-
|| (
|
|
80
|
-
model?.capabilities?.supports_reasoning === true
|
|
81
|
-
&& (
|
|
82
|
-
!Array.isArray(model.capabilities.reasoning_effort)
|
|
83
|
-
|| model.capabilities.reasoning_effort.length === 0
|
|
84
|
-
|| model.capabilities.reasoning_effort.some((effort) => typeof effort !== "string")
|
|
85
|
-
|| (
|
|
86
|
-
model.capabilities.default_reasoning_effort !== undefined
|
|
87
|
-
&& !model.capabilities.reasoning_effort.includes(model.capabilities.default_reasoning_effort)
|
|
88
|
-
)
|
|
89
|
-
)
|
|
90
|
-
)
|
|
91
|
-
));
|
|
92
|
-
if (invalid) throw new Error("Cursor model catalog did not advertise the required Responses/tool contract");
|
|
93
|
-
return payload;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function statusLines(status, tenantId) {
|
|
97
|
-
const vendorLabel = status.vendor
|
|
98
|
-
? `${status.vendor.version} (${status.vendor.commit.slice(0, 12)})`
|
|
99
|
-
: "incompatible";
|
|
100
|
-
const managedLabel = status.manifest?.vendor?.version || "not prepared";
|
|
101
|
-
return [
|
|
102
|
-
`Managed Cursor tenant: ${tenantId}`,
|
|
103
|
-
`Installed stable Cursor: ${vendorLabel}`,
|
|
104
|
-
`Managed runtime: ${managedLabel}`,
|
|
105
|
-
`Managed bundle contract: ${status.ready ? "ready" : "needs prepare"}`,
|
|
106
|
-
`Local Agent UI contract: ${status.ready ? "ready" : "needs prepare"}`,
|
|
107
|
-
...(status.compatibilityError ? [`Compatibility: blocked — ${status.compatibilityError}`] : []),
|
|
108
|
-
...(status.runtimeError ? [`Managed signature: invalid — ${status.runtimeError}`] : []),
|
|
109
|
-
];
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export async function cmdCursorExperimental(argv, dependencies = {}) {
|
|
113
|
-
const [action, ...args] = argv;
|
|
114
|
-
const log = dependencies.log || console.log;
|
|
115
|
-
if (action == null || action === "help") {
|
|
116
|
-
if (args.length) throw new Error("usage: impel experimental cursor prepare|open|status");
|
|
117
|
-
log(CURSOR_EXPERIMENT_HELP);
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
if (!["prepare", "open", "status"].includes(action) || (action !== "open" && args.length)) {
|
|
121
|
-
throw new Error("usage: impel experimental cursor prepare|open|status");
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const config = (dependencies.loadConfig || loadConfig)();
|
|
125
|
-
if (!config?.pat) throw new Error("run `impel setup` before preparing managed Cursor");
|
|
126
|
-
const selection = await (dependencies.ensureTenantSelection || ensureTenantSelection)(config, {
|
|
127
|
-
refresh: !Array.isArray(config.scopes),
|
|
128
|
-
});
|
|
129
|
-
assertProviderScopes(selection.scopes, ["claude", "codex"], { requireLive: true });
|
|
130
|
-
const tenantId = selection.tenantId;
|
|
131
|
-
const homeDir = dependencies.homeDir || os.homedir();
|
|
132
|
-
const gatewayUrl = normalizeGatewayUrl(config.gatewayUrl || resolveDefaultGateway());
|
|
133
|
-
const common = {
|
|
134
|
-
tenantId,
|
|
135
|
-
homeDir,
|
|
136
|
-
appRoot: dependencies.appRoot,
|
|
137
|
-
...(dependencies.vendorPath ? { vendorPath: dependencies.vendorPath } : {}),
|
|
138
|
-
};
|
|
139
|
-
const credential = tenantCredential(config.pat, tenantId);
|
|
140
|
-
|
|
141
|
-
if (action === "status") {
|
|
142
|
-
const status = (dependencies.managedCursorStatus || managedCursorStatus)(common, dependencies);
|
|
143
|
-
for (const line of statusLines(status, tenantId)) log(line);
|
|
144
|
-
try {
|
|
145
|
-
const catalog = await cursorGatewayCatalog({ gatewayUrl, credential, tenantId }, dependencies);
|
|
146
|
-
log(`Gateway Cursor catalog: ready (${catalog.data.length} model${catalog.data.length === 1 ? "" : "s"})`);
|
|
147
|
-
return { ...status, gatewayReady: true, catalog };
|
|
148
|
-
} catch (error) {
|
|
149
|
-
const gatewayError = redactSecretText(error?.message || error);
|
|
150
|
-
log(`Gateway Cursor catalog: unavailable — ${gatewayError}`);
|
|
151
|
-
return { ...status, gatewayReady: false, gatewayError };
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
let prepared;
|
|
156
|
-
try {
|
|
157
|
-
prepared = (dependencies.prepareManagedCursor || prepareManagedCursor)(common, dependencies);
|
|
158
|
-
} catch (error) {
|
|
159
|
-
if (action !== "open") throw error;
|
|
160
|
-
prepared = (dependencies.reusePreparedManagedCursor || reusePreparedManagedCursor)(common, dependencies);
|
|
161
|
-
log(`Installed Cursor update was not imported; reusing the last verified managed runtime — ${redactSecretText(error?.message || error)}`);
|
|
162
|
-
}
|
|
163
|
-
const updateLabel = prepared.updated ? "Prepared" : "Reused";
|
|
164
|
-
log(`${updateLabel} managed Cursor ${prepared.source.version} for tenant ${tenantId}.`);
|
|
165
|
-
if (action === "prepare") return prepared;
|
|
166
|
-
const catalog = await cursorGatewayCatalog({ gatewayUrl, credential, tenantId }, dependencies);
|
|
167
|
-
const managedModels = (dependencies.ensureCursorManagedModels || ensureCursorManagedModels)(
|
|
168
|
-
prepared.paths.userData,
|
|
169
|
-
catalog.data,
|
|
170
|
-
`${gatewayUrl}/experimental/openai/v1`,
|
|
171
|
-
dependencies,
|
|
172
|
-
);
|
|
173
|
-
const authentication = (dependencies.ensureCursorManagedAuthentication || ensureCursorManagedAuthentication)(
|
|
174
|
-
prepared.paths.userData,
|
|
175
|
-
dependencies,
|
|
176
|
-
);
|
|
177
|
-
const spec = (dependencies.cursorLaunchSpec || cursorLaunchSpec)({
|
|
178
|
-
paths: prepared.paths,
|
|
179
|
-
gatewayUrl,
|
|
180
|
-
credential,
|
|
181
|
-
tenantId,
|
|
182
|
-
cursorAuthToken: authentication.token,
|
|
183
|
-
argv: args,
|
|
184
|
-
environment: dependencies.environment || process.env,
|
|
185
|
-
});
|
|
186
|
-
await (dependencies.launchManagedCursor || launchManagedCursor)(spec, dependencies);
|
|
187
|
-
log(`Opened managed Cursor for tenant ${tenantId} with ${catalog.data.length} gateway model${catalog.data.length === 1 ? "" : "s"}.`);
|
|
188
|
-
return { ...prepared, catalog, managedModels, launched: true };
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export { cursorGatewayCatalog };
|