Skip to content

fix(hub): govern runner capabilities so Cursor reopen soft-fails on skew#1086

Closed
heavygee wants to merge 11 commits into
tiann:mainfrom
heavygee:fix/hub-runner-version-governance
Closed

fix(hub): govern runner capabilities so Cursor reopen soft-fails on skew#1086
heavygee wants to merge 11 commits into
tiann:mainfrom
heavygee:fix/hub-runner-version-governance

Conversation

@heavygee

Copy link
Copy Markdown
Collaborator

Summary

After #1037, Cursor reopen hard-failed when the hub was newer than a remote runner: missing cursor-chat-store-status was treated as deleted chat data even when store.db still existed.

This PR makes hub↔runner generation governance explicit:

  • Soft-fail Cursor reopen when the chat-store probe errors (missing handler / skew / unknown). Definitive onDisk: false still blocks.
  • Runners advertise version + capability set on machine metadata; hub declares required capabilities (starting with Cursor chat-store probe).
  • Unmissable persistent web banner on session/machines UI listing upgrade/restart steps for skewed hosts.
  • Ensure v1: if a newer CLI binary is already on disk (installedCliMtimeMsstartedCliMtimeMs), hub may call stop-runner so systemd/handoff loads the new generation; otherwise the banner stays.
  • Multi-machine install note pointing operators at the banner.
  • Machine selector shows CLI version + UPDATE REQUIRED when skewed.

Test plan

  • bun typecheck
  • bun run test (shared capability registry, soft-fail reopen, runner ensure, web reopen gate, skew banner)
  • Peer hub: live banner copy verified (Runner out of date on proxmox + upgrade/restart instructions)
  • Operator dogfood: upgrade hub first, leave remote runner old, confirm reopen is allowed with honest hint + banner; upgrade remote CLI and confirm banner clears

Issues

Fixes #1084

Hub↔runner protocol drift was reported as missing Cursor chat data when
cursor-chat-store-status was unregistered. Soft-fail reopen on probe errors,
advertise required machine capabilities, surface an unmissable upgrade banner,
and stop-runner when a newer CLI binary is already on disk.

Fixes tiann#1084

Co-authored-by: Cursor <cursoragent@cursor.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Refresh full runner capability metadata for existing machines — /cli/machines returns an existing machine row unchanged (hub/src/store/machines.ts:44), so the new buildMachineMetadata({ ... }) payload passed by a restarted runner (cli/src/runner/run.ts:773) is ignored for every already-known machine. The new heartbeat refresh only writes installedCliMtimeMs (cli/src/api/apiMachine.ts:556), leaving old/missing capabilities and stale startedCliMtimeMs in place. Result: an upgraded runner can keep looking skewed forever; with stale mtime drift retained, the hub can repeatedly call stop-runner against the fresh runner (hub/src/sync/syncEngine.ts:474).
    Suggested fix:
    const installedCliMtimeMs = getInstalledCliMtimeMs()
    const nextCapabilities = [...CURRENT_MACHINE_CAPABILITIES]
    const needsCapabilityRefresh = !arraysEqual(
        this.machine.metadata?.capabilities ?? [],
        nextCapabilities,
    )
    const needsMtimeRefresh = typeof installedCliMtimeMs === 'number'
        && this.machine.metadata?.installedCliMtimeMs !== installedCliMtimeMs
    
    if (this.machine.metadata && (needsCapabilityRefresh || needsMtimeRefresh)) {
        void this.updateMachineMetadata((current) => ({
            ...(current ?? this.machine.metadata!),
            capabilities: nextCapabilities,
            ...(typeof installedCliMtimeMs === 'number' ? { installedCliMtimeMs } : {}),
            // Also refresh startedCliMtimeMs from the runner start snapshot, not the old DB row.
        }))
    }
  • [Major] Preserve the runner handoff opt-out before hub-initiated stops — the existing runner explicitly skips mtime/version restarts when HAPI_DISABLE_VERSION_HANDOFF=1 is set (cli/src/runner/run.ts:893) and persists that opt-out for other callers (cli/src/runner/run.ts:731). The new hub ensure path does not receive or check that state, so once installedCliMtimeMs !== startedCliMtimeMs, it can still send stop-runner (hub/src/sync/syncEngine.ts:474) and force a clean shutdown that the operator opted out of.
    Suggested fix:
    // MachineMetadataSchema/buildMachineMetadata
    versionHandoffDisabled: process.env.HAPI_DISABLE_VERSION_HANDOFF === '1'
    
    // maybeEnsureRunnerGeneration
    if (machine.metadata.versionHandoffDisabled === true) {
        return
    }
    await this.rpcGateway.stopRunner(machineId)

