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..2a3a3afb --- /dev/null +++ b/openspec/changes/cursor-version-probe-targeting/design.md @@ -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. 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..e1d91a42 --- /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 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. 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..0f1d6e16 --- /dev/null +++ b/openspec/changes/cursor-version-probe-targeting/specs/agent-catalog/spec.md @@ -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` 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..661e3933 --- /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. + +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 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..fc14c45a --- /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 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` 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..9b1b592b --- /dev/null +++ b/openspec/changes/cursor-version-probe-targeting/tasks.md @@ -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 diff --git a/src/core/production-observation.ts b/src/core/production-observation.ts index 434c6ab3..45386e42 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 { + 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' @@ -137,7 +141,7 @@ async function inspectExecutable( agent: AgentDefinition, context: ProviderOperationContext, ): Promise { - 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) @@ -148,19 +152,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..fb56e903 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 { executableLookupNamesForAgent } 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,7 @@ async function inspectExecutable( agent: AgentDefinition, context: ProviderOperationContext, ): Promise { - 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 diff --git a/src/inspection/agents.ts b/src/inspection/agents.ts index 065d55c6..6ca4b30f 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 { executableLookupNamesForAgent } 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(executableLookupNamesForAgent(agent), 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..d4cabd59 100644 --- a/src/utils/executable-search-paths.ts +++ b/src/utils/executable-search-paths.ts @@ -46,6 +46,29 @@ 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 +} + +// 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 2863d58e..f9b06807 100644 --- a/test/agents.test.ts +++ b/test/agents.test.ts @@ -484,6 +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.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/core/production-observation.test.ts b/test/core/production-observation.test.ts index 07545bfd..f6b1f0b2 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}:/usr/bin:/bin` + + 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}:/usr/bin:/bin` + + 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..29842cd1 100644 --- a/test/utils/executable-resolution.test.ts +++ b/test/utils/executable-resolution.test.ts @@ -3,7 +3,12 @@ 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 { + executableLookupNamesForAgent, + getExecutableCandidateNames, + getKnownAgentInstallDirectories, + uniqueExecutableLookupNames, +} from '../../src/utils/executable-search-paths' const mockSpawn = vi.hoisted(() => vi.fn()) @@ -127,6 +132,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 +180,20 @@ 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']) + }) +}) + +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']) + }) +}) 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'))