fix(hub): govern runner capabilities so Cursor reopen soft-fails on skew#1086
fix(hub): govern runner capabilities so Cursor reopen soft-fails on skew#1086heavygee wants to merge 11 commits into
Conversation
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>
There was a problem hiding this comment.
Findings
- [Major] Refresh full runner capability metadata for existing machines —
/cli/machinesreturns an existing machine row unchanged (hub/src/store/machines.ts:44), so the newbuildMachineMetadata({ ... })payload passed by a restarted runner (cli/src/runner/run.ts:773) is ignored for every already-known machine. The new heartbeat refresh only writesinstalledCliMtimeMs(cli/src/api/apiMachine.ts:556), leaving old/missingcapabilitiesand stalestartedCliMtimeMsin place. Result: an upgraded runner can keep looking skewed forever; with stale mtime drift retained, the hub can repeatedly callstop-runneragainst 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=1is 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 onceinstalledCliMtimeMs !== startedCliMtimeMs, it can still sendstop-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
|
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. |
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>
There was a problem hiding this comment.
Findings
- [Major] Build hub-artifacts for the target runner, not the hub host —
upgradeMachineRunner()prepareshub-artifactoffers withprocess.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 tooffer.artifact.platform/arch, so the remote can install an incompatible executable and stay skewed. Evidencehub/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()putsHAPI_CLI_EXECUTABLEonly inoptions.env, butspawnHappyCLI()resolves the command from the current process env before mergingoptions.envand, in compiled mode, writes the old command back into the child env. A verified hub-artifact can reportstartedwhile relaunching the old binary. Evidencecli/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.bakpath 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. Evidencehub/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-artifactfleet-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
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>
|
Babysit update @ Addressed (threads resolved):
CI: Will re-poll when the review action recovers; not holding further code changes for that infra flake. |
There was a problem hiding this comment.
Findings
- [Major] Validate artifact identifiers before using them in paths —
/cli/upgrade/cli-artifactaccepts queryversion/platform/arch, thenartifactFileName()joins those raw values intodataDir/upgrade-artifacts. Because decoded query strings can contain/../, any holder of a CLI/namespace token can make the hub compile/write the artifact and.jsonmetadata outside the artifact directory, or build the current source under arbitrary fake versions. Evidencehub/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
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>
|
Closed in favor of same-repo #1108. |
Summary
After #1037, Cursor reopen hard-failed when the hub was newer than a remote runner: missing
cursor-chat-store-statuswas treated as deleted chat data even whenstore.dbstill existed.This PR makes hub↔runner generation governance explicit:
onDisk: falsestill blocks.installedCliMtimeMs≠startedCliMtimeMs), hub may callstop-runnerso systemd/handoff loads the new generation; otherwise the banner stays.Test plan
bun typecheckbun run test(shared capability registry, soft-fail reopen, runner ensure, web reopen gate, skew banner)Runner out of date on proxmox+ upgrade/restart instructions)Issues
Fixes #1084