Questions

  • None.

Summary

  • Review mode: initial
  • Two Major issues found in runner generation governance. The existing tests cover skew detection and the stop trigger, but they do not cover an already-existing machine row being refreshed by a new runner, or a runner with version handoff disabled.

Testing

  • Not run (automation; review only).

HAPI Bot

Comment thread cli/src/api/apiMachine.ts Outdated
Comment thread hub/src/sync/syncEngine.ts
@heavygee
heavygee marked this pull request as draft July 18, 2026 15:29
@heavygee

Copy link
Copy Markdown
Collaborator Author

Holding merge readiness pending insight from the operator's tooling/meta-bot session (dogfood-before-upstream order). Will follow up after that context lands — do not treat this as ready for review yet.

Debian and others added 8 commits July 18, 2026 17:01
Compact the out-of-date banner (minimize + 1h snooze + per-host Restart)
so it no longer blocks the session list. Auto stop-runner on skew stays
opt-in via HAPI_AUTO_UPGRADE_RUNNERS / autoUpgradeRunners (default off).

Co-authored-by: Cursor <cursoragent@cursor.com>
QuotaExceededError from setItem aborted minimize before React state
updated, leaving the banner stuck over the session list. Persist to
memory when storage fails; only enable Restart when a newer CLI is
already on disk; clarify opt-in is stop-runner only, not package push.

Co-authored-by: Cursor <cursoragent@cursor.com>
…tart

CLI version handoff already reloads the runner when the on-disk binary
mtime changes. Hub-driven stop-runner on skew duplicated that. Keep the
skew banner and manual Restart only as a stuck/disabled-handoff escape.

Co-authored-by: Cursor <cursoragent@cursor.com>
Detect whether the hub is a published npm install or a source/soup tree,
then ask capability-skewed remotes to self-upgrade (registry install or
hub-built binary). Banner Upgrade triggers the same path; soft-fail and
dismissible skew banner remain the safety net. HAPI_UPGRADE_CHANNEL=off disables.

Co-authored-by: Cursor <cursoragent@cursor.com>
… assets

Stub/restore embeddedAssets.generated.ts and compile cli bootstrap like
build-executable (plus tunwg download), so hub-artifact upgrades no longer
fail on hashed web/dist imports from a previous web build.

Co-authored-by: Cursor <cursoragent@cursor.com>
Upgraded runners kept advertising stale hub metadata (old version, no
capabilities) because getOrCreateMachine ignored POST body for existing
machines and connect only synced workspace roots. Banner then lied.

Co-authored-by: Cursor <cursoragent@cursor.com>
Published npm CLIs can implement required machine RPCs without writing
metadata.capabilities. Overlay RpcRegistry onto machine payloads so the
skew banner stops lying about hosts that already register the handlers.

Co-authored-by: Cursor <cursoragent@cursor.com>
…t upgrade

Keepalive refreshes capabilities/version for long-lived runners. Advertise
versionHandoffDisabled and skip auto fleet upgrade when set. Switch
selfUpgrade tests to vitest so CI stops failing on bun:test.

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee
heavygee marked this pull request as ready for review July 19, 2026 12:52

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Build hub-artifacts for the target runner, not the hub host — upgradeMachineRunner() prepares hub-artifact offers with process.platform/process.arch, so a source hub on linux/x64 will send a linux/x64 binary to a connected darwin/arm64 or win32 runner. The CLI then pins its download query to offer.artifact.platform/arch, so the remote can install an incompatible executable and stay skewed. Evidence hub/src/sync/syncEngine.ts:562.
    Suggested fix:
    // MachineMetadataSchema / buildMachineMetadata
    arch: process.arch,
    
    // upgradeMachineRunner
    const platform = machine.metadata.platform
    const arch = machine.metadata.arch
    if (!platform || !arch) {
        return { type: 'error', message: 'Machine platform/arch unavailable', code: 'upgrade_unavailable' }
    }
    offer = await this.prepareArtifactOffer(offer, platform, arch)
  • [Major] Relaunch the verified artifact executable directly — scheduleRunnerRelaunch() puts HAPI_CLI_EXECUTABLE only in options.env, but spawnHappyCLI() resolves the command from the current process env before merging options.env and, in compiled mode, writes the old command back into the child env. A verified hub-artifact can report started while relaunching the old binary. Evidence cli/src/upgrade/selfUpgrade.ts:146.
    Suggested fix:
    const env = { ...process.env, ...(cliExecutable ? { HAPI_CLI_EXECUTABLE: cliExecutable } : {}) }
    const child = cliExecutable
        ? spawn(cliExecutable, ['runner', 'start-sync'], { detached: true, stdio: 'ignore', env })
        : spawnHappyCLI(['runner', 'start-sync'], { detached: true, stdio: 'ignore', env })
    child.unref()
  • [Major] Serialize artifact builds before mutating embeddedAssets.generated.ts — every first-time artifact build uses the same .fleet-upgrade.bak path while replacing a source file. Two concurrent auto/manual upgrade requests can make the second build back up the first build's stub over the real manifest, leaving the working tree with the empty embedded-assets file after restore. Evidence hub/src/upgrade/cliArtifact.ts:151.
    Suggested fix:
    let artifactBuildLock: Promise<void> = Promise.resolve()
    
    async function withArtifactBuildLock<T>(run: () => Promise<T>): Promise<T> {
        const previous = artifactBuildLock
        let release!: () => void
        artifactBuildLock = new Promise<void>((resolve) => { release = resolve })
        await previous
        try {
            return await run()
        } finally {
            release()
        }
    }
    
    return await withArtifactBuildLock(async () => {
        // re-check cached artifact, then call withStubEmbeddedAssets/build/writeMeta
    })

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • Three Major issues remain in the new hub-artifact fleet-upgrade path. The previous bot findings around metadata refresh and handoff opt-out appear addressed. Residual test gaps: no heterogeneous runner platform/arch case, no assertion that artifact relaunch uses the downloaded binary, and no concurrent artifact build coverage.

Testing

  • Not run (automation; review only).

HAPI Bot

Comment thread hub/src/sync/syncEngine.ts Outdated
Comment thread cli/src/upgrade/selfUpgrade.ts
Comment thread hub/src/upgrade/cliArtifact.ts Outdated
Build hub-artifacts for the machine's platform/arch (not the hub host),
relaunch via direct spawn of the downloaded executable, and serialize
artifact compiles so stub/restore cannot race across heartbeats.

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee

Copy link
Copy Markdown
Collaborator Author

Babysit update @ ead199947:

Addressed (threads resolved):

  1. hub-artifact prepared for runner platform/arch (not hub host)
  2. artifact relaunch spawn()s downloaded binary directly
  3. artifact compile serialized + unique stub backups

CI: test green. pr-review failing on Codex Cloud 503 (供应商暂时不可用) — no new review posted against this tip; unresolved threads 0.

Will re-poll when the review action recovers; not holding further code changes for that infra flake.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Validate artifact identifiers before using them in paths — /cli/upgrade/cli-artifact accepts query version/platform/arch, then artifactFileName() joins those raw values into dataDir/upgrade-artifacts. Because decoded query strings can contain /../, any holder of a CLI/namespace token can make the hub compile/write the artifact and .json metadata outside the artifact directory, or build the current source under arbitrary fake versions. Evidence hub/src/upgrade/cliArtifact.ts:43, hub/src/web/routes/upgrade.ts:65.
    Suggested fix:
    const ARTIFACT_TOKEN_RE = /^[A-Za-z0-9._+-]+$/
    
    function artifactToken(name: string, value: string): string {
        if (!ARTIFACT_TOKEN_RE.test(value)) {
            throw new Error(`Invalid artifact ${name}`)
        }
        return value
    }
    
    export function artifactFileName(version: string, platform: string, arch: string): string {
        return `hapi-${artifactToken('version', version)}-${artifactToken('platform', platform)}-${artifactToken('arch', arch)}`
    }
    
    if (targetVersion !== baseOffer.targetVersion) {
        return c.json({ error: 'Unsupported artifact version' }, 400)
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • One Major issue remains in the new hub-artifact download/build path. The prior bot findings around target arch, relaunching the downloaded binary, and concurrent artifact builds appear addressed in the follow-up commit. Residual risk: artifact route behavior is reviewed statically only.

Testing

  • Not run (automation; review only).

HAPI Bot

Comment thread hub/src/upgrade/cliArtifact.ts
Validate version/platform/arch before path join, and only serve the
hub's current offer version so authenticated clients cannot traverse
or compile arbitrary fake versions.

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee

Copy link
Copy Markdown
Collaborator Author

Superseded by same-repo mirror #1108 (tiann:fix/hub-runner-version-governance) so HAPI Bot can review while fork PR checkouts are blocked (see #1107 / GitHub 2026-07-20 checkout backport).

Commits and file set are identical; closing this fork-head PR to avoid duplicate review noise.

@heavygee

Copy link
Copy Markdown
Collaborator Author

Closed in favor of same-repo #1108.

@heavygee heavygee closed this Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hub must govern remote runner version/capabilities (Cursor reopen fail-closed on skew)

1 participant