From af27fb6739c10a598ae44bf6ef96228b4924576a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 14:22:42 +0000 Subject: [PATCH 1/5] docs(openspec): plan Cursor version-probe targeting Record the PATH `agent` collision fix as probe targeting, without renaming Cursor catalog identity. --- .../.openspec.yaml | 2 + .../cursor-version-probe-targeting/design.md | 43 +++++++++++++++++++ .../proposal.md | 38 ++++++++++++++++ .../specs/agent-catalog/spec.md | 21 +++++++++ .../specs/agent-version-probing/spec.md | 23 ++++++++++ .../specs/lifecycle-reconciliation/spec.md | 23 ++++++++++ .../cursor-version-probe-targeting/tasks.md | 27 ++++++++++++ 7 files changed, 177 insertions(+) create mode 100644 openspec/changes/cursor-version-probe-targeting/.openspec.yaml create mode 100644 openspec/changes/cursor-version-probe-targeting/design.md create mode 100644 openspec/changes/cursor-version-probe-targeting/proposal.md create mode 100644 openspec/changes/cursor-version-probe-targeting/specs/agent-catalog/spec.md create mode 100644 openspec/changes/cursor-version-probe-targeting/specs/agent-version-probing/spec.md create mode 100644 openspec/changes/cursor-version-probe-targeting/specs/lifecycle-reconciliation/spec.md create mode 100644 openspec/changes/cursor-version-probe-targeting/tasks.md diff --git a/openspec/changes/cursor-version-probe-targeting/.openspec.yaml b/openspec/changes/cursor-version-probe-targeting/.openspec.yaml new file mode 100644 index 00000000..1a62d62b --- /dev/null +++ b/openspec/changes/cursor-version-probe-targeting/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-06 diff --git a/openspec/changes/cursor-version-probe-targeting/design.md b/openspec/changes/cursor-version-probe-targeting/design.md new file mode 100644 index 00000000..4815a291 --- /dev/null +++ b/openspec/changes/cursor-version-probe-targeting/design.md @@ -0,0 +1,43 @@ +## Context + +Cursor CLI installs as both `agent` and `cursor-agent`. Quantex catalogs it as `name: cursor`, `binaryName: agent`, `lookupAliases: ["agent"]`, `versionProbe.command: ["agent", "--version"]`. Observation resolves the executable with `which`/`where` on `binaryName` and, when the probe command leads with that same name, substitutes the resolved absolute path. + +That substitution already matches `lifecycle-reconciliation`: probes go through the resolved path, not a second bare-name spawn. The remaining bug is the resolution itself. The first `agent` on `PATH` may be some other tool (issue #715). Closed PR #714 changed `binaryName` to `cursor-agent` and added lookup alias `cursor`, which failed: + +- `test/agents.test.ts` locks `binaryName` to `agent` +- lookup aliases MUST NOT repeat the canonical name `cursor` +- v1 list/inspect goldens include `binaryName: "agent"` + +## Goals / Non-Goals + +**Goals:** + +- `list` / `inspect` installed-version for Cursor matches the Cursor executable Quantex resolved. +- Prefer a unique Cursor executable name (`cursor-agent`) when locating that binary, then probe via the resolved absolute path. +- Keep catalog identity and CLI contracts frozen. + +**Non-Goals:** + +- Renaming the agent, changing `binaryName`, or adding `cursor` as a lookup alias. +- Widening `--json` / exit codes / inspect-list field sets. +- Heuristic fingerprinting of arbitrary `agent` binaries. +- Self-upgrade, release-core.yml, protect-main, command/SDK expansion. +- Changing `selfUpdate.command` (`agent update`). + +## Decisions + +1. **Preferred binaries are probe-targeting metadata, not identity.** Add optional `versionProbe.preferredBinaries: string[]`. Cursor sets `["cursor-agent"]`. `binaryName` stays `agent`. Lookup aliases stay `["agent"]`. CLI `--json` does not grow a new field. + +2. **Resolve preferred names completely before falling back.** For each name in `[...preferredBinaries, binaryName]` (deduped, first wins): look up `PATH`, then known install directories. Stop at the first hit. That way a `cursor-agent` in `~/.local/bin` wins over an unrelated `agent` earlier on `PATH`. + +3. **Keep the existing substitution rule.** Probe command remains `["agent", "--version"]`, so `command[0] === binaryName` still substitutes the resolved absolute path (which may be `.../cursor-agent`). Custom probes whose first argument is not the executable name stay unchanged. + +4. **One targeting helper, used by Core observation and the shared resolver.** Core list/inspect goes through `production-observation`. The same lookup-name order is applied in `resolveAgentExecutablePath` so update observation and leftover inspection paths do not drift. Do not edit KEEP-marked leftover comments or self-upgrade modules. + +5. **Fallback remains `agent`.** If `cursor-agent` is absent, behavior stays PATH-then-known-dir lookup of `agent`. That preserves single-name installs; it cannot disambiguate two unrelated `agent` binaries without identity churn. + +## Risks / Trade-offs + +- **Preferred name in known dirs beats PATH `agent`.** Intentional: the unique Cursor name is stronger evidence than a generic PATH hit. +- **Fallback still collides** when only a non-Cursor `agent` exists. Accepted; fixing that would require fingerprinting or renaming `binaryName`. +- **Generated catalogs must copy `preferredBinaries`.** Core observation reads the generated Core catalog; omitting the field would leave list/inspect on `agent` only. diff --git a/openspec/changes/cursor-version-probe-targeting/proposal.md b/openspec/changes/cursor-version-probe-targeting/proposal.md new file mode 100644 index 00000000..2c0ad862 --- /dev/null +++ b/openspec/changes/cursor-version-probe-targeting/proposal.md @@ -0,0 +1,38 @@ +## Why + +`qtx ls` and `qtx inspect cursor` sometimes report a version from an unrelated `agent` binary on `PATH`. Cursor CLI's catalog identity uses `binaryName: agent` and probes `agent --version`, so the first `agent` wins even when Quantex has already resolved, or can resolve, the actual Cursor executable. Closed PR #714 tried to dodge the collision by renaming the catalog binary and adding `cursor` as an alias; that failed golden and identity tests. The version field must come from the Cursor executable Quantex resolved, without catalog identity churn. + +## What Changes + +- Keep Cursor's catalog identity unchanged: canonical name `cursor`, `binaryName` `agent`, lookup aliases `["agent"]` (aliases MUST NOT include the canonical name). +- Teach version-probe targeting to locate Cursor through a more specific executable name (`cursor-agent`) before falling back to `agent`, then probe the resolved absolute path. +- Do not widen CLI `--json` fields, exit codes, or list/inspect contract shapes. +- Do not rename slugs, expand commands/SDK, or touch self-upgrade / release-core / protect-main paths. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `agent-version-probing`: Installed-version evidence for an observed agent MUST come from the executable Quantex resolved for that agent, not from a bare `PATH` name that can collide. +- `agent-catalog`: Cursor CLI keeps `binaryName` `agent` and alias `agent`, and declares a preferred probe executable `cursor-agent` used only to locate the binary. +- `lifecycle-reconciliation`: Observation already substitutes a resolved path into probes whose first argument is the agent's executable name; add the PATH-collision case where a preferred, more specific executable is resolved first. + +## Impact + +- `src/agents/catalog/cursor.json` and generated catalog/core catalog projections +- `src/agents/types.ts`, `src/agents/schema.ts`, generated `catalog.schema.json` +- `src/core/production-observation.ts` (list/inspect observation) +- `src/utils/executable-resolution.ts`, `src/utils/executable-search-paths.ts` +- `src/services/lifecycle-observations.ts`, `src/inspection/agents.ts`, `src/core/update-production.ts` (same targeting rule) +- `test/agents.test.ts`, `test/core/production-observation.test.ts`, `test/utils/executable-resolution.test.ts` +- OpenSpec deltas under this change + +No CLI command catalog, schema-version, or `--json` field changes. Launch argv already uses the observed executable path, so correcting observation also aims execution at that same binary without changing the execution executor. + +## Intake classification + +Observable CLI version-probe / agent-catalog behavior change; OpenSpec required. diff --git a/openspec/changes/cursor-version-probe-targeting/specs/agent-catalog/spec.md b/openspec/changes/cursor-version-probe-targeting/specs/agent-catalog/spec.md new file mode 100644 index 00000000..98d32282 --- /dev/null +++ b/openspec/changes/cursor-version-probe-targeting/specs/agent-catalog/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: Cursor CLI catalog identity MUST stay `agent` while version probing prefers `cursor-agent` + +Quantex SHALL keep Cursor CLI in the supported agent catalog with canonical name `cursor`, executable `binaryName` `agent`, and lookup alias `agent`. Lookup aliases MUST NOT include the canonical name `cursor`. The catalog SHALL declare `cursor-agent` as a preferred version-probe binary so observation can locate Cursor CLI without renaming the catalog identity. + +Structured `list` and `inspect` output SHALL continue to report `binaryName` as `agent`. Preferred probe binaries are catalog targeting metadata and MUST NOT appear as a new CLI `--json` field. + +#### Scenario: Looking up Cursor CLI + +- **WHEN** a user or machine consumer looks up the canonical agent name `cursor` or the alias `agent` +- **THEN** Quantex returns the Cursor CLI catalog entry +- **AND** the entry identifies `agent` as the executable `binaryName` +- **AND** the entry's lookup aliases are `agent` and do not include `cursor` + +#### Scenario: Probing Cursor CLI version + +- **WHEN** Quantex probes the installed version of Cursor CLI +- **THEN** it locates `cursor-agent` before falling back to `agent` +- **AND** it runs the catalog version-probe command against the resolved absolute path +- **AND** structured output still reports `binaryName` as `agent` diff --git a/openspec/changes/cursor-version-probe-targeting/specs/agent-version-probing/spec.md b/openspec/changes/cursor-version-probe-targeting/specs/agent-version-probing/spec.md new file mode 100644 index 00000000..e6a867e9 --- /dev/null +++ b/openspec/changes/cursor-version-probe-targeting/specs/agent-version-probing/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Installed-version probes MUST target the resolved agent executable + +When Quantex reports an installed version for a catalog agent, that version SHALL be parsed from a probe of the executable Quantex resolved for that agent. Quantex MUST NOT spawn a bare executable name from `PATH` when it already holds a resolved absolute path whose probe command leads with the agent's declared executable name. + +When the catalog declares preferred binaries for version-probe targeting, Quantex SHALL locate the executable by trying those names in order before the agent's `binaryName`, completing PATH lookup and known-install-directory fallback for each name before moving to the next. The first existing executable wins. The installed-version field on `list` and `inspect` SHALL match that resolved executable. + +#### Scenario: A preferred Cursor executable exists beside an unrelated PATH agent + +- **GIVEN** Cursor CLI is catalogued with `binaryName` `agent` and preferred probe binary `cursor-agent` +- **AND** `PATH` contains an `agent` executable that is not Cursor CLI +- **AND** a `cursor-agent` executable exists on `PATH` or in a known install directory +- **WHEN** Quantex lists agents or inspects `cursor` +- **THEN** the reported installed version is the version of the resolved `cursor-agent` executable +- **AND** Quantex does not report the unrelated `agent` binary's version + +#### Scenario: Preferred binary is absent + +- **GIVEN** no preferred probe binary resolves +- **AND** the agent's `binaryName` resolves on `PATH` or in a known install directory +- **WHEN** Quantex probes the installed version +- **THEN** Quantex uses that `binaryName` resolution and probes through its absolute path diff --git a/openspec/changes/cursor-version-probe-targeting/specs/lifecycle-reconciliation/spec.md b/openspec/changes/cursor-version-probe-targeting/specs/lifecycle-reconciliation/spec.md new file mode 100644 index 00000000..4a51f2cf --- /dev/null +++ b/openspec/changes/cursor-version-probe-targeting/specs/lifecycle-reconciliation/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Executable resolution MAY prefer a more specific binary name before `binaryName` + +When locating an agent executable for observation, Quantex SHALL try any catalog-declared preferred probe binaries before the agent's `binaryName`. Each name is resolved with the existing PATH-then-known-install-directory rule. The first hit is the resolved executable used for presence, reported path, and version probing. + +This does not change the substitution rule: a catalog version-probe command whose first argument is the agent's executable name is still invoked through the resolved absolute path, even when that path's basename differs from `binaryName`. + +#### Scenario: Preferred name in a known install directory beats a PATH name collision + +- **GIVEN** an unrelated `agent` executable is the first `agent` on `PATH` +- **AND** `cursor-agent` exists only in a known agent install directory +- **WHEN** Quantex observes Cursor CLI +- **THEN** the resolved executable path is that `cursor-agent` +- **AND** the version probe is invoked as that absolute path plus the catalog probe arguments + +#### Scenario: Probe command still substitutes when the resolved basename differs + +- **GIVEN** Cursor's catalog probe command is `agent --version` +- **AND** observation resolved `/home/user/.local/bin/cursor-agent` +- **WHEN** Quantex probes the installed version +- **THEN** it invokes `/home/user/.local/bin/cursor-agent --version` +- **AND** it does not spawn a bare `agent` from `PATH` diff --git a/openspec/changes/cursor-version-probe-targeting/tasks.md b/openspec/changes/cursor-version-probe-targeting/tasks.md new file mode 100644 index 00000000..d6bbb33b --- /dev/null +++ b/openspec/changes/cursor-version-probe-targeting/tasks.md @@ -0,0 +1,27 @@ +## 1. Catalog targeting metadata + +- [x] 1.1 Add optional `versionProbe.preferredBinaries` to `AgentVersionProbe`, catalog schema, and Core catalog projection +- [x] 1.2 Set Cursor `preferredBinaries` to `["cursor-agent"]` without changing `name`, `binaryName`, or `lookupAliases` +- [x] 1.3 Regenerate agent catalog manifests and Core catalog + +## 2. Resolve and probe the preferred executable + +- [x] 2.1 Add a lookup-name helper that tries preferred binaries before `binaryName` +- [x] 2.2 Resolve each name with PATH then known install directories, first hit wins +- [x] 2.3 Use that order in Core production observation (list/inspect) +- [x] 2.4 Apply the same order in shared executable resolution and leftover observation/inspect/update probe call sites +- [x] 2.5 Keep substituting the resolved absolute path into probes whose first argument is `binaryName` + +## 3. Tests + +- [x] 3.1 Lock Cursor identity: `binaryName` is `agent`, aliases are `["agent"]` and do not include `cursor`, probe command stays `["agent", "--version"]` +- [x] 3.2 Assert list/inspect version comes from `cursor-agent` when PATH has a different `agent` +- [x] 3.3 Assert fallback to `agent` when `cursor-agent` is absent +- [x] 3.4 Do not update v1 command goldens unless `binaryName` in JSON actually changed (it must not) + +## 4. Validation and delivery + +- [ ] 4.1 Run `bun run lint`, `bun run format:check`, `bun run typecheck` +- [ ] 4.2 Run `bun run test` +- [ ] 4.3 Run `bun run openspec:validate` +- [ ] 4.4 Commit, push, and open a draft PR linking #715 From 50469a16f99eb64f7569c7a015517f66d8533ee0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 14:22:42 +0000 Subject: [PATCH 2/5] fix(agents): probe Cursor version via resolved cursor-agent Prefer `cursor-agent` when locating the Cursor CLI executable so list/inspect versions match that binary instead of a colliding `agent` on PATH. Keep binaryName and aliases unchanged. --- scripts/build/write-core-agent-catalog.ts | 11 +- src/agents/catalog.schema.json | 8 ++ src/agents/catalog/cursor.json | 3 +- src/agents/schema.ts | 1 + src/agents/types.ts | 5 + src/core/generated/agent-catalog.ts | 1 + src/core/production-observation.ts | 40 ++++--- src/core/update-production.ts | 6 +- src/inspection/agents.ts | 3 +- src/services/lifecycle-observations.ts | 6 +- src/utils/executable-resolution.ts | 22 ++-- src/utils/executable-search-paths.ts | 13 +++ test/agents.test.ts | 2 + test/architecture/core-agent-catalog.test.ts | 11 +- test/core/production-observation.test.ts | 105 +++++++++++++++++++ test/utils/executable-resolution.test.ts | 48 ++++++++- test/utils/version.test.ts | 14 +++ 17 files changed, 271 insertions(+), 28 deletions(-) diff --git a/scripts/build/write-core-agent-catalog.ts b/scripts/build/write-core-agent-catalog.ts index 39f3cb7b..7cc3a68e 100644 --- a/scripts/build/write-core-agent-catalog.ts +++ b/scripts/build/write-core-agent-catalog.ts @@ -152,7 +152,16 @@ function projectCoreAgent(agent: AgentDefinition): AgentDefinition { })), ]), ) as AgentDefinition['platforms'], - ...(agent.versionProbe ? { versionProbe: { command: agent.versionProbe.command } } : {}), + ...(agent.versionProbe + ? { + versionProbe: { + command: agent.versionProbe.command, + ...(agent.versionProbe.preferredBinaries?.length + ? { preferredBinaries: [...agent.versionProbe.preferredBinaries] } + : {}), + }, + } + : {}), } } diff --git a/src/agents/catalog.schema.json b/src/agents/catalog.schema.json index 17fe9c33..d2c0ea4a 100644 --- a/src/agents/catalog.schema.json +++ b/src/agents/catalog.schema.json @@ -435,6 +435,14 @@ "type": "string", "minLength": 1 } + }, + "preferredBinaries": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } } }, "additionalProperties": false diff --git a/src/agents/catalog/cursor.json b/src/agents/catalog/cursor.json index 9037aaf7..99ebb4cc 100644 --- a/src/agents/catalog/cursor.json +++ b/src/agents/catalog/cursor.json @@ -8,7 +8,8 @@ "command": ["agent", "update"] }, "versionProbe": { - "command": ["agent", "--version"] + "command": ["agent", "--version"], + "preferredBinaries": ["cursor-agent"] }, "platforms": { "windows": [ diff --git a/src/agents/schema.ts b/src/agents/schema.ts index a0aae16c..b7ed2ee1 100644 --- a/src/agents/schema.ts +++ b/src/agents/schema.ts @@ -173,6 +173,7 @@ export const agentSelfUpdateSchema = z export const agentVersionProbeSchema = z .object({ command: commandSchema.optional(), + preferredBinaries: z.array(nonEmptyStringSchema).min(1).optional(), }) .strict() .refine(probe => probe.command !== undefined, { diff --git a/src/agents/types.ts b/src/agents/types.ts index d3ae82e4..5cc93a83 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -47,6 +47,11 @@ export interface AgentSelfUpdate { export interface AgentVersionProbe { command?: string[] parser?: (stdout: string) => string | undefined + /** + * Names tried before `binaryName` when locating the executable to probe. + * Identity (`binaryName`, lookup aliases) stays unchanged. + */ + preferredBinaries?: string[] } export interface AgentDefinition { diff --git a/src/core/generated/agent-catalog.ts b/src/core/generated/agent-catalog.ts index 8ac6a54f..7cbc14fc 100644 --- a/src/core/generated/agent-catalog.ts +++ b/src/core/generated/agent-catalog.ts @@ -495,6 +495,7 @@ export const coreAgentCatalog: readonly AgentDefinition[] = [ }, versionProbe: { command: ['agent', '--version'], + preferredBinaries: ['cursor-agent'], }, }, { diff --git a/src/core/production-observation.ts b/src/core/production-observation.ts index 434c6ab3..649f1906 100644 --- a/src/core/production-observation.ts +++ b/src/core/production-observation.ts @@ -13,7 +13,11 @@ import process from 'node:process' import { observeAgentLifecycle } from '../lifecycle/agent-observation' import { resolveInstallMethodProviderBinding } from '../lifecycle/provider-binding' import { createEmptyStateDocument, parseStateDocument, StateSchemaError } from '../state/schema' -import { getExecutableCandidateNames, getKnownAgentInstallDirectories } from '../utils/executable-search-paths' +import { + getExecutableCandidateNames, + getKnownAgentInstallDirectories, + uniqueExecutableLookupNames, +} from '../utils/executable-search-paths' import { getCoreAgentByNameOrAlias, getCoreAgents } from './agent-catalog' import { createCoreProviderObservationRegistry } from './provider-observation-registry' import { CoreProcessInterruptionError, runReadOnlyCommand } from './read-only-process' @@ -137,7 +141,10 @@ async function inspectExecutable( agent: AgentDefinition, context: ProviderOperationContext, ): Promise { - const path = await findExecutable(agent.binaryName, context) + const path = await findExecutable( + uniqueExecutableLookupNames(agent.binaryName, agent.versionProbe?.preferredBinaries), + context, + ) if (!path) return { present: false } const executablePath = (await resolveExecutablePath(path, context.signal)) ?? path const version = await inspectVersion(agent, executablePath, context) @@ -148,19 +155,26 @@ async function inspectExecutable( } } -async function findExecutable(binaryName: string, context: ProviderOperationContext): Promise { - try { - const result = await runReadOnlyCommand([process.platform === 'win32' ? 'where' : 'which', binaryName], context) - if (result.exitCode === 0) { - const fromPath = result.stdout.trim().split(/\r?\n/u)[0] - if (fromPath) return fromPath +async function findExecutable( + binaryNames: readonly string[], + context: ProviderOperationContext, +): Promise { + for (const binaryName of binaryNames) { + try { + const result = await runReadOnlyCommand([process.platform === 'win32' ? 'where' : 'which', binaryName], context) + if (result.exitCode === 0) { + const fromPath = result.stdout.trim().split(/\r?\n/u)[0] + if (fromPath) return fromPath + } + } catch (error) { + if (error instanceof CoreProcessInterruptionError) throw error } - } catch (error) { - if (error instanceof CoreProcessInterruptionError) throw error + // An installer that writes into a directory it also appends to a shell profile + // is invisible to the PATH of this already-running process. + const fromKnown = await findInKnownInstallDirectories(binaryName) + if (fromKnown) return fromKnown } - // An installer that writes into a directory it also appends to a shell profile - // is invisible to the PATH of this already-running process. - return findInKnownInstallDirectories(binaryName) + return undefined } async function findInKnownInstallDirectories(binaryName: string): Promise { diff --git a/src/core/update-production.ts b/src/core/update-production.ts index ff399c28..930c8a27 100644 --- a/src/core/update-production.ts +++ b/src/core/update-production.ts @@ -18,6 +18,7 @@ import { firstPartyProviderRegistry } from '../providers' import { getInstalledAgentState, getLifecycleReceipt, lifecycleReceiptStore, loadState } from '../state' import { getPlatform } from '../utils/detect' import { resolveAgentExecutablePath } from '../utils/executable-resolution' +import { uniqueExecutableLookupNames } from '../utils/executable-search-paths' import { isResourceLockError } from '../utils/lock' import { getResolvedBinaryPath, probeInstalledVersion } from '../utils/version' import { getCoreAgentByNameOrAlias, getCoreAgents } from './agent-catalog' @@ -158,7 +159,10 @@ async function inspectExecutable( agent: AgentDefinition, context: ProviderOperationContext, ): Promise { - const binaryPath = await resolveAgentExecutablePath(agent.binaryName, context) + const binaryPath = await resolveAgentExecutablePath( + uniqueExecutableLookupNames(agent.binaryName, agent.versionProbe?.preferredBinaries), + context, + ) if (!binaryPath) return { present: false } const version = await probeInstalledVersion(agent.binaryName, agent.versionProbe, context, binaryPath) const path = (await getResolvedBinaryPath(binaryPath, context)) ?? binaryPath diff --git a/src/inspection/agents.ts b/src/inspection/agents.ts index 065d55c6..1d7c39b1 100644 --- a/src/inspection/agents.ts +++ b/src/inspection/agents.ts @@ -3,6 +3,7 @@ import type { InstalledAgentState } from '../state' import { getManagedInstalledPackageVersion, getOrderedInstallMethods } from '../package-manager' import { getInstalledAgentState } from '../state' import { resolveAgentExecutablePath } from '../utils/executable-resolution' +import { uniqueExecutableLookupNames } from '../utils/executable-search-paths' import { formatInstalledSource, formatUpdateManagement, @@ -32,7 +33,7 @@ export async function inspectAgent(agent: AgentDefinition): Promise { // One resolution decides presence and feeds the version probe, so an agent // outside the inherited PATH is probed through the path it actually occupies. - const binaryPath = await resolveAgentExecutablePath(agent.binaryName, context) + const binaryPath = await resolveAgentExecutablePath( + uniqueExecutableLookupNames(agent.binaryName, agent.versionProbe?.preferredBinaries), + context, + ) if (!binaryPath) return { present: false } const version = await getObservedInstalledVersion(agent, installedState, context, binaryPath) diff --git a/src/utils/executable-resolution.ts b/src/utils/executable-resolution.ts index b0ce8f34..d8c3f7f1 100644 --- a/src/utils/executable-resolution.ts +++ b/src/utils/executable-resolution.ts @@ -51,18 +51,24 @@ async function findInKnownDirectories(binaryName: string, inputs: ExecutableSear } /** - * Resolves an agent executable to an absolute path. The inherited PATH is - * authoritative; the known install directories are consulted only when the PATH - * lookup does not resolve, so existing setups keep resolving exactly as before. + * Resolves an agent executable to an absolute path. Each lookup name is tried + * completely (inherited PATH, then known install directories) before the next + * name, so a preferred unique binary in a known directory wins over a colliding + * PATH hit for a later name. A single name keeps the historical PATH-first rule. */ export async function resolveAgentExecutablePath( - binaryName: string, + binaryName: string | readonly string[], context?: ProviderOperationContext, ): Promise { - const fromPath = await lookupThroughPath(binaryName, context) - if (fromPath) return fromPath - if (context?.signal.aborted) return undefined - return findInKnownDirectories(binaryName, currentSearchInputs()) + const names = typeof binaryName === 'string' ? [binaryName] : binaryName + for (const name of names) { + const fromPath = await lookupThroughPath(name, context) + if (fromPath) return fromPath + if (context?.signal.aborted) return undefined + const fromKnown = await findInKnownDirectories(name, currentSearchInputs()) + if (fromKnown) return fromKnown + } + return undefined } export async function isAgentExecutableAvailable( diff --git a/src/utils/executable-search-paths.ts b/src/utils/executable-search-paths.ts index 4170ffaa..c0070c02 100644 --- a/src/utils/executable-search-paths.ts +++ b/src/utils/executable-search-paths.ts @@ -46,6 +46,19 @@ export function getKnownAgentInstallDirectories(inputs: ExecutableSearchInputs): return [...new Set(directories.filter(Boolean))] } +/** + * Ordered names used to locate an agent executable for observation and version + * probing. Preferred names are unique enough to beat a colliding `PATH` hit for + * `binaryName`; `binaryName` remains the fallback and the catalog identity. + */ +export function uniqueExecutableLookupNames(binaryName: string, preferredBinaries: readonly string[] = []): string[] { + const names: string[] = [] + for (const name of [...preferredBinaries, binaryName]) { + if (name.length > 0 && !names.includes(name)) names.push(name) + } + return names +} + /** * Candidate file names for one binary. POSIX uses the bare name; Windows appends * each executable extension so `agy` can resolve `agy.exe`. diff --git a/test/agents.test.ts b/test/agents.test.ts index 2863d58e..fdfcd5c7 100644 --- a/test/agents.test.ts +++ b/test/agents.test.ts @@ -484,6 +484,8 @@ describe('cursor', () => { expect(cursor.binaryName).toBe('agent') expect(cursor.selfUpdate?.command).toEqual(['agent', 'update']) expect(cursor.versionProbe?.command).toEqual(['agent', '--version']) + expect(cursor.versionProbe?.preferredBinaries).toEqual(['cursor-agent']) + expect(cursor.lookupAliases).toEqual(['agent']) }) it('binary install returns correct strings per platform', () => { diff --git a/test/architecture/core-agent-catalog.test.ts b/test/architecture/core-agent-catalog.test.ts index 00bfb8e2..25e3d27e 100644 --- a/test/architecture/core-agent-catalog.test.ts +++ b/test/architecture/core-agent-catalog.test.ts @@ -31,6 +31,15 @@ function projectCoreAgent(agent: AgentDefinition): AgentDefinition { })), ]), ) as AgentDefinition['platforms'], - ...(agent.versionProbe ? { versionProbe: { command: agent.versionProbe.command } } : {}), + ...(agent.versionProbe + ? { + versionProbe: { + command: agent.versionProbe.command, + ...(agent.versionProbe.preferredBinaries?.length + ? { preferredBinaries: [...agent.versionProbe.preferredBinaries] } + : {}), + }, + } + : {}), } } diff --git a/test/core/production-observation.test.ts b/test/core/production-observation.test.ts index 07545bfd..db379a15 100644 --- a/test/core/production-observation.test.ts +++ b/test/core/production-observation.test.ts @@ -221,6 +221,91 @@ describe('production Core observation', () => { } }, ) + + it.skipIf(process.platform === 'win32')( + 'probes Cursor via cursor-agent when PATH has a different agent binary', + async () => { + const root = await mkdtemp(join(tmpdir(), 'quantex-core-cursor-probe-')) + const collidingBin = join(root, 'colliding-bin') + const home = join(root, 'home') + const localBin = join(home, '.local', 'bin') + const configDir = join(root, 'config') + const previousPath = process.env.PATH + const previousHome = process.env.HOME + + try { + await mkdir(collidingBin, { recursive: true }) + await mkdir(localBin, { recursive: true }) + await mkdir(configDir, { recursive: true }) + await writeFile(join(collidingBin, 'agent'), '#!/bin/sh\necho other-agent 9.9.9\n') + await chmod(join(collidingBin, 'agent'), 0o755) + const cursorAgent = join(localBin, 'cursor-agent') + await writeFile(cursorAgent, '#!/bin/sh\necho 2026.09.02-c22c1a3\n') + await chmod(cursorAgent, 0o755) + process.env.HOME = home + process.env.PATH = `${collidingBin}:${join(root, 'empty')}` + + const ports = createProductionCoreReadPorts({ providerRegistry: scriptAbsentRegistry() }) + const outcome = await runCoreInvocation(undefined, context => + ports.inspectAgent('cursor', { ...context, configDir }), + ) + + expect(outcome).toMatchObject({ + kind: 'success', + value: { + agent: { binaryName: 'agent', name: 'cursor' }, + pathExecutable: { + path: await realpath(cursorAgent), + present: true, + version: '2026.09.02-c22c1a3', + }, + }, + }) + } finally { + process.env.PATH = previousPath + process.env.HOME = previousHome + await rm(root, { force: true, recursive: true }) + } + }, + ) + + it.skipIf(process.platform === 'win32')('falls back to agent when cursor-agent is absent', async () => { + const root = await mkdtemp(join(tmpdir(), 'quantex-core-cursor-fallback-')) + const binDir = join(root, 'bin') + const configDir = join(root, 'config') + const previousPath = process.env.PATH + const previousHome = process.env.HOME + + try { + await mkdir(binDir, { recursive: true }) + await mkdir(configDir, { recursive: true }) + const agentBin = join(binDir, 'agent') + await writeFile(agentBin, '#!/bin/sh\necho 2026.03.30-a5d3e17\n') + await chmod(agentBin, 0o755) + process.env.HOME = join(root, 'home') + process.env.PATH = `${binDir}:${join(root, 'empty')}` + + const ports = createProductionCoreReadPorts({ providerRegistry: scriptAbsentRegistry() }) + const outcome = await runCoreInvocation(undefined, context => + ports.inspectAgent('cursor', { ...context, configDir }), + ) + + expect(outcome).toMatchObject({ + kind: 'success', + value: { + pathExecutable: { + path: await realpath(agentBin), + present: true, + version: '2026.03.30-a5d3e17', + }, + }, + }) + } finally { + process.env.PATH = previousPath + process.env.HOME = previousHome + await rm(root, { force: true, recursive: true }) + } + }) }) function bunRegistry(version: string | undefined = '0.73.1'): ProviderRegistry { @@ -247,3 +332,23 @@ function bunRegistry(version: string | undefined = '0.73.1'): ProviderRegistry { list: () => [adapter], } } + +function scriptAbsentRegistry(): ProviderRegistry { + const adapter = { + async availability() { + return { kind: 'success' as const, value: { executable: 'sh' } } + }, + id: 'script' as const, + async observe(request: ProviderTargetRequest) { + return { + kind: 'success' as const, + value: { evidence: [], kind: 'absent' as const, target: request.target }, + } + }, + } + return { + get: id => (id === 'script' ? adapter : undefined), + getCapabilities: id => (id === 'script' ? ['availability', 'observe'] : []), + list: () => [adapter], + } +} diff --git a/test/utils/executable-resolution.test.ts b/test/utils/executable-resolution.test.ts index 92fa97a4..3dc745e7 100644 --- a/test/utils/executable-resolution.test.ts +++ b/test/utils/executable-resolution.test.ts @@ -3,7 +3,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' import { afterEach, describe, expect, it, vi } from 'vitest' -import { getExecutableCandidateNames, getKnownAgentInstallDirectories } from '../../src/utils/executable-search-paths' +import { + getExecutableCandidateNames, + getKnownAgentInstallDirectories, + uniqueExecutableLookupNames, +} from '../../src/utils/executable-search-paths' const mockSpawn = vi.hoisted(() => vi.fn()) @@ -127,6 +131,38 @@ describe('resolveAgentExecutablePath', () => { expect(await resolveAgentExecutablePath('definitely-missing-agent')).toBeUndefined() }) + it('tries preferred names completely before falling back to binaryName', async () => { + if (process.platform === 'win32') return + + const home = await mkdtemp(join(tmpdir(), 'qtx-resolution-preferred-')) + const binDirectory = join(home, '.local', 'bin') + await mkdir(binDirectory, { recursive: true }) + const preferred = join(binDirectory, 'cursor-agent') + await writeFile(preferred, '#!/bin/sh\n') + await chmod(preferred, 0o755) + + vi.stubEnv('HOME', home) + const { resolveAgentExecutablePath } = await import('../../src/utils/executable-resolution') + mockSpawn.mockImplementation((argv: string[]) => { + const name = argv[1] + if (name === 'agent') return createMockProcess(0, '/usr/bin/agent\n') + return createMockProcess(1, '') + }) + + expect(await resolveAgentExecutablePath(['cursor-agent', 'agent'])).toBe(preferred) + }) + + it('falls back to a later lookup name when earlier names miss', async () => { + const { resolveAgentExecutablePath } = await import('../../src/utils/executable-resolution') + mockSpawn.mockImplementation((argv: string[]) => { + const name = argv[1] + if (name === 'agent') return createMockProcess(0, '/usr/bin/agent\n') + return createMockProcess(1, '') + }) + + expect(await resolveAgentExecutablePath(['cursor-agent', 'agent'])).toBe('/usr/bin/agent') + }) + it('does not treat a non-executable file as a resolved agent', async () => { if (process.platform === 'win32') return @@ -143,3 +179,13 @@ describe('resolveAgentExecutablePath', () => { expect(await resolveAgentExecutablePath('agy')).toBeUndefined() }) }) + +describe('uniqueExecutableLookupNames', () => { + it('places preferred binaries before binaryName and drops duplicates', () => { + expect(uniqueExecutableLookupNames('agent', ['cursor-agent', 'agent', 'cursor-agent'])).toEqual([ + 'cursor-agent', + 'agent', + ]) + expect(uniqueExecutableLookupNames('agent')).toEqual(['agent']) + }) +}) diff --git a/test/utils/version.test.ts b/test/utils/version.test.ts index 313c587f..09397247 100644 --- a/test/utils/version.test.ts +++ b/test/utils/version.test.ts @@ -156,6 +156,20 @@ describe('getInstalledVersion', () => { expect(mockSpawn).toHaveBeenCalledWith(['/home/agent/.local/bin/agy', 'version'], expect.any(Object)) }) + it('substitutes a resolved path whose basename differs from binaryName', async () => { + const { probeInstalledVersion } = await import('../../src/utils/version') + mockSpawn.mockReturnValue(createMockProcess(0, '2026.09.02-c22c1a3\n')) + + await probeInstalledVersion( + 'agent', + { command: ['agent', '--version'] }, + undefined, + '/home/agent/.local/bin/cursor-agent', + ) + + expect(mockSpawn).toHaveBeenCalledWith(['/home/agent/.local/bin/cursor-agent', '--version'], expect.any(Object)) + }) + it('leaves a probe command that does not lead with the executable untouched', async () => { const { probeInstalledVersion } = await import('../../src/utils/version') mockSpawn.mockReturnValue(createMockProcess(0, '1.2.3\n')) From c852e85fadff91e4ba03cf7d409bc068e3c1da6b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 14:27:41 +0000 Subject: [PATCH 3/5] test: keep which on PATH in Cursor version-probe fixtures The fallback fixture omitted /usr/bin, so which could not spawn and Cursor looked absent. --- openspec/changes/cursor-version-probe-targeting/tasks.md | 8 ++++---- test/core/production-observation.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openspec/changes/cursor-version-probe-targeting/tasks.md b/openspec/changes/cursor-version-probe-targeting/tasks.md index d6bbb33b..c60fef8c 100644 --- a/openspec/changes/cursor-version-probe-targeting/tasks.md +++ b/openspec/changes/cursor-version-probe-targeting/tasks.md @@ -21,7 +21,7 @@ ## 4. Validation and delivery -- [ ] 4.1 Run `bun run lint`, `bun run format:check`, `bun run typecheck` -- [ ] 4.2 Run `bun run test` -- [ ] 4.3 Run `bun run openspec:validate` -- [ ] 4.4 Commit, push, and open a draft PR linking #715 +- [x] 4.1 Run `bun run lint`, `bun run format:check`, `bun run typecheck` +- [x] 4.2 Run `bun run test` +- [x] 4.3 Run `bun run openspec:validate` +- [x] 4.4 Commit, push, and open a draft PR linking #715 diff --git a/test/core/production-observation.test.ts b/test/core/production-observation.test.ts index db379a15..f6b1f0b2 100644 --- a/test/core/production-observation.test.ts +++ b/test/core/production-observation.test.ts @@ -243,7 +243,7 @@ describe('production Core observation', () => { await writeFile(cursorAgent, '#!/bin/sh\necho 2026.09.02-c22c1a3\n') await chmod(cursorAgent, 0o755) process.env.HOME = home - process.env.PATH = `${collidingBin}:${join(root, 'empty')}` + process.env.PATH = `${collidingBin}:/usr/bin:/bin` const ports = createProductionCoreReadPorts({ providerRegistry: scriptAbsentRegistry() }) const outcome = await runCoreInvocation(undefined, context => @@ -283,7 +283,7 @@ describe('production Core observation', () => { await writeFile(agentBin, '#!/bin/sh\necho 2026.03.30-a5d3e17\n') await chmod(agentBin, 0o755) process.env.HOME = join(root, 'home') - process.env.PATH = `${binDir}:${join(root, 'empty')}` + process.env.PATH = `${binDir}:/usr/bin:/bin` const ports = createProductionCoreReadPorts({ providerRegistry: scriptAbsentRegistry() }) const outcome = await runCoreInvocation(undefined, context => From fe820ac4c2c65b9b8bf8088b841178aa95de4905 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 14:39:46 +0000 Subject: [PATCH 4/5] test(compat): refresh v1 root-declaration after preferredBinaries Keep preferredBinaries documented with line comments so tsdown does not emit JSDoc into the pinned dist/index.d.mts, then update the maintained v1 signature fixture to the new bytes/sha256. --- src/agents/types.ts | 7 +++---- test/fixtures/compatibility/v1/root-declaration.json | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/agents/types.ts b/src/agents/types.ts index 5cc93a83..0e16272c 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -47,10 +47,9 @@ export interface AgentSelfUpdate { export interface AgentVersionProbe { command?: string[] parser?: (stdout: string) => string | undefined - /** - * Names tried before `binaryName` when locating the executable to probe. - * Identity (`binaryName`, lookup aliases) stays unchanged. - */ + // Names tried before `binaryName` when locating the executable to probe. + // Identity (`binaryName`, lookup aliases) stays unchanged. Line comments + // keep this note out of the pinned v1 dist/index.d.mts signature. preferredBinaries?: string[] } diff --git a/test/fixtures/compatibility/v1/root-declaration.json b/test/fixtures/compatibility/v1/root-declaration.json index 109c1e17..99c4be27 100644 --- a/test/fixtures/compatibility/v1/root-declaration.json +++ b/test/fixtures/compatibility/v1/root-declaration.json @@ -1,4 +1,4 @@ { - "bytes": 49262, - "sha256": "8a219038d08076caf26848dade2872ede86c823b3bccf55d421fed0c9d411d9f" + "bytes": 49485, + "sha256": "9c32e2d562599554738bb7a5b7b117d601ba0393d9ed5bb5647bb98eb88232b3" } From 36ca3ccbfe63125338e6c452fbdfaeae3a37ab44 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 14:48:20 +0000 Subject: [PATCH 5/5] fix(agents): keep Cursor probe targeting off the v1 type surface Prefer cursor-agent via an internal lookup table keyed by canonical name. Restore AgentVersionProbe, catalog JSON/schema, and the v1 root-declaration pin so the exported type and symbol sets stay unchanged. --- .../cursor-version-probe-targeting/design.md | 17 ++++++++++------ .../proposal.md | 18 ++++++++--------- .../specs/agent-catalog/spec.md | 7 +++++-- .../specs/agent-version-probing/spec.md | 6 +++--- .../specs/lifecycle-reconciliation/spec.md | 2 +- .../cursor-version-probe-targeting/tasks.md | 20 +++++++++---------- scripts/build/write-core-agent-catalog.ts | 11 +--------- src/agents/catalog.schema.json | 8 -------- src/agents/catalog/cursor.json | 3 +-- src/agents/schema.ts | 1 - src/agents/types.ts | 4 ---- src/core/generated/agent-catalog.ts | 1 - src/core/production-observation.ts | 7 ++----- src/core/update-production.ts | 7 ++----- src/inspection/agents.ts | 4 ++-- src/services/lifecycle-observations.ts | 7 ++----- src/utils/executable-search-paths.ts | 10 ++++++++++ test/agents.test.ts | 3 ++- test/architecture/core-agent-catalog.test.ts | 11 +--------- .../compatibility/v1/root-declaration.json | 4 ++-- test/utils/executable-resolution.test.ts | 8 ++++++++ 21 files changed, 72 insertions(+), 87 deletions(-) diff --git a/openspec/changes/cursor-version-probe-targeting/design.md b/openspec/changes/cursor-version-probe-targeting/design.md index 4815a291..2a3a3afb 100644 --- a/openspec/changes/cursor-version-probe-targeting/design.md +++ b/openspec/changes/cursor-version-probe-targeting/design.md @@ -8,36 +8,41 @@ That substitution already matches `lifecycle-reconciliation`: probes go through - lookup aliases MUST NOT repeat the canonical name `cursor` - v1 list/inspect goldens include `binaryName: "agent"` +A later attempt added `versionProbe.preferredBinaries` to the public `AgentVersionProbe` type and catalog schema. That expanded the v1 root declaration type set (`dist/index.d.mts` pin) and is rejected: pin updates are allowed only for reordering/size/hash drift that does not change the exported type set or symbol set. + ## Goals / Non-Goals **Goals:** - `list` / `inspect` installed-version for Cursor matches the Cursor executable Quantex resolved. - Prefer a unique Cursor executable name (`cursor-agent`) when locating that binary, then probe via the resolved absolute path. -- Keep catalog identity and CLI contracts frozen. +- Keep catalog identity, CLI contracts, and the v1 root declaration type/export set frozen. **Non-Goals:** - Renaming the agent, changing `binaryName`, or adding `cursor` as a lookup alias. +- Adding catalog fields or public type members such as `preferredBinaries`. - Widening `--json` / exit codes / inspect-list field sets. - Heuristic fingerprinting of arbitrary `agent` binaries. -- Self-upgrade, release-core.yml, protect-main, command/SDK expansion. +- Self-upgrade, release-core.yml, protect-main, YAML, P9, config fold, command/SDK expansion. - Changing `selfUpdate.command` (`agent update`). ## Decisions -1. **Preferred binaries are probe-targeting metadata, not identity.** Add optional `versionProbe.preferredBinaries: string[]`. Cursor sets `["cursor-agent"]`. `binaryName` stays `agent`. Lookup aliases stay `["agent"]`. CLI `--json` does not grow a new field. +1. **Probe targeting is an internal table, not a catalog or v1 type field.** Key extra lookup names by canonical agent name (`cursor` → `cursor-agent`). `binaryName` stays `agent`. Lookup aliases stay `["agent"]`. `AgentVersionProbe`, catalog JSON, catalog schema, and Core catalog projection stay unchanged. CLI `--json` does not grow a new field. -2. **Resolve preferred names completely before falling back.** For each name in `[...preferredBinaries, binaryName]` (deduped, first wins): look up `PATH`, then known install directories. Stop at the first hit. That way a `cursor-agent` in `~/.local/bin` wins over an unrelated `agent` earlier on `PATH`. +2. **Resolve preferred names completely before falling back.** For Cursor, try `cursor-agent` then `agent` (deduped, first wins): look up `PATH`, then known install directories. Stop at the first hit. That way a `cursor-agent` in `~/.local/bin` wins over an unrelated `agent` earlier on `PATH`. Other agents keep a single-name lookup of `binaryName`. 3. **Keep the existing substitution rule.** Probe command remains `["agent", "--version"]`, so `command[0] === binaryName` still substitutes the resolved absolute path (which may be `.../cursor-agent`). Custom probes whose first argument is not the executable name stay unchanged. -4. **One targeting helper, used by Core observation and the shared resolver.** Core list/inspect goes through `production-observation`. The same lookup-name order is applied in `resolveAgentExecutablePath` so update observation and leftover inspection paths do not drift. Do not edit KEEP-marked leftover comments or self-upgrade modules. +4. **One targeting helper, used by Core observation and the shared resolver.** Core list/inspect goes through `production-observation`. The same lookup-name order is applied in `resolveAgentExecutablePath` so update observation and leftover inspection paths do not drift. Do not edit KEEP-marked leftover comments or self-upgrade modules. Keep the helper in `src/utils/executable-search-paths.ts` so Core's allowed runtime closure does not grow. 5. **Fallback remains `agent`.** If `cursor-agent` is absent, behavior stays PATH-then-known-dir lookup of `agent`. That preserves single-name installs; it cannot disambiguate two unrelated `agent` binaries without identity churn. +6. **v1 root-declaration pin.** Refresh `test/fixtures/compatibility/v1/root-declaration.json` only if the built `dist/index.d.mts` bytes/hash drift from reordering or size with an unchanged exported type set and symbol set. Do not expand root exports under the guise of fixing the probe. + ## Risks / Trade-offs - **Preferred name in known dirs beats PATH `agent`.** Intentional: the unique Cursor name is stronger evidence than a generic PATH hit. - **Fallback still collides** when only a non-Cursor `agent` exists. Accepted; fixing that would require fingerprinting or renaming `binaryName`. -- **Generated catalogs must copy `preferredBinaries`.** Core observation reads the generated Core catalog; omitting the field would leave list/inspect on `agent` only. +- **Cursor targeting is a named special case.** Accepted over a public catalog field: the v1 type/export freeze outranks making this data-driven in catalog JSON. diff --git a/openspec/changes/cursor-version-probe-targeting/proposal.md b/openspec/changes/cursor-version-probe-targeting/proposal.md index 2c0ad862..e1d91a42 100644 --- a/openspec/changes/cursor-version-probe-targeting/proposal.md +++ b/openspec/changes/cursor-version-probe-targeting/proposal.md @@ -1,13 +1,14 @@ ## Why -`qtx ls` and `qtx inspect cursor` sometimes report a version from an unrelated `agent` binary on `PATH`. Cursor CLI's catalog identity uses `binaryName: agent` and probes `agent --version`, so the first `agent` wins even when Quantex has already resolved, or can resolve, the actual Cursor executable. Closed PR #714 tried to dodge the collision by renaming the catalog binary and adding `cursor` as an alias; that failed golden and identity tests. The version field must come from the Cursor executable Quantex resolved, without catalog identity churn. +`qtx ls` and `qtx inspect cursor` sometimes report a version from an unrelated `agent` binary on `PATH`. Cursor CLI's catalog identity uses `binaryName: agent` and probes `agent --version`, so the first `agent` wins even when Quantex has already resolved, or can resolve, the actual Cursor executable. Closed PR #714 tried to dodge the collision by renaming the catalog binary and adding `cursor` as an alias; that failed golden and identity tests. The version field must come from the Cursor executable Quantex resolved, without catalog identity churn or v1 root-declaration expansion. ## What Changes - Keep Cursor's catalog identity unchanged: canonical name `cursor`, `binaryName` `agent`, lookup aliases `["agent"]` (aliases MUST NOT include the canonical name). - Teach version-probe targeting to locate Cursor through a more specific executable name (`cursor-agent`) before falling back to `agent`, then probe the resolved absolute path. -- Do not widen CLI `--json` fields, exit codes, or list/inspect contract shapes. -- Do not rename slugs, expand commands/SDK, or touch self-upgrade / release-core / protect-main paths. +- Do that with an internal targeting table keyed by canonical agent name. Do not add catalog fields, public type members, or root exports. +- Do not widen CLI `--json` fields, exit codes, list/inspect contract shapes, or the v1 root declaration type/export set. +- Do not rename slugs, expand commands/SDK, or touch self-upgrade / release-core / protect-main / YAML / P9 / config-fold paths. ## Capabilities @@ -18,20 +19,19 @@ None. ### Modified Capabilities - `agent-version-probing`: Installed-version evidence for an observed agent MUST come from the executable Quantex resolved for that agent, not from a bare `PATH` name that can collide. -- `agent-catalog`: Cursor CLI keeps `binaryName` `agent` and alias `agent`, and declares a preferred probe executable `cursor-agent` used only to locate the binary. -- `lifecycle-reconciliation`: Observation already substitutes a resolved path into probes whose first argument is the agent's executable name; add the PATH-collision case where a preferred, more specific executable is resolved first. +- `agent-catalog`: Cursor CLI keeps `binaryName` `agent` and alias `agent`. Probe targeting MAY prefer `cursor-agent` internally without changing catalog JSON, public types, or v1 exports. +- `lifecycle-reconciliation`: Observation already substitutes a resolved path into probes whose first argument is the agent's executable name; add the PATH-collision case where a more specific Cursor executable is resolved first. ## Impact -- `src/agents/catalog/cursor.json` and generated catalog/core catalog projections -- `src/agents/types.ts`, `src/agents/schema.ts`, generated `catalog.schema.json` +- `src/utils/executable-search-paths.ts` (internal Cursor probe targeting) - `src/core/production-observation.ts` (list/inspect observation) -- `src/utils/executable-resolution.ts`, `src/utils/executable-search-paths.ts` +- `src/utils/executable-resolution.ts` - `src/services/lifecycle-observations.ts`, `src/inspection/agents.ts`, `src/core/update-production.ts` (same targeting rule) - `test/agents.test.ts`, `test/core/production-observation.test.ts`, `test/utils/executable-resolution.test.ts` - OpenSpec deltas under this change -No CLI command catalog, schema-version, or `--json` field changes. Launch argv already uses the observed executable path, so correcting observation also aims execution at that same binary without changing the execution executor. +No catalog JSON, `AgentVersionProbe`, catalog schema, CLI command catalog, schema-version, or `--json` field changes. Launch argv already uses the observed executable path, so correcting observation also aims execution at that same binary without changing the execution executor. ## Intake classification diff --git a/openspec/changes/cursor-version-probe-targeting/specs/agent-catalog/spec.md b/openspec/changes/cursor-version-probe-targeting/specs/agent-catalog/spec.md index 98d32282..0f1d6e16 100644 --- a/openspec/changes/cursor-version-probe-targeting/specs/agent-catalog/spec.md +++ b/openspec/changes/cursor-version-probe-targeting/specs/agent-catalog/spec.md @@ -2,9 +2,11 @@ ### Requirement: Cursor CLI catalog identity MUST stay `agent` while version probing prefers `cursor-agent` -Quantex SHALL keep Cursor CLI in the supported agent catalog with canonical name `cursor`, executable `binaryName` `agent`, and lookup alias `agent`. Lookup aliases MUST NOT include the canonical name `cursor`. The catalog SHALL declare `cursor-agent` as a preferred version-probe binary so observation can locate Cursor CLI without renaming the catalog identity. +Quantex SHALL keep Cursor CLI in the supported agent catalog with canonical name `cursor`, executable `binaryName` `agent`, and lookup alias `agent`. Lookup aliases MUST NOT include the canonical name `cursor`. -Structured `list` and `inspect` output SHALL continue to report `binaryName` as `agent`. Preferred probe binaries are catalog targeting metadata and MUST NOT appear as a new CLI `--json` field. +When locating Cursor CLI for version probing, Quantex SHALL try the more specific executable name `cursor-agent` before falling back to `agent`. That targeting rule is internal. The catalog entry, public `AgentVersionProbe` type, catalog schema, and v1 root declaration MUST NOT gain a preferred-binaries field or any other new member to carry it. + +Structured `list` and `inspect` output SHALL continue to report `binaryName` as `agent`. Probe targeting names MUST NOT appear as a new CLI `--json` field. #### Scenario: Looking up Cursor CLI @@ -12,6 +14,7 @@ Structured `list` and `inspect` output SHALL continue to report `binaryName` as - **THEN** Quantex returns the Cursor CLI catalog entry - **AND** the entry identifies `agent` as the executable `binaryName` - **AND** the entry's lookup aliases are `agent` and do not include `cursor` +- **AND** the catalog entry has no preferred-binaries field #### Scenario: Probing Cursor CLI version diff --git a/openspec/changes/cursor-version-probe-targeting/specs/agent-version-probing/spec.md b/openspec/changes/cursor-version-probe-targeting/specs/agent-version-probing/spec.md index e6a867e9..661e3933 100644 --- a/openspec/changes/cursor-version-probe-targeting/specs/agent-version-probing/spec.md +++ b/openspec/changes/cursor-version-probe-targeting/specs/agent-version-probing/spec.md @@ -4,11 +4,11 @@ When Quantex reports an installed version for a catalog agent, that version SHALL be parsed from a probe of the executable Quantex resolved for that agent. Quantex MUST NOT spawn a bare executable name from `PATH` when it already holds a resolved absolute path whose probe command leads with the agent's declared executable name. -When the catalog declares preferred binaries for version-probe targeting, Quantex SHALL locate the executable by trying those names in order before the agent's `binaryName`, completing PATH lookup and known-install-directory fallback for each name before moving to the next. The first existing executable wins. The installed-version field on `list` and `inspect` SHALL match that resolved executable. +For Cursor CLI, Quantex SHALL locate the executable by trying `cursor-agent` before `binaryName` `agent`, completing PATH lookup and known-install-directory fallback for each name before moving to the next. The first existing executable wins. Other catalog agents SHALL keep single-name lookup of `binaryName`. The installed-version field on `list` and `inspect` SHALL match that resolved executable. #### Scenario: A preferred Cursor executable exists beside an unrelated PATH agent -- **GIVEN** Cursor CLI is catalogued with `binaryName` `agent` and preferred probe binary `cursor-agent` +- **GIVEN** Cursor CLI is catalogued with `binaryName` `agent` - **AND** `PATH` contains an `agent` executable that is not Cursor CLI - **AND** a `cursor-agent` executable exists on `PATH` or in a known install directory - **WHEN** Quantex lists agents or inspects `cursor` @@ -17,7 +17,7 @@ When the catalog declares preferred binaries for version-probe targeting, Quante #### Scenario: Preferred binary is absent -- **GIVEN** no preferred probe binary resolves +- **GIVEN** no `cursor-agent` executable resolves - **AND** the agent's `binaryName` resolves on `PATH` or in a known install directory - **WHEN** Quantex probes the installed version - **THEN** Quantex uses that `binaryName` resolution and probes through its absolute path diff --git a/openspec/changes/cursor-version-probe-targeting/specs/lifecycle-reconciliation/spec.md b/openspec/changes/cursor-version-probe-targeting/specs/lifecycle-reconciliation/spec.md index 4a51f2cf..fc14c45a 100644 --- a/openspec/changes/cursor-version-probe-targeting/specs/lifecycle-reconciliation/spec.md +++ b/openspec/changes/cursor-version-probe-targeting/specs/lifecycle-reconciliation/spec.md @@ -2,7 +2,7 @@ ### Requirement: Executable resolution MAY prefer a more specific binary name before `binaryName` -When locating an agent executable for observation, Quantex SHALL try any catalog-declared preferred probe binaries before the agent's `binaryName`. Each name is resolved with the existing PATH-then-known-install-directory rule. The first hit is the resolved executable used for presence, reported path, and version probing. +When locating Cursor CLI for observation, Quantex SHALL try `cursor-agent` before the agent's `binaryName` `agent`. Each name is resolved with the existing PATH-then-known-install-directory rule. The first hit is the resolved executable used for presence, reported path, and version probing. This targeting rule is internal and MUST NOT add a catalog field or a v1 public type member. This does not change the substitution rule: a catalog version-probe command whose first argument is the agent's executable name is still invoked through the resolved absolute path, even when that path's basename differs from `binaryName`. diff --git a/openspec/changes/cursor-version-probe-targeting/tasks.md b/openspec/changes/cursor-version-probe-targeting/tasks.md index c60fef8c..9b1b592b 100644 --- a/openspec/changes/cursor-version-probe-targeting/tasks.md +++ b/openspec/changes/cursor-version-probe-targeting/tasks.md @@ -1,23 +1,23 @@ -## 1. Catalog targeting metadata +## 1. Internal Cursor probe targeting -- [x] 1.1 Add optional `versionProbe.preferredBinaries` to `AgentVersionProbe`, catalog schema, and Core catalog projection -- [x] 1.2 Set Cursor `preferredBinaries` to `["cursor-agent"]` without changing `name`, `binaryName`, or `lookupAliases` -- [x] 1.3 Regenerate agent catalog manifests and Core catalog +- [x] 1.1 Add an internal lookup-name helper that tries `cursor-agent` before Cursor's `binaryName` without changing public types, catalog JSON, or catalog schema +- [x] 1.2 Keep Cursor identity frozen: `name` `cursor`, `binaryName` `agent`, lookup aliases `["agent"]` +- [x] 1.3 Do not project a preferred-binaries field through Core catalog generation ## 2. Resolve and probe the preferred executable -- [x] 2.1 Add a lookup-name helper that tries preferred binaries before `binaryName` -- [x] 2.2 Resolve each name with PATH then known install directories, first hit wins -- [x] 2.3 Use that order in Core production observation (list/inspect) -- [x] 2.4 Apply the same order in shared executable resolution and leftover observation/inspect/update probe call sites -- [x] 2.5 Keep substituting the resolved absolute path into probes whose first argument is `binaryName` +- [x] 2.1 Resolve each name with PATH then known install directories, first hit wins +- [x] 2.2 Use that order in Core production observation (list/inspect) +- [x] 2.3 Apply the same order in shared executable resolution and leftover observation/inspect/update probe call sites +- [x] 2.4 Keep substituting the resolved absolute path into probes whose first argument is `binaryName` ## 3. Tests -- [x] 3.1 Lock Cursor identity: `binaryName` is `agent`, aliases are `["agent"]` and do not include `cursor`, probe command stays `["agent", "--version"]` +- [x] 3.1 Lock Cursor identity: `binaryName` is `agent`, aliases are `["agent"]` and do not include `cursor`, probe command stays `["agent", "--version"]`, and the catalog entry has no preferred-binaries field - [x] 3.2 Assert list/inspect version comes from `cursor-agent` when PATH has a different `agent` - [x] 3.3 Assert fallback to `agent` when `cursor-agent` is absent - [x] 3.4 Do not update v1 command goldens unless `binaryName` in JSON actually changed (it must not) +- [x] 3.5 Keep the v1 root declaration type set and exported symbol set unchanged; refresh the bytes/sha256 pin only if size/hash drifted without type or export expansion ## 4. Validation and delivery diff --git a/scripts/build/write-core-agent-catalog.ts b/scripts/build/write-core-agent-catalog.ts index 7cc3a68e..39f3cb7b 100644 --- a/scripts/build/write-core-agent-catalog.ts +++ b/scripts/build/write-core-agent-catalog.ts @@ -152,16 +152,7 @@ function projectCoreAgent(agent: AgentDefinition): AgentDefinition { })), ]), ) as AgentDefinition['platforms'], - ...(agent.versionProbe - ? { - versionProbe: { - command: agent.versionProbe.command, - ...(agent.versionProbe.preferredBinaries?.length - ? { preferredBinaries: [...agent.versionProbe.preferredBinaries] } - : {}), - }, - } - : {}), + ...(agent.versionProbe ? { versionProbe: { command: agent.versionProbe.command } } : {}), } } diff --git a/src/agents/catalog.schema.json b/src/agents/catalog.schema.json index d2c0ea4a..17fe9c33 100644 --- a/src/agents/catalog.schema.json +++ b/src/agents/catalog.schema.json @@ -435,14 +435,6 @@ "type": "string", "minLength": 1 } - }, - "preferredBinaries": { - "minItems": 1, - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } } }, "additionalProperties": false diff --git a/src/agents/catalog/cursor.json b/src/agents/catalog/cursor.json index 99ebb4cc..9037aaf7 100644 --- a/src/agents/catalog/cursor.json +++ b/src/agents/catalog/cursor.json @@ -8,8 +8,7 @@ "command": ["agent", "update"] }, "versionProbe": { - "command": ["agent", "--version"], - "preferredBinaries": ["cursor-agent"] + "command": ["agent", "--version"] }, "platforms": { "windows": [ diff --git a/src/agents/schema.ts b/src/agents/schema.ts index b7ed2ee1..a0aae16c 100644 --- a/src/agents/schema.ts +++ b/src/agents/schema.ts @@ -173,7 +173,6 @@ export const agentSelfUpdateSchema = z export const agentVersionProbeSchema = z .object({ command: commandSchema.optional(), - preferredBinaries: z.array(nonEmptyStringSchema).min(1).optional(), }) .strict() .refine(probe => probe.command !== undefined, { diff --git a/src/agents/types.ts b/src/agents/types.ts index 0e16272c..d3ae82e4 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -47,10 +47,6 @@ export interface AgentSelfUpdate { export interface AgentVersionProbe { command?: string[] parser?: (stdout: string) => string | undefined - // Names tried before `binaryName` when locating the executable to probe. - // Identity (`binaryName`, lookup aliases) stays unchanged. Line comments - // keep this note out of the pinned v1 dist/index.d.mts signature. - preferredBinaries?: string[] } export interface AgentDefinition { diff --git a/src/core/generated/agent-catalog.ts b/src/core/generated/agent-catalog.ts index 7cbc14fc..8ac6a54f 100644 --- a/src/core/generated/agent-catalog.ts +++ b/src/core/generated/agent-catalog.ts @@ -495,7 +495,6 @@ export const coreAgentCatalog: readonly AgentDefinition[] = [ }, versionProbe: { command: ['agent', '--version'], - preferredBinaries: ['cursor-agent'], }, }, { diff --git a/src/core/production-observation.ts b/src/core/production-observation.ts index 649f1906..45386e42 100644 --- a/src/core/production-observation.ts +++ b/src/core/production-observation.ts @@ -14,9 +14,9 @@ import { observeAgentLifecycle } from '../lifecycle/agent-observation' import { resolveInstallMethodProviderBinding } from '../lifecycle/provider-binding' import { createEmptyStateDocument, parseStateDocument, StateSchemaError } from '../state/schema' import { + executableLookupNamesForAgent, getExecutableCandidateNames, getKnownAgentInstallDirectories, - uniqueExecutableLookupNames, } from '../utils/executable-search-paths' import { getCoreAgentByNameOrAlias, getCoreAgents } from './agent-catalog' import { createCoreProviderObservationRegistry } from './provider-observation-registry' @@ -141,10 +141,7 @@ async function inspectExecutable( agent: AgentDefinition, context: ProviderOperationContext, ): Promise { - const path = await findExecutable( - uniqueExecutableLookupNames(agent.binaryName, agent.versionProbe?.preferredBinaries), - context, - ) + const path = await findExecutable(executableLookupNamesForAgent(agent), context) if (!path) return { present: false } const executablePath = (await resolveExecutablePath(path, context.signal)) ?? path const version = await inspectVersion(agent, executablePath, context) diff --git a/src/core/update-production.ts b/src/core/update-production.ts index 930c8a27..fb56e903 100644 --- a/src/core/update-production.ts +++ b/src/core/update-production.ts @@ -18,7 +18,7 @@ import { firstPartyProviderRegistry } from '../providers' import { getInstalledAgentState, getLifecycleReceipt, lifecycleReceiptStore, loadState } from '../state' import { getPlatform } from '../utils/detect' import { resolveAgentExecutablePath } from '../utils/executable-resolution' -import { uniqueExecutableLookupNames } from '../utils/executable-search-paths' +import { executableLookupNamesForAgent } from '../utils/executable-search-paths' import { isResourceLockError } from '../utils/lock' import { getResolvedBinaryPath, probeInstalledVersion } from '../utils/version' import { getCoreAgentByNameOrAlias, getCoreAgents } from './agent-catalog' @@ -159,10 +159,7 @@ async function inspectExecutable( agent: AgentDefinition, context: ProviderOperationContext, ): Promise { - const binaryPath = await resolveAgentExecutablePath( - uniqueExecutableLookupNames(agent.binaryName, agent.versionProbe?.preferredBinaries), - context, - ) + const binaryPath = await resolveAgentExecutablePath(executableLookupNamesForAgent(agent), context) if (!binaryPath) return { present: false } const version = await probeInstalledVersion(agent.binaryName, agent.versionProbe, context, binaryPath) const path = (await getResolvedBinaryPath(binaryPath, context)) ?? binaryPath diff --git a/src/inspection/agents.ts b/src/inspection/agents.ts index 1d7c39b1..6ca4b30f 100644 --- a/src/inspection/agents.ts +++ b/src/inspection/agents.ts @@ -3,7 +3,7 @@ import type { InstalledAgentState } from '../state' import { getManagedInstalledPackageVersion, getOrderedInstallMethods } from '../package-manager' import { getInstalledAgentState } from '../state' import { resolveAgentExecutablePath } from '../utils/executable-resolution' -import { uniqueExecutableLookupNames } from '../utils/executable-search-paths' +import { executableLookupNamesForAgent } from '../utils/executable-search-paths' import { formatInstalledSource, formatUpdateManagement, @@ -33,7 +33,7 @@ export async function inspectAgent(agent: AgentDefinition): Promise { // One resolution decides presence and feeds the version probe, so an agent // outside the inherited PATH is probed through the path it actually occupies. - const binaryPath = await resolveAgentExecutablePath( - uniqueExecutableLookupNames(agent.binaryName, agent.versionProbe?.preferredBinaries), - context, - ) + const binaryPath = await resolveAgentExecutablePath(executableLookupNamesForAgent(agent), context) if (!binaryPath) return { present: false } const version = await getObservedInstalledVersion(agent, installedState, context, binaryPath) diff --git a/src/utils/executable-search-paths.ts b/src/utils/executable-search-paths.ts index c0070c02..d4cabd59 100644 --- a/src/utils/executable-search-paths.ts +++ b/src/utils/executable-search-paths.ts @@ -59,6 +59,16 @@ export function uniqueExecutableLookupNames(binaryName: string, preferredBinarie return names } +// Cursor CLI installs as both `agent` and `cursor-agent`. Catalog identity stays +// `binaryName=agent`; this table is probe targeting only and is not a v1 export. +const PREFERRED_PROBE_BINARIES_BY_AGENT: Readonly> = { + cursor: ['cursor-agent'], +} + +export function executableLookupNamesForAgent(agent: { readonly name: string; readonly binaryName: string }): string[] { + return uniqueExecutableLookupNames(agent.binaryName, PREFERRED_PROBE_BINARIES_BY_AGENT[agent.name]) +} + /** * Candidate file names for one binary. POSIX uses the bare name; Windows appends * each executable extension so `agy` can resolve `agy.exe`. diff --git a/test/agents.test.ts b/test/agents.test.ts index fdfcd5c7..f9b06807 100644 --- a/test/agents.test.ts +++ b/test/agents.test.ts @@ -484,8 +484,9 @@ describe('cursor', () => { expect(cursor.binaryName).toBe('agent') expect(cursor.selfUpdate?.command).toEqual(['agent', 'update']) expect(cursor.versionProbe?.command).toEqual(['agent', '--version']) - expect(cursor.versionProbe?.preferredBinaries).toEqual(['cursor-agent']) expect(cursor.lookupAliases).toEqual(['agent']) + expect(cursor.lookupAliases).not.toContain('cursor') + expect(cursor.versionProbe).not.toHaveProperty('preferredBinaries') }) it('binary install returns correct strings per platform', () => { diff --git a/test/architecture/core-agent-catalog.test.ts b/test/architecture/core-agent-catalog.test.ts index 25e3d27e..00bfb8e2 100644 --- a/test/architecture/core-agent-catalog.test.ts +++ b/test/architecture/core-agent-catalog.test.ts @@ -31,15 +31,6 @@ function projectCoreAgent(agent: AgentDefinition): AgentDefinition { })), ]), ) as AgentDefinition['platforms'], - ...(agent.versionProbe - ? { - versionProbe: { - command: agent.versionProbe.command, - ...(agent.versionProbe.preferredBinaries?.length - ? { preferredBinaries: [...agent.versionProbe.preferredBinaries] } - : {}), - }, - } - : {}), + ...(agent.versionProbe ? { versionProbe: { command: agent.versionProbe.command } } : {}), } } diff --git a/test/fixtures/compatibility/v1/root-declaration.json b/test/fixtures/compatibility/v1/root-declaration.json index 99c4be27..109c1e17 100644 --- a/test/fixtures/compatibility/v1/root-declaration.json +++ b/test/fixtures/compatibility/v1/root-declaration.json @@ -1,4 +1,4 @@ { - "bytes": 49485, - "sha256": "9c32e2d562599554738bb7a5b7b117d601ba0393d9ed5bb5647bb98eb88232b3" + "bytes": 49262, + "sha256": "8a219038d08076caf26848dade2872ede86c823b3bccf55d421fed0c9d411d9f" } diff --git a/test/utils/executable-resolution.test.ts b/test/utils/executable-resolution.test.ts index 3dc745e7..29842cd1 100644 --- a/test/utils/executable-resolution.test.ts +++ b/test/utils/executable-resolution.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import process from 'node:process' import { afterEach, describe, expect, it, vi } from 'vitest' import { + executableLookupNamesForAgent, getExecutableCandidateNames, getKnownAgentInstallDirectories, uniqueExecutableLookupNames, @@ -189,3 +190,10 @@ describe('uniqueExecutableLookupNames', () => { expect(uniqueExecutableLookupNames('agent')).toEqual(['agent']) }) }) + +describe('executableLookupNamesForAgent', () => { + it('prefers cursor-agent for Cursor without changing catalog identity names', () => { + expect(executableLookupNamesForAgent({ name: 'cursor', binaryName: 'agent' })).toEqual(['cursor-agent', 'agent']) + expect(executableLookupNamesForAgent({ name: 'claude', binaryName: 'claude' })).toEqual(['claude']) + }) +})