Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-06
48 changes: 48 additions & 0 deletions openspec/changes/cursor-version-probe-targeting/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
## 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"`

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, 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, YAML, P9, config fold, command/SDK expansion.
- Changing `selfUpdate.command` (`agent update`).

## Decisions

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 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. 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`.
- **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.
38 changes: 38 additions & 0 deletions openspec/changes/cursor-version-probe-targeting/proposal.md
Original file line number Diff line number Diff line change
@@ -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 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 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

### 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`. 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/utils/executable-search-paths.ts` (internal Cursor probe targeting)
- `src/core/production-observation.ts` (list/inspect observation)
- `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 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

Observable CLI version-probe / agent-catalog behavior change; OpenSpec required.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## 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`.

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

- **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`
- **AND** the catalog entry has no preferred-binaries field

#### 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`
Original file line number Diff line number Diff line change
@@ -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.

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** `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 `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
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## ADDED Requirements

### Requirement: Executable resolution MAY prefer a more specific binary name before `binaryName`

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`.

#### 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`
27 changes: 27 additions & 0 deletions openspec/changes/cursor-version-probe-targeting/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## 1. Internal Cursor probe targeting

- [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 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"]`, 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

- [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
37 changes: 24 additions & 13 deletions src/core/production-observation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
executableLookupNamesForAgent,
getExecutableCandidateNames,
getKnownAgentInstallDirectories,
} from '../utils/executable-search-paths'
import { getCoreAgentByNameOrAlias, getCoreAgents } from './agent-catalog'
import { createCoreProviderObservationRegistry } from './provider-observation-registry'
import { CoreProcessInterruptionError, runReadOnlyCommand } from './read-only-process'
Expand Down Expand Up @@ -137,7 +141,7 @@ async function inspectExecutable(
agent: AgentDefinition,
context: ProviderOperationContext,
): Promise<AgentExecutableObservation> {
const path = await findExecutable(agent.binaryName, 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)
Expand All @@ -148,19 +152,26 @@ async function inspectExecutable(
}
}

async function findExecutable(binaryName: string, context: ProviderOperationContext): Promise<string | undefined> {
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<string | undefined> {
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<string | undefined> {
Expand Down
3 changes: 2 additions & 1 deletion src/core/update-production.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { executableLookupNamesForAgent } from '../utils/executable-search-paths'
import { isResourceLockError } from '../utils/lock'
import { getResolvedBinaryPath, probeInstalledVersion } from '../utils/version'
import { getCoreAgentByNameOrAlias, getCoreAgents } from './agent-catalog'
Expand Down Expand Up @@ -158,7 +159,7 @@ async function inspectExecutable(
agent: AgentDefinition,
context: ProviderOperationContext,
): Promise<AgentExecutableObservation> {
const binaryPath = await resolveAgentExecutablePath(agent.binaryName, 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
Expand Down
3 changes: 2 additions & 1 deletion src/inspection/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { executableLookupNamesForAgent } from '../utils/executable-search-paths'
import {
formatInstalledSource,
formatUpdateManagement,
Expand Down Expand Up @@ -32,7 +33,7 @@ export async function inspectAgent(agent: AgentDefinition): Promise<AgentInspect
const [methods, installedState, binaryPath] = await Promise.all([
getOrderedInstallMethods(agent),
getInstalledAgentState(agent.name),
resolveAgentExecutablePath(agent.binaryName),
resolveAgentExecutablePath(executableLookupNamesForAgent(agent)),
])
const inPath = binaryPath !== undefined

Expand Down
3 changes: 2 additions & 1 deletion src/services/lifecycle-observations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { createCliOperationContext } from '../runtime/cli-operation-context'
import { getInstalledAgentState, getLifecycleReceipt } from '../state'
import { getPlatform } from '../utils/detect'
import { resolveAgentExecutablePath } from '../utils/executable-resolution'
import { executableLookupNamesForAgent } from '../utils/executable-search-paths'
import { getLatestVersionPackage } from '../utils/install'
import { getLatestVersion, getResolvedBinaryPath, probeInstalledVersion } from '../utils/version'

Expand Down Expand Up @@ -171,7 +172,7 @@ async function inspectExecutable(
): Promise<AgentExecutableObservation> {
// 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(executableLookupNamesForAgent(agent), context)
if (!binaryPath) return { present: false }

const version = await getObservedInstalledVersion(agent, installedState, context, binaryPath)
Expand Down
Loading
Loading