Skip to content

fix(runtimes): reach a working agent CLI past a broken shim - #7153

Open
lefarcen wants to merge 3 commits into
mainfrom
fix/dsh-version-compat
Open

fix(runtimes): reach a working agent CLI past a broken shim#7153
lefarcen wants to merge 3 commits into
mainfrom
fix/dsh-version-compat

Conversation

@lefarcen

Copy link
Copy Markdown
Contributor

Why

A user installed DeepSeek Harness on Windows and OpenDesign still reported "scan found nothing". Chasing it turned up four separate defects on one chain, three of which affect every agent, not just DSH.

The root cause: their machine had two dsh binaries — a stale wrapper left in %APPDATA%\npm by an earlier failed npm i -g, and the working one the official installer wrote to ~/.local/bin. %APPDATA%\npm ranks earlier in our search order (packages/platform/src/toolchain.ts:115 vs :118), so detection resolved the broken one, failed to execute it, and gave up — never reaching the healthy CLI sitting one directory later.

It then made the failure invisible: the not-invocable branch called unavailableAgent(def, [diagnostic]) without a path, and the picker only renders an unavailable agent when it carries one (deepSeekHarnessNeedsSetup). So the agent vanished from Settings with no diagnostic, no fix action, nothing to click.

Two more defects surfaced while verifying:

  • @open-design/dsh-runtime was uninstallable against any newer host. Its peers were pinned to an exact release candidate, so when upstream shipped the next one, peer resolution failed and our connection component could no longer be installed at all.
  • The DSH installers could never finish. They pinned only @deepseek-ai/dsh, while its ~190 siblings declare each other with caret ranges over prerelease floors. npm reads ^0.1.0-rc.6 as "this prerelease or anything newer", so the transitive tree floated onto the newest release candidate while the entry package stayed pinned. Those generations peer-require their own siblings, so the mixed tree has no solution — npm backtracks across a combinatorial space forever. To the user it looks like an endless scroll of ERESOLVE warnings that never completes.

What users will see

  • An agent CLI that is installed and working is now found, even when a stale wrapper of the same name shadows it earlier on PATH.
  • When an agent genuinely can't be launched, it stays visible in Settings with a diagnostic and a fix action, instead of silently disappearing from the list.
  • DeepSeek Harness keeps working after upgrading dsh — OpenDesign no longer requires one exact release candidate.
  • The one-line DSH installers complete instead of hanging on an endless ERESOLVE scroll.
  • The rescan notice count now matches the list below it ("2 available" over two rows, not "3").

Surface area

  • UI — agent rows in Settings that previously vanished now render with a diagnostic; rescan notice count corrected
  • Keyboard shortcut
  • CLI / env var
  • API / contract
  • Extension point
  • i18n keys
  • New top-level dependency
  • Default behavior change — detection now walks past unusable candidates (bounded at 8 spawns, healthy case unchanged at 1); the DSH installers pin 0.1.0-rc.8 instead of 0.1.0-rc.6
  • None

Screenshots

Not attached — the UI change here is not a new surface but a row that stops disappearing, which needs a machine with a shadowed binary to photograph. Verified instead at the daemon HTTP boundary against a real dsh, which is what drives that row:

Before (baseline main build), same machine, same PATH:

available  : false
path       : undefined      ← picker hides the row entirely
version    : undefined
diagnostics: ["shim-broken"]

After:

available  : true
path       : .../e2e-installer/bin/dsh    ← walked past the broken shim
version    : 0.1.0-rc.8
diagnostics: ["untested-version"]         ← warning only, not blocking

Happy to add UI captures if a reviewer wants them.

Bug fix verification

  • Test path that reproduces the bug: apps/daemon/tests/runtimes/executable-fallback.test.ts
  • Did the test go red before the source change and green after? yes — both cases failed first, and their failures match the field report exactly:
    • expected true, received false (working CLI unreachable behind the broken one)
    • expected "<path>", received undefined (path dropped, so the row is hidden)
  • apps/landing-page/tests/install-dsh-static.test.ts gains a case asserting both installers freeze the resolution window and agree on version + cutoff, so a future version bump that forgets one half fails the build.
  • apps/web/tests/utils/visibleAgents.test.ts covers the count/render mismatch and the path requirement that made the row disappear.

Validation

  • pnpm guard — exit 0 (dependency spec check now reports 7 external-host peer ranges, so the exemption is visible, not silent)
  • pnpm typecheck — exit 0 across all packages
  • pnpm --filter @open-design/daemon test — 52 files, 756 passed / 2 skipped
  • pnpm --filter @open-design/landing-page test — 148 passed
  • pnpm --filter @open-design/dsh-runtime typecheck && build && test — exit 0, 12 passed, with devDependencies raised to 0.1.0-rc.8, so cross-version compatibility is compiler-verified rather than asserted
  • apps/webtests/utils/ (111 passed) plus SettingsDialog.execution / AgentDiagnosticRow / App.onboarding-agent-autoselect (151 passed)

End-to-end, beyond the unit layer:

  • Ran the patched install-dsh.sh for real against the live npm registry: added 452 packages in 8m, exit 0, zero ERESOLVE lines, tree uniformly 0.1.0-rc.8, launcher reports 0.1.0-rc.8. The same install pre-fix does not converge.
  • Started a real daemon twice against a real dsh with a deliberately broken shim earlier on PATH, and queried /api/agents — the before/after above. The baseline run used a main-equivalent build (source changes stashed and rebuilt), so the comparison is against actual pre-fix behavior, not a recollection of it.
  • Note on the probe: this machine already had an open-design profile installed by Open Design Beta on 2026-08-14, carrying the rc.6-era @open-design/dsh-runtime@0.1.0. It completed the --probe handshake against the rc.8 dsh binary, so runtime compatibility across release candidates is demonstrated, not just compile-time.

Note for reviewers

scripts/guard.ts is the one change that touches a repo-wide convention, and it deserves a deliberate look. The rule "all dependency specs must be exact" is right for everything we install. It is wrong for one narrow case: a manifest published for an external host to load as a plugin, whose peerDependencies describe packages the user's host supplies and this repo cannot pin. Pinning those exactly is precisely what made dsh-runtime uninstallable.

The exemption is a one-entry allowlist covering peerDependencies only — dependencies and devDependencies in the same manifest still must be exact — and guard prints the count so it can't rot silently. If you'd rather solve this another way, the alternative is keeping exact peers and accepting that DSH support breaks on every upstream release candidate.

Resolving an agent's name on PATH only proves a file exists there, never
that it runs. Detection stopped at the first hit, so a wrapper orphaned by
a half-finished `npm i -g` shadowed a perfectly good CLI of the same name
in a later search directory — and because the not-invocable branch dropped
the resolved path, the picker (which renders an unavailable agent only when
it carries one) hid the agent entirely. The user saw "scan found nothing"
with no way to act.

Detection now walks past candidates that cannot be executed, and reports
the path it tried when every candidate fails, so the row stays actionable.

Also on this chain:

- `@open-design/dsh-runtime` pinned its DeepSeek Harness peers to one exact
  release candidate, so it became uninstallable the moment upstream shipped
  the next one. Peers now take ranges. `scripts/guard.ts` gains a narrow,
  logged exemption for external-host plugin manifests — their peers describe
  a host this repo neither installs nor controls, which is the one case
  where an exact pin is wrong rather than reproducible.
- The DSH installers pinned only the entry package while its ~190 siblings
  used caret ranges over prerelease floors. Once a newer release candidate
  existed, the transitive tree floated onto it and its mutually exclusive
  peer sets sent npm into an ERESOLVE backtrack that never converged. Both
  installers now freeze the resolution window alongside the pin.
- The rescan notice counted agents the list never renders, so it announced
  "3 available" above two rows.
@lefarcen
lefarcen requested a review from a team as a code owner August 20, 2026 03:25
@lefarcen
lefarcen requested a review from mrcfps August 20, 2026 03:29
@lefarcen lefarcen added size/L PR changes 300-700 lines risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps type/bugfix Bug fix needs-validation Runtime change detected; needs human or /explore agent validation. labels Aug 20, 2026
@lefarcen

Copy link
Copy Markdown
Contributor Author

🧪 This PR has changes that need a manual QA pass before merge — please hold off self-merging for now; we'll loop QA in once it's merge-ready (and design/product have signed off, where applicable).

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

🚀 Landing page preview

This PR is deployed to a Cloudflare Pages preview — not staging or production:

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Visual regression review

Head: 3b4c2f7 · Base: 8d62fda

0 changed · 49 unchanged · 0 new without baseline · 0 failed

Unchanged cases
Case Main PR Diff
visual-avatar-local-agent-list
0 px (0.00%)
main pr diff
visual-avatar-local-agent-list-panel
0 px (0.00%)
main pr diff
visual-avatar-menu
0 px (0.00%)
main pr diff
visual-avatar-menu-panel
0 px (0.00%)
main pr diff
visual-avatar-open-design-model-picker
0 px (0.00%)
main pr diff
visual-critical-settings
0 px (0.00%)
main pr diff
visual-critical-workspace
0 px (0.00%)
main pr diff
visual-critical-workspace-preview
0 px (0.00%)
main pr diff
visual-design-system-detail
0 px (0.00%)
main pr diff
visual-design-systems
0 px (0.00%)
main pr diff
visual-home
0 px (0.00%)
main pr diff
visual-home-catalog
0 px (0.00%)
main pr diff
visual-home-context-picker
0 px (0.00%)
main pr diff
visual-home-context-picker-popover
0 px (0.00%)
main pr diff
visual-home-plugin-filter
0 px (0.00%)
main pr diff
visual-home-plugin-use-staged
0 px (0.00%)
main pr diff
visual-home-plugin-use-with-query
0 px (0.00%)
main pr diff
visual-home-staged-attachment
0 px (0.00%)
main pr diff
visual-integrations
0 px (0.00%)
main pr diff
visual-integrations-mcp
0 px (0.00%)
main pr diff

Visual diff is advisory only and does not block merging.

@mrcfps mrcfps left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @lefarcen — the Settings visibility fix, the installer generation freeze, and the narrow guard exemption for external-host peers are all thoughtfully done.

One launch-path gap still blocks the stated goal: detection can walk past a broken shim and mark the agent available, but chat/run still resolve the first PATH hit. Details are inline.

🔁 Powered by <a href="https://github.com/nexu-io/looper\">Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

Comment thread apps/daemon/src/runtimes/detection.ts
… use

The candidate walk taught detection to skip a binary it cannot execute, but
every other resolution — chat, the connection test, the memory summariser,
companion install — called `resolveAgentLaunch` with no skip list and got
back the first file that merely exists on PATH: the broken shim detection
had just rejected. Settings advertised the agent as installed while each
turn exec'd the wrapper, so the fix only ever reached the picker.

Detection is the only stage that spawns anything, so it is the only one that
learns which candidate works. It now publishes that winner, and resolution
prefers it over a fresh first-hit walk. An explicit `*_BIN` override and a
packaged built-in still outrank it, a winner whose file has since vanished
is ignored, and detection clears the entry before each pass so a rescan
after a repair never resolves against a stale one.

This restores the invariant the file already documented — detection probes
the exact path the runtime will spawn — which the walk had broken.

Reported by @mrcfps in review.
@lefarcen
lefarcen requested a review from mrcfps August 20, 2026 03:44

@mrcfps mrcfps left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@lefarcen thank you for the careful follow-up — this is a really solid bug-fix chain.

I re-reviewed fd5716d against the earlier launch-path split. Detection still walks past a not-invocable PATH hit, then rememberDetectedExecutable publishes the winner and inspectAgentExecutableResolution prefers it, so resolveAgentLaunch (chat, connection test, memory, companion setup) now lands on the same binary Settings reported. Overrides and packaged built-ins still outrank the cache, a vanished winner falls through to a live walk, and forgetDetectedExecutable at the top of probe() keeps a rescan honest. The new fallback test asserting both detectAgent() and a later resolveAgentLaunch(def) closes the hole that went red on the previous head.

On the sandbox-keying question: agent-id-only matches detectedRuntimeVersions and is the right call. OD_AGENT_HOME is process-wide, so a sandboxed detect cannot populate a winner that a non-sandboxed spawn in the same daemon would reuse.

The rest of the PR holds up too: unavailable agents keep their attempted path so the Settings row stays actionable, the rescan count ignores hidden CLIs, the installer freeze (version + shared --before cutoff) is tested on both scripts, and the guard exemption is peer-only, allowlisted, and visible in the pass line.

Really nice work tracing one Windows field report into four real defects and then closing the review gap without widening the design. 🙏

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

…te set

The winner map made resolution stop being a pure function of the current
environment. It only checked that the remembered file still existed, so a
path learned in a richer environment survived an emptied PATH, a sandboxed
OD_AGENT_HOME, and any other narrowing of the search — resurrecting a binary
the caller could no longer see.

That is not just a test artifact: `GET /api/integrations/vela/status` must
answer `amr-runtime-unavailable` when the vela binary cannot be resolved, and
it started answering "ready" instead, because an earlier detection in the
same process had remembered a path.

The memory now reorders candidates rather than introducing them: it applies
only when the live search already offers the remembered path. An emptied
PATH, a sandbox, and an uninstalled CLI all keep meaning "not found", while
a broken shim shadowing a working binary is still resolved to the winner.

This also settles the scope question raised in review — no separate cache key
is needed, because the candidate set already carries the resolution scope.
@lefarcen

Copy link
Copy Markdown
Contributor Author

Pushed 3b4c2f7 — CI caught a regression I introduced in the previous commit, so recording what happened here.

What broke. The winner map from the last commit made binary resolution stop being a pure function of the current environment. It only verified that the remembered file still existed, so a path learned earlier in the process survived an emptied PATH, a sandboxed OD_AGENT_HOME, or any other narrowing of the search — resurrecting a binary the caller could no longer see.

Daemon tests (1/4) failed on it, and it is a real behaviour bug rather than a strict test:

tests/integrations/vela.routes.test.ts:677
expect(status).toBe(503)   // GET /api/integrations/vela/status, vela unresolvable
received 200

That route must answer amr-runtime-unavailable when the vela binary cannot be resolved. It began answering "ready" because an earlier detection in the same process had remembered a path. I reproduced it locally before changing anything, and the file passes in isolation on this head.

Fix. The memory now reorders candidates instead of introducing one — it applies only when the live search already offers the remembered path. An emptied PATH, a sandbox, and an uninstalled CLI all keep meaning "not found", while a broken shim shadowing a working binary still resolves to the winner. A regression test pins the invariant: detect against a populated PATH, then empty PATH and assert resolveAgentLaunch returns null even though the file is still on disk.

This also answers the scope question I raised in the thread above. No separate cache key is needed for sandboxed passes — the candidate set already carries the resolution scope, so a winner from a richer environment can never leak into a narrower one. Disregard that open question.

Validation on this head

  • apps/daemon full suite (not the runtimes subset — that narrowness is what let the regression through): 8621 passed. The only failures were in tests/routes/project-move-to-personal.test.ts, which passes 21/21 in isolation and is green on CI — single-process contention over a 24-minute run, unrelated to this change.
  • tsc on both tsconfig.json and tsconfig.tests.json: exit 0.
  • pnpm guard: exit 0.

Re-reviewing is welcome but not required from my side — the approval predates this commit, so treat it as your call whether the fix changes anything for you.

@lefarcen
lefarcen requested a review from mrcfps August 20, 2026 04:24

@mrcfps mrcfps left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@lefarcen thank you for the careful CI follow-up — this is a really solid close-out of the winner-map regression.

I re-reviewed 3b4c2f7 against the earlier existence-only cache. preferRememberedExecutable now only reorders paths that the live search already offers, so an emptied PATH, a sandboxed OD_AGENT_HOME, or a vanished CLI stays “not found” instead of resurrecting a file that happens to still be on disk. That matches the vela status failure you reproduced (503 / amr-runtime-unavailable vs a stale ready), and the new fallback test pins it: detect against a populated PATH, empty PATH, assert resolveAgentLaunch returns null even though the binary is still there.

The rest of the chain still holds. Detection walks past a not-invocable PATH hit, publishes the winner, and later resolveAgentLaunch (chat, connection test, memory, companion setup) lands on the same binary Settings reported. Overrides and packaged built-ins still outrank the memory, forgetDetectedExecutable at the top of probe() keeps a rescan honest, unavailable agents keep the attempted path so the Settings row stays actionable, the rescan count ignores hidden CLIs, both installers freeze the same version + --before cutoff, and the guard exemption is peer-only, allowlisted, and visible in the pass line.

Really nice work turning one Windows field report into four real defects and then tightening the cache so resolution stays a function of the current environment. 🙏

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

@lefarcen
lefarcen requested a review from ivy-ting August 20, 2026 04:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-validation Runtime change detected; needs human or /explore agent validation. risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps size/L PR changes 300-700 lines type/bugfix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants