Removes three negative-existence test assertions that have decayed into
no-ops because the testids they target no longer exist in source. Each
was added in the same PR that deleted the related component (or, for
login-cta, in the OSS-strip PR) and was useful as a regression guard at
the time. Once the deletion is several weeks old, an assertion of the
form `expect(queryByTestId("deleted-testid")).not.toBeInTheDocument()`
is always trivially true and only adds maintenance noise.
Follow-up to PR #1545 review feedback (tofarr): 'A test that makes sure
that something which was deleted six months ago does not exist. We
should probably have an agent review our tests for cases like this and
remove them.' See APP-2570 for the audit inventory and follow-ups.
* __tests__/routes/device-verify.test.tsx — drop the entire
'keeps the device verification view OSS-only without login CTA chrome'
test. The testid 'login-cta' has no source counterpart; the assertion
was always trivially true. Test was added in #17 (the OSS-strip PR).
* __tests__/routes/agent-settings.test.tsx — drop the orphan
queryByTestId('acp-credentials-save-button') assertion (and the
obsolete explanatory comment) from the 'a single Save persists ACP
credentials' test. The testid has no source counterpart; the rest of
the test (the single Save flow) is unaffected.
* __tests__/components/features/conversation-panel/conversation-card.test.tsx
— drop the entire 'should not render the llm model in the conversation
card' test. The testid 'conversation-card-llm-model' was removed in
6e545c54 ('Move LLM model name from conversation lists/title to chat
input'); the inverted test added at the same time is now a no-op.
All three test files still pass. typecheck clean. No new lint issues.
The PR's own new analytics-consent-modal assertion is intentionally kept
— it is load-bearing for the release that removed the modal and matches
the same pattern that PR #1251 used for acp-credentials-save-button
(now itself a candidate for a future cleanup pass; APP-2570 tracks).
Co-authored-by: openhands <openhands@all-hands.dev>
The Manage Backends modal previously rendered pencil and trash icons on
every backend row even when the deployment was started with
--lock-to-cloud. In locked mode the user should not be able to mutate
the locked backend's host/key/name or remove it outright.
backend-row.tsx now consults getLockedCloudHost() and skips rendering
both action buttons when a locked cloud host is configured. The row
identity (name + host) still renders so the user can see which backend
is locked.
Adds three focused tests to describe("BackendRow", ...):
* renders edit and remove buttons when not locked to a cloud host
* hides edit and remove buttons when locked via VITE_LOCK_TO_CLOUD
* hides edit and remove buttons when locked via the
window.__AGENT_CANVAS_LOCK_TO_CLOUD__ runtime global
Also extends afterEach with vi.unstubAllEnvs + window-global cleanup
(consistent with backend-form-modal.test.tsx) and isolates three
pre-existing edit-form tests from a local .env that sets
VITE_LOCK_TO_CLOUD, so they keep exercising edit-form behavior rather
than lock behavior.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: seed AUTOMATION_KV_SECRET for local dev
The KV store (automation PR#69) requires AUTOMATION_KV_SECRET to be set
or every KV endpoint returns 503. In a local dev stack the secret is never
configured, so the store is silently unavailable.
Fall back to sessionApiKey when the env var is not set explicitly — the
same zero-config pattern already used for AUTOMATION_AGENT_SERVER_URL,
AUTOMATION_BASE_URL, and AUTOMATION_WORKSPACE_BASE.
Co-authored-by: openhands <openhands@all-hands.dev>
* chore: bump openhands-automation to 1.0.0a10
Update to the latest released version of openhands-automation on PyPI.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
* Increase secrets table max-height to show up to 12 rows
Raises the scrollable container cap from min(40vh,22rem) to
min(70vh,39rem). At 48px per row (h-12) + 48px sticky header,
39rem (624px) fits exactly 12 data rows before the scrollbar
appears. The max-h constraint means the container still
shrinks naturally when there are fewer than 12 rows.
Co-authored-by: openhands <openhands@all-hands.dev>
* Remove scroll cap and sticky header from secrets table
The secrets table had a max-h constraint (overflow-auto) that triggered
a scrollbar after 12 rows, plus a sticky thead to compensate. Both were
specific to this file. Since the server API returns all secrets in one
response and the columns are simple (name, description, actions), a
plain full-height table is clearer.
- Replace settingsListScrollContainerClassName with
settingsListContainerClassName on the outer div
- Inline border-b on <thead> instead of the sticky-specific class
- Remove onScroll handler, tableContainerRef, handleScroll callback,
and isFetchingNextPage spinner (all scroll-only code)
The shared scroll/sticky classes in settings-list-classes.ts are
untouched for use by other tables.
Co-authored-by: openhands <openhands@all-hands.dev>
* Revert secrets-settings.tsx and settings-list-classes.ts to main
* Increase secrets table max-height to show up to 12 rows
Raises the scrollable container cap from min(40vh,22rem) to
min(70vh,39rem). At 48px per row (h-12) + 48px sticky header,
39rem (624px) fits exactly 12 data rows before the scrollbar
appears. The max-h constraint means the container still
shrinks naturally when there are fewer than 12 rows.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
The automationSdk version was always intended to equal agentServer.
Having a separate field creates a maintenance foothole where the two
values can silently drift. Remove automationSdk from defaults.json and
have all consumers (check-sdk-version-sync, dev-with-automation,
agent-canvas CLI --version output) read versions.agentServer directly.
The sync check still catches any mismatch between the released
openhands-automation package and the expected SDK version.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: show full workspace list when dropdown reopens after selection
When a workspace was already selected, opening the dropdown again would
filter the list to just the one matching item (because inputValue was set
to the selected name). Add onIsOpenChange to useCombobox so the input
clears on open (showing all options) and restores on close. When open,
use the current selection as the input placeholder so the user still has
a visual reminder of what they've chosen.
This is the same pattern already used in src/ui/dropdown/dropdown.tsx.
Co-authored-by: openhands <openhands@all-hands.dev>
* Lint fixes
---------
Co-authored-by: openhands <openhands@all-hands.dev>
* chore: bump agent-server → 1.28.1, automation → 1.0.0a9, extensions → 0.4.1
Co-authored-by: openhands <openhands@all-hands.dev>
* Test fixes
* fix: inject proxy base_url for litellm_proxy/* when server omits it (agent-server ≥1.28)
Agent-server ≥1.28 may return base_url:null when fetching a litellm_proxy/*
profile config, even when the profile was saved with the All-Hands proxy URL.
This caused the Basic-tab re-save flow in LlmSettingsLocalView.handleSave to
call isOpenHandsProxyModel(model, null) → false, hitting the else-branch that
deletes base_url and stranding the profile (issue #1146).
Fix: add a secondary check — litellm_proxy/* with a missing base_url is treated
the same as litellm_proxy/* with the proxy URL already set, and
OPENHANDS_LLM_PROXY_BASE_URL is injected before the save request is sent.
Also updates the mock-LLM E2E test to accept both storage representations:
- litellm_proxy/* + proxyBaseUrl (pre-1.28, guards issue #1146 regression)
- openhands/* + null (1.28+, server-managed routing)
And adds a unit test exercising the base_url:null path.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
* chore: bump agent-server → 1.28.1, automation → 1.0.0a9, extensions → 0.4.1
Co-authored-by: openhands <openhands@all-hands.dev>
* chore: update doc examples to reference agent-server 1.28.1
Update version references in AGENTS.md, scripts/dev-safe.mjs, and
scripts/check-sdk-version-sync.mjs from 1.27.0 → 1.28.1 to stay
in sync with the agentServer pin in config/defaults.json.
Fixes: docs-version-sync.test.ts failures
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: inject proxy base_url for litellm_proxy/* when server omits it (agent-server ≥1.28)
Agent-server ≥1.28 may return base_url:null when fetching a litellm_proxy/*
profile config, even when the profile was saved with the All-Hands proxy URL.
This caused the Basic-tab re-save flow in LlmSettingsLocalView.handleSave to
call isOpenHandsProxyModel(model, '') → false, hitting the else-branch that
deletes base_url and stranding the profile (issue #1146).
Fix: add a secondary check for litellm_proxy/* models with a missing base_url
(null/undefined/empty), treating them the same as a stored proxy URL and
injecting OPENHANDS_LLM_PROXY_BASE_URL before the save request is sent.
Also adds a unit test exercising the base_url:null path.
Co-authored-by: openhands <openhands@all-hands.dev>
* test(e2e): accept agent-server 1.28 model rewrite in proxy profile test
Agent-server 1.28 normalises litellm_proxy/* → openhands/* on storage
and manages the proxy URL internally (returning base_url:null). The old
assertions hard-coded the pre-1.28 storage format (litellm_proxy/* +
explicit proxy URL), causing the test to fail on every 1.28 run.
Extract assertProxyProfileConfig() helper that accepts both storage
representations:
- litellm_proxy/* + proxyBaseUrl (pre-1.28, guards issue #1146 regression)
- openhands/* + null (1.28+, server-managed routing)
The issue #1146 guard is preserved: a litellm_proxy/* profile without a
proxy URL is still flagged as a stranded profile.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: set bundled skill source to real filesystem path
Bundled public skills were sent to the Python agent-server with
`source: "public"`, which the SDK uses as the skill's `location` in
SkillKnowledge. Any skill whose SKILL.md references bundled resources
(scripts/, references/) is therefore unable to resolve those files —
the agent sees 'Skill location: public' instead of an actual path.
Fix: compute the absolute path to the skills directory inside the
`@openhands/extensions` node_modules package at Vite build/serve time
and inject it as `__EXTENSIONS_SKILLS_DIR__` via Vite's `define`.
`buildBundledSkills()` now sets `source` to:
`${__EXTENSIONS_SKILLS_DIR__}/${name}/SKILL.md`
Library builds receive an empty string so consumers are not bound to
this machine's node_modules path; the adapter falls back to 'public'
when the value is falsy.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: declare __EXTENSIONS_SKILLS_DIR__ global for TypeScript
Add missing 'declare const __EXTENSIONS_SKILLS_DIR__: string' to
src/react-app-env.d.ts so tsc can resolve the Vite-injected constant
used in buildBundledSkills().
Co-authored-by: openhands <openhands@all-hands.dev>
* test: assert skill source is absolute path ending in /<name>/SKILL.md
Replace the loose 'typeof === string' / toBeTruthy checks with two specific
assertions that directly verify the intent of buildBundledSkills():
- source starts with '/' (absolute path the agent-server can resolve)
- source ends with '/<name>/SKILL.md' (points to the right file)
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
* bump automation to 1.0.0a7 and add automationSdk==agentServer version test
- Update versions.automation: 1.0.0a6 → 1.0.0a7 (latest on PyPI)
- Update versions.automationSdk: 1.22.1 → 1.27.0
openhands-automation==1.0.0a7 depends on openhands-sdk==1.27.0,
which now matches versions.agentServer (1.27.0)
- Add 'versions.automationSdk matches versions.agentServer' test in
__tests__/scripts/check-sdk-version-sync.test.ts so any future bump
that forgets to keep both fields in sync fails CI immediately
- Add config/defaults.json to sdk-version-sync.yml path triggers so
the PyPI metadata check also fires when the config file is changed
Co-authored-by: openhands <openhands@all-hands.dev>
* remove automationSdk==agentServer static test
The sdk-version-sync workflow already covers the meaningful invariant
(automationSdk matches what the released openhands-automation on PyPI
actually depends on). The static test enforced automationSdk===agentServer
at all times, but the script explicitly allows automationSdk to lag
agentServer while a compatible automation release is pending — making
the test both unnecessary and incorrect.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
Update agentServer version pin in config/defaults.json from 1.25.0 to 1.26.0.
This drives all four packages (openhands-agent-server, openhands-sdk,
openhands-tools, openhands-workspace) which are released in lockstep.
Also update matching test expectations and example version strings in
dev-safe.mjs, check-sdk-version-sync.mjs, and AGENTS.md.
Co-authored-by: openhands <openhands@all-hands.dev>
* feat: add daily-rotating file logger for dev scripts (issue #815)
Add winston + winston-daily-rotate-file to write all dev-server log
output to logs/agent-canvas.YYYY-MM-DD.log alongside the existing
console output (which is unchanged).
- scripts/logger.mjs — shared module; exports fileLog(level, msg)
and stripAnsi(str). DailyRotateFile transport stores files in
logs/ relative to the project root, rotates at midnight, and
auto-deletes files older than 7 days.
- scripts/dev-with-automation.mjs — logService / logStep /
logSuccess / logError each call fileLog as a side-channel. The
shutdown message, startup title, checkPrerequisites uvx-error, and
printBanner summary are also captured.
- scripts/dev-safe.mjs — spawnProcess errors, main() startup lines,
the unexpected-exit error, and the fatal-error handler all call
fileLog.
- logs/ was already in .gitignore.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: store log files in agent-canvas state dir, not project root
Use OH_CANVAS_SAFE_STATE_DIR (or ~/.openhands/agent-canvas as
the default) to match where all other agent-canvas runtime state
lives, e.g. ~/.openhands/agent-canvas/logs/agent-canvas.YYYY-MM-DD.log
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
* feat: default EXTENSIONS_REF from pinned @openhands/extensions SHA in package.json
The agent-server polls the OpenHands extensions repo using EXTENSIONS_REF
(defaulting to 'main'), while the frontend bundles the MCP catalog and
automations data from a pinned commit SHA in package.json. This caused the
two to silently diverge — skills loaded at runtime were from latest main
while the UI showed catalog data from an older commit.
Fix: derive a DEFAULT_EXTENSIONS_REF from the '#<sha>' fragment in the
@openhands/extensions git URL in package.json and inject it as the agent-
server's EXTENSIONS_REF when the caller hasn't set it explicitly.
- scripts/dev-safe.mjs: getExtensionsRef() helper reads package.json at
module init; buildAgentServerEnv() spreads EXTENSIONS_REF into the
child-process env unless the caller already set it.
- docker/Dockerfile (config-gen stage): reads package.json alongside
config/defaults.json and emits CONFIG_EXTENSIONS_REF=<sha> into
defaults.env when the dependency uses a pinned git SHA.
- docker/entrypoint.sh: applies CONFIG_EXTENSIONS_REF as the default via
EXTENSIONS_REF="${EXTENSIONS_REF:-${CONFIG_EXTENSIONS_REF:-}}".
User-supplied EXTENSIONS_REF always takes precedence in both paths.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: address review bot comments on EXTENSIONS_REF sync
- entrypoint.sh: guard EXTENSIONS_REF export so an absent CONFIG_EXTENSIONS_REF
never sets the variable to an empty string (which would defeat the
agent-server's own 'main' default in os.environ.get('EXTENSIONS_REF','main'))
- scripts/dev-safe.mjs: getExtensionsRef() now checks devDependencies as
fallback when @openhands/extensions is not in dependencies
- docker/Dockerfile config-gen stage: same devDependencies fallback
- AGENTS.md: replace internal constant name DEFAULT_EXTENSIONS_REF with the
actual env var EXTENSIONS_REF; also note the empty-string guard
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: pre-seed extensions cache when EXTENSIONS_REF is a commit SHA
The SDK's git clone --depth 1 --branch <sha> fails because --branch only
accepts branch/tag names, not raw 40-char commit SHAs (GitHub returns
'fatal: Remote branch <sha> not found').
When EXTENSIONS_REF is a commit SHA, pre-seed the public-skills cache with
a plain full git clone + checkout before starting the agent-server:
- docker/entrypoint.sh: pre-seeds after EXTENSIONS_REF is exported; is a
no-op when the cache already exists or EXTENSIONS_REF is a branch/tag name
- scripts/dev-safe.mjs: exports preseedExtensionsCache() and
DEFAULT_EXTENSIONS_REF; calls pre-seed in main() before spawning the
agent-server; guards with the same SHA regex as entrypoint.sh
- scripts/dev-with-automation.mjs: imports preseedExtensionsCache and
DEFAULT_EXTENSIONS_REF from dev-safe.mjs; calls pre-seed in
startAgentServer() before spawnService()
The SDK's update path (fetch + git checkout) can handle SHAs once the
repo exists, so this is sufficient without any SDK changes.
Co-authored-by: openhands <openhands@all-hands.dev>
* refactor(docker): bake extensions clone into image, cp at runtime
Move the git clone for the pinned extensions SHA from the entrypoint
into a dedicated Dockerfile build stage (extensions-cache).
Why: the clone is deterministic (same SHA for a given image), so doing
it once at build time is cheaper than doing it on first container start.
It also avoids network access at runtime entirely for Docker users.
How:
- New extensions-cache stage (node:24-slim + git): reads package.json,
clones OpenHands/extensions, checks out the pinned SHA into
/tmp/public-skills. When the dependency is not SHA-pinned the stage
exits early with an empty directory (graceful no-op).
- Final stage: COPY --from=extensions-cache stores the result at
/opt/agent-canvas/extensions-cache/ — outside the
/home/openhands/.openhands VOLUME so it is not hidden by bind-mounts.
chown passes ownership to openhands.
- entrypoint.sh: replaces git clone + checkout with cp -r from the
pre-baked path. The guard checks [ -d "${_ext_baked}/.git" ] so
the block is a no-op when the stage produced an empty directory.
The npm-dev preseedExtensionsCache() path in dev-safThe npm-dev preseedExtensionsCache() path in d — that still clones over the
network on first run (build-time baking does not apply to npm dev).
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(extensions-preseed): handle existing shallow clone missing the pinned SHA
When `npm run dev` has been run previously without the PR, the agent-server
SDK creates a shallow clone of the extensions repo via:
git clone --depth 1 --branch main
After PR #1060 is applied, `EXTENSIONS_REF` is set to the 40-char pinned
SHA. The SDK then tries `git checkout <sha>` against that shallow clone,
which fails because the specific commit is not in its shallow history.
`preseedExtensionsCache` had a blind early-return when `.git` already
existed, trusting the "SDK update path" to handle it. But the SDK's update
path also does a plain `git checkout <sha>` and cannot succeed on a shallow
clone that lacks the commit.
Fix: before returning early, verify the SHA is accessible with
git cat-file -t <sha>
If it returns anything other than "commit", the cache is a shallow clone
that pre-dates the pinned commit. Unshallow the existing clone with
git fetch --unshallow
so the full history is available, then checkout the SHA.
Co-authored-by: openhands <openhands@all-hands.dev>
* refactor(extensions): copy from node_modules on npm, always overwrite cache
Implement the intended architecture for extensions cache seeding:
npm path (dev-safe.mjs / dev-with-automation.mjs):
- Replace preseedExtensionsCache (git clone approach) with
copyExtensionsToSkillsCache, which copies the already-installed
node_modules/@openhands/extensions directly into the skills cache.
No network call required — npm already installed the package at
the pinned SHA.
- Always overwrite the cache (rm -rf + cpSync) so stale files from a
prior run that used a different version are never left behind.
- Set EXTENSIONS_REF unconditionally in buildAgentServerEnv so it
always matches the pre-seeded cache content.
Docker path (docker/entrypoint.sh):
- Always copy /opt/agent-canvas/extensions-cache into the skills cache
(rm -rf + cp -r), removing the prior guard that skipped the copy when
.git already existed.
- Set EXTENSIONS_REF unconditionally from CONFIG_EXTENSIONS_REF.
Both paths accept that the SDK will warn 'Using cached version' for a
raw commit SHA — that warning is the expected fallback until SDK polling
is disabled in a follow-up PR.
Note: the npm package only publishes integrations/ and automations/.
Docker's baked full clone also includes marketplaces/, plugins/, skills/.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(extensions): delete stale cache on npm startup instead of copying files
The copy-from-node_modules approach was still broken: the npm package has no
.git directory, so the SDK tried a fresh 'git clone --depth 1 --branch <sha>'
on top of the already-populated directory and got confused.
Simpler fix: just delete ~/.openhands/cache/skills/public-skills on startup.
The SDK starts with a clean slate and attempts its normal clone; for a raw SHA
ref it will warn 'Using cached version' which is the accepted fallback until
SDK polling is disabled in a follow-up PR.
- Replace copyExtensionsToSkillsCache (copy from node_modules) with
clearExtensionsCache (delete the directory)
- Remove now-unused cpSync import
- Docker path unchanged (baked clone always copied by entrypoint.sh)
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(extensions): always delete cache; only set EXTENSIONS_REF if not in env
Three simple rules:
1. Always delete ~/.openhands/cache/skills/public-skills on startup so the
SDK clones fresh — no conditional on DEFAULT_EXTENSIONS_REF.
2. Only inject EXTENSIONS_REF into the agent-server env when the caller has
not already set it (restore the !process.env.EXTENSIONS_REF guard).
3. Let the agent-server SDK do its own cloning from there.
Co-authored-by: openhands <openhands@all-hands.dev>
* feat(extensions): use OH_PUBLIC_SKILLS_PATH to bypass git polling
Switch from EXTENSIONS_REF (which still went through the SDK's broken
git clone --branch <sha> path) to OH_PUBLIC_SKILLS_PATH, introduced in
software-agent-sdk PR #3513. When set, the SDK skips all git operations
and loads public skills directly from the given directory.
npm path (dev-safe.mjs / dev-with-automation.mjs):
- Add cloneExtensionsForSkillsCache(sha, cacheDir): does a targeted
git fetch --depth=1 origin <sha> into ~/.openhands/cache/skills/public-skills.
Reuses the existing directory when it already contains the right commit.
- buildAgentServerEnv now sets OH_PUBLIC_SKILLS_PATH (not EXTENSIONS_REF)
pointing at that directory when DEFAULT_EXTENSIONS_REF is known and
OH_PUBLIC_SKILLS_PATH is not already in the environment.
Docker path (docker/entrypoint.sh):
- Removes EXTENSIONS_REF export entirely.
- After copying the baked clone to the skills cache, exports
OH_PUBLIC_SKILLS_PATH pointing at that directory.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(dev): include openhands-sdk in git-ref uvx args
When OH_AGENT_SERVER_GIT_REF is set, openhands-sdk was missing from the
--with args, so uv fell back to the released PyPI version. That version
lacks the public_skills_path parameter added to update_skills_repository
in SDK PR #3513, so the agent-server's skills_service called the old SDK
path and did a plain git clone of 'main' — overwriting the SHA-pinned
clone our cloneExtensionsForSkillsCache had just created.
Fix: add --with git+<repo>@<ref>#subdirectory=openhands-sdk alongside the
existing openhands-tools and openhands-workspace entries, matching what
the PyPI and local paths already do for openhands-sdk.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(dev): add --reinstall to git-ref uvx command
When OH_AGENT_SERVER_GIT_REF points at a branch whose version string
matches the current PyPI release (e.g. both are 1.25.0), uv silently
reuses the cached PyPI wheels and the git ref is never used.
--reinstall forces uv to build a fresh environment from the specified
git sources regardless of what is already cached. The git clone itself
is still cached in ~/.cache/uv/git-v0/, so only the first run after a
ref change pays the full network cost.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: rename OH_PUBLIC_SKILLS_PATH to PUBLIC_SKILLS_PATH
Aligns with the env var name used by the SDK PR.
Co-authored-by: openhands <openhands@all-hands.dev>
* test: update dev-safe buildAgentServerCommand tests for --reinstall and openhands-sdk
The git-ref path now adds --reinstall (so uv doesn't silently reuse cached
PyPI wheels) and includes openhands-sdk as a --with package so inter-package
APIs stay in sync. Update the two affected toEqual assertions to match.
Co-authored-by: openhands <openhands@all-hands.dev>
* refactor: use EXTENSIONS_REF instead of PUBLIC_SKILLS_PATH for extensions pinning
The PUBLIC_SKILLS_PATH / cloneExtensionsForSkillsCache approach was considered
but not chosen. EXTENSIONS_REF is the variable the agent-server SDK uses, and
it now skips network polling when the requested SHA is already in its cache.
- scripts/dev-safe.mjs: replace PUBLIC_SKILLS_PATH spread in buildAgentServerEnv
with a simple EXTENSIONS_REF injection; remove cloneExtensionsForSkillsCache
function, EXTENSIONS_REPO const, and the main() clone call; remove the
spawnSync and rmSync imports that were only needed for cloning
- scripts/dev-with-automation.mjs: remove cloneExtensionsForSkillsCache import
and the clone call from startAgentServer()
- docker/Dockerfile: remove the extensions-cache build stage entirely; keep the
CONFIG_EXTENSIONS_REF line in config-gen (still needed for entrypoint.sh)
- docker/entrypoint.sh: replace the cp-based seeding block with a single
export EXTENSIONS_REF="${EXTENSIONS_REF:-${CONFIG_EXTENSIONS_REF:-}}"
- AGENTS.md: replace inaccurate OH_PUBLIC_SKILLS_PATH bullet with an accurate
description of the EXTENSIONS_REF flow
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
Switch the agent-server to LOG_JSON=true so the Python SDK emits one
JSON object per log record instead of Rich-formatted output. Rich was
wrapping long lines across multiple entries and prepending its own
timestamp on top of the HH:MM:SS [agent-server] prefix that logService
already provides.
Add parseAgentServerLogLine() which turns each JSON record into a clean
single-line string:
INFO 127.0.0.1:51658 - "GET /api/..." 200 h11_impl.py:481
Level-based ANSI colouring is also applied (dim for DEBUG, yellow for
WARNING, red for ERROR/CRITICAL, blue for INFO) so errors still stand
out. Non-JSON lines (e.g. startup text) fall back to the original
yellow stderr / service-colour stdout behaviour unchanged.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: prevent deletion of the active LLM profile
Hide the Delete option in the profile actions menu when the profile is
the currently active one, so users cannot accidentally remove it.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: add tooltip to disabled Delete menu item for active profile
When the Delete action is disabled because the profile is active, show
a StyledTooltip ('Cannot delete the active profile') over the item so
users understand why it is not interactive.
- Add SETTINGS$PROFILE_CANNOT_DELETE_ACTIVE i18n key + all translations
- Wrap the disabled Delete MenuItem in StyledTooltip with a span so
pointer events reach the trigger on a disabled button
- Add complementary test confirming Delete is enabled when isActive=false
- Extend i18n mock with the new key
Co-authored-by: openhands <openhands@all-hands.dev>
* test: fix delete-modal tests to use inactive profile
The two delete-modal tests were clicking Delete on the active profile,
which is now correctly disabled. Switch both tests to the second
(inactive) profile so they exercise the enabled path.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: route LLM metadata fetches through cloud proxy for cloud backends
When the active backend is 'cloud', ConfigService.searchProviders,
ConfigService.searchModels, and fetchVerifiedModelsByProvider all called
getAgentServerClientOptions(), which delegates to getEffectiveLocalBackend().
That returns null for cloud backends, so NoBackendAvailableError was thrown,
surfacing as the 'No backend is configured' toast on /settings/llm.
Follow the established cloud-vs-local branching pattern: for cloud backends,
route the three LLM metadata endpoints (/api/llm/providers,
/api/llm/models, /api/llm/models/verified) through callCloudProxy so
correct Bearer auth is used and CORS is handled; for local backends keep
the existing LLMMetadataClient path unchanged.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: extract named fields from raw cloud proxy LLM responses
callCloudProxy returns the full JSON response body, while
LLMMetadataClient extracts named sub-fields:
getProviders() → response.data.providers (raw: { providers: string[] })
getModels() → response.data.models (raw: { models: string[] })
getVerifiedModels() → response.data.models (raw: { models: Record<string, string[]> })
The cloud proxy calls were treating the whole response object as the
array/record directly, causing '(models ?? []).filter is not a function'
and garbled provider/model dropdowns.
Use typed raw-response wrappers and chain .then(raw => raw?.field ?? null)
to mirror exactly what LLMMetadataClient does internally.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: use cloud API search endpoints for providers and models
For cloud backends, /api/llm/providers and /api/llm/models are wrong —
those are local agent-server endpoints. The cloud API exposes:
GET /api/v1/config/providers/search → ProviderPage
GET /api/v1/config/models/search → LLMModelPage
Both return the same shape the local ConfigService types already define,
so the cloud path calls callCloudProxy directly and returns the response
as-is, with no reconstruction logic needed.
The intermediate fetchVerifiedModelsByProvider step is skipped for cloud
entirely — the cloud search endpoints embed verified status natively.
Co-authored-by: openhands <openhands@all-hands.dev>
* style: fix prettier formatting on buildCloudQueryString signature
Co-authored-by: openhands <openhands@all-hands.dev>
* docs: address review bot suggestions on cloud LLM metadata fix
- Add JSDoc to ConfigService.searchModels and searchProviders noting that
verifiedByProvider is ignored for cloud backends (cloud API embeds
verified status directly on each returned item)
- Expand comment on fetchVerifiedModelsByProvider's cloud early-return to
clarify it is safe to treat as a no-op for cloud callers
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
* chore: bump openhands-automation to 1.0.0a6
* Remove fragile automation version assertion test
The test hardcoded an exact version string that breaks on every
version bump. It provides no utility — the version is already
covered by integration/config tests; pinning it in a unit test
just means a manual edit is required on every release.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
* chore: always publish to npm with --tag latest until first stable release
All alpha/beta/rc versions now get the 'latest' dist-tag so plain
'npm install @openhands/agent-canvas' always resolves to the newest
published release. The per-tier dist-tags (alpha/beta/rc) can be
re-introduced once the first full stable version is ready to ship.
Co-authored-by: openhands <openhands@all-hands.dev>
* chore: auto-graduate npm dist-tag when first stable release ships
At publish time, query npm for any published version without a pre-release
suffix. If none exists, all releases (alpha/beta/rc/stable) use --tag latest
so plain 'npm install' always resolves to the newest build. Once a stable
version has been published, pre-release versions revert to their own
dist-tags (alpha/beta/rc) automatically — no workflow change required.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: align dev-stack OH_PERSISTENCE_DIR and automation DB path with Docker
Two path mismatches between `npm run dev` and Docker prevented config and
automation data from being shared across the two modes:
1. **OH_PERSISTENCE_DIR wrong (supersedes PR #959)**
Docker's entrypoint.sh sets OH_PERSISTENCE_DIR to $HOME/.openhands
(OPENHANDS_DIR). PR #959 added the env var to buildAgentServerEnv() but
used config.stateDir (~/.openhands/agent-canvas) — one level too deep.
Settings and secrets written by Docker live at ~/.openhands/settings.toml
etc; dev wrote to ~/.openhands/agent-canvas/settings.toml.
Fix: use path.dirname(config.stateDir) = ~/.openhands, matching Docker.
2. **Automation DB in wrong directory**
dev-with-automation.mjs put the SQLite DB at
~/.openhands/agent-canvas/automations.db. Docker puts it at
~/.openhands/automation/automations.db (from config/defaults.json
paths.automationDb = "automation/automations.db" relative to OPENHANDS_DIR).
Fix: use join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb)
so both modes resolve to ~/.openhands/automation/automations.db.
Also ensure the directory is created on startup (mirrors Docker's mkdir -p).
3. **Mock-LLM test cleanup**
Update playwright.mock-llm.config.ts to clean both STATE_DIR and the new
AUTOMATION_DB_DIR (.tmp/automation/) before each test run, since the DB now
lives outside STATE_DIR.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: use dev_conversations dir for npm to isolate from Docker conversations
npm dev mode now writes conversations to ~/.openhands/agent-canvas/dev_conversations
instead of conversations, keeping them separate from Docker's conversations dir.
OH_CONVERSATIONS_PATH (set via buildAgentServerEnv) is the single control point;
all mkdir and releaseStaleConversationLeases calls updated to match.
Also condense verbose multi-line comments on OH_PERSISTENCE_DIR and
AUTOMATION_DB_URL to single lines.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: use join(config.stateDir, dev_conversations) in ensureDirectories
The outer config in dev-with-automation.mjs does not have conversationsPath
(that is a SafeDevConfig property). Use the same join(stateDir, ...) pattern
as the other dirs in the list.
Also removes the debug console.log and restores dev-safe.mjs and dev-static.mjs
to dev_conversations after the manual revert.
Co-authored-by: openhands <openhands@all-hands.dev>
* test: update conversationsPath assertion to match dev_conversations
The implementation in buildConfigFromPorts was changed to use
'dev_conversations' as the subdirectory name, but the corresponding
test assertion was not updated.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
The Release Tag ruleset requires test-and-build (ubuntu) to pass
before v* tags can be pushed, but CI previously only ran on main and
pull_request events. This caused rel-* version bump commits to fail
the tag protection check unless a workaround PR was opened.
Co-authored-by: openhands <openhands@all-hands.dev>
Fixes#980
The agent prompt in recommended-automations-launcher and the
RUNTIME_SERVICES block in agent-server-adapter both advertised
X-API-Key as the auth header for the local automation backend.
The automation service (openhands-automation) does not accept
X-API-Key — it accepts Authorization: Bearer and X-Session-API-Key.
X-Session-API-Key is the established local convention: the agent
server uses it, the frontend automation API client uses it (with an
explicit comment that both backends share the same header), and
auth.py describes it as matching that convention. Update both call
sites and the corresponding test assertion to use X-Session-API-Key.
Co-authored-by: openhands <openhands@all-hands.dev>
- create-release.yml now triggers on v* tag push (not PR merge).
The release branch is never merged to main; publishing is triggered
by pushing the tag directly to the rel-X.Y.Z branch.
- npm-publish.yml resolves the dist-tag from the version string:
alpha → alpha, beta → beta, rc → rc, stable → latest
Removes the temporary 'always publish as latest' workaround.
- release.md skill and AGENTS.md updated to document the new model.
Co-authored-by: openhands <openhands@all-hands.dev>
Without AUTOMATION_AGENT_SERVER_URL, ServiceSettings.is_local_mode returns
False and the automation server tries to validate the session API key against
the OpenHands cloud API (app.all-hands.dev/api/v1/users/me), which returns
401 for locally-generated keys.
Setting AUTOMATION_AGENT_SERVER_URL activates the local-mode fast path in
authenticate_request(), which validates the key directly against
AUTOMATION_LOCAL_API_KEY (already set to the session key) without any
network call. This matches what dev-with-automation.mjs and dev-static.mjs
already do correctly.
Co-authored-by: openhands <openhands@all-hands.dev>
shouldUseProxyOrigin() only redirected to window.location.origin when the
browser was at a non-local hostname. When the configured backend uses
127.0.0.1 and the browser accesses via localhost (Docker default), both
are in localHosts so the proxy substitution was skipped — sending the
automation health check directly to http://127.0.0.1:8000 and triggering
a CORS error from http://localhost:8000.
Expand the condition to also proxy when the configured local hostname
differs from the browser hostname (e.g. 127.0.0.1 vs localhost).
Fixes#974
Co-authored-by: openhands <openhands@all-hands.dev>
Dev mode (npm run dev) was always using the hardcoded static default key
'openhands-dev-secret-key-change-in-prod' from config/defaults.json.
Docker mode (docker/entrypoint.sh) generates a random key on first run and
persists it to ~/.openhands/agent-canvas/secret-key.txt.
When both modes share the same ~/.openhands directory, they used different
keys — causing decryption failures for any settings encrypted by the other
mode.
Fix: remove the static default in dev-safe.mjs and instead use
getOrCreatePersistedApiKey() with a new DEFAULT_SECRET_KEY_PATH constant
pointing to the same secret-key.txt file that Docker reads/writes.
Whichever mode runs first generates and persists the key; the other picks
it up automatically on next start.
- Add DEFAULT_SECRET_KEY_PATH export to dev-safe.mjs
- Replace 'env.OH_SECRET_KEY || DEFAULT_SECRET_KEY' with
'env.OH_SECRET_KEY || getOrCreatePersistedApiKey(secretKeyPath, "secret")'
- Update startup log to show persisted file path (not 'default (for local development)')
- Remove now-unused 'defaults.secretKey' from config/defaults.json
- Update AGENTS.md to reflect the new shared-file behavior
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: fast-fail dev scripts when ports are already in use
Add `assertPortsFree` to `scripts/dev-safe.mjs` that checks each
preferred port and throws a descriptive error if any are already bound,
rather than silently binding to an alternative port.
- `buildSafeDevConfigAsync` now calls `assertPortsFree` before
returning config, so `npm run dev:minimal` exits immediately with a
clear message when the agent-server port is occupied.
- `dev-with-automation.mjs`'s `buildConfig` does the same for all four
service ports (ingress, agent-server, automation, vite), covering
`npm run dev` and `npm run dev:static`.
- `buildConfig` gains env-var overrides for internal service ports
(`OH_CANVAS_SAFE_BACKEND_PORT`, `OH_CANVAS_SAFE_AUTOMATION_PORT`,
`OH_CANVAS_SAFE_VITE_PORT`) so tests and advanced users can redirect
ports without touching production defaults.
Tests updated accordingly:
- Old "falls back when port is busy" tests replaced with "throws when
port is busy" equivalents.
- New `assertPortsFree` describe block with three targeted cases.
- `envWithIsolatedKeyPath` in `dev-with-automation.test.ts` now seeds
high free ports so the pre-flight check passes when a real dev stack
is running during local test execution.
Closes#934
Co-authored-by: openhands <openhands@all-hands.dev>
* refactor: address review bot suggestions on assertPortsFree
- Check ports in parallel with Promise.all instead of sequentially
(each check is independent I/O, so parallel is faster and more idiomatic)
- Include vscode port in the buildSafeDevConfigAsync pre-flight check
alongside the agent-server port, so a conflict on that internal port
is also caught with a helpful message rather than a cryptic spawn error
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
PR #229 flipped shouldLoadPublicSkills() from opt-out (default true) to
opt-in (default false) for dev-latency reasons. PR #362 patched the
conversation-start path by hardcoding load_public_skills: true, but
that fix was inadvertently lost in the #457 refactor which reintroduced
shouldLoadPublicSkills() inside the new buildAgentContext() helper.
This restores the original opt-out semantics:
- shouldLoadPublicSkills() now returns true unless VITE_LOAD_PUBLIC_SKILLS
is explicitly set to "false"
- .env.sample updated to document the opt-out form
- Tests updated to match the new default
- AGENTS.md updated (it already said "defaults to true" but the code
disagreed — now they agree)
Co-authored-by: openhands <openhands@all-hands.dev>
* refactor: update @openhands/extensions from MCP to integrations
Migrate from @openhands/extensions/mcps to @openhands/extensions/integrations
following the upstream rename in OpenHands/extensions.
Key changes:
- MCP_CATALOG -> INTEGRATION_CATALOG
- McpCatalogEntry -> IntegrationCatalogEntry
- MarketplaceTemplate -> IntegrationTransport
- MCP_LOGOS/MCP_FALLBACK_LOGO -> INTEGRATION_LOGOS/INTEGRATION_FALLBACK_LOGO
- entry.template -> entry.connectionOptions[].transport (via getDefaultTemplate helper)
- automation.requiredMcpIds -> automation.requiredIntegrationIds
The new integration catalog structure supports multiple connection options per
entry (e.g., OAuth + stdio fallback). A getDefaultTemplate() helper extracts
the transport config from the default connection option.
Co-authored-by: openhands <openhands@all-hands.dev>
* Set correct version
* fix: resolve lint errors
- Replace Date.now() with useId() for pure render function compliance
- Use optional chaining in handleStdioSubmit
- Remove unused eslint-disable directive
- Fix prettier formatting issues
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: add getInstallableTemplate for stdio-preferred MCP installations
Integration entries like GitHub and Slack now default to OAuth transport,
but the UI doesn't support OAuth yet. Add getInstallableTemplate() that
prefers stdio (API key-based) connection options over OAuth defaults.
- Add getInstallableTemplate() that finds stdio options first
- Update install-server-modal.tsx to use getInstallableTemplate
- Update recommended-automations-*.tsx to use getInstallableTemplate
- Update findCatalogEntryForServer to check ALL connection options
This ensures the install modal shows the correct input fields (e.g.,
GITHUB_PERSONAL_ACCESS_TOKEN) and correctly detects installed servers.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(skills): save disabled_skills when settings has no prior disabled_skills field
The hydration effect in SkillsSettingsScreen only set hasHydratedInitialSettings=true
when settings?.disabled_skills was truthy. When no skills had ever been disabled,
the server omits the field entirely (undefined), so the condition was always false:
if (settings?.disabled_skills) { ... } // skipped when field is absent
Because settings?.disabled_skills is undefined both before *and* after settings
load, the dependency array [settings?.disabled_skills] never changed value on
load either, so the effect never ran a second time. hasHydratedInitialSettings
stayed false, and the save effect's early-return guard blocked every toggle.
Fix by gating on settingsLoading instead and defaulting the missing field with
?? []. Also add a localStorage stub for Node.js 25+ which ships a built-in
localStorage that is non-functional without --localstorage-file, breaking the
zustand persist middleware in the test environment.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(tests): use mockResolvedValue(true) for saveSettings spy
saveSettings returns Promise<boolean>, not Promise<void>, so
mockResolvedValue(undefined) caused TS2345 type errors in CI.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(skills): persist disabled_skills to localStorage for local backend
The local agent-server has no concept of disabled_skills and its
PATCH /api/settings strips the field before sending. As a result,
toggling a skill off appeared to work in the UI but was never
persisted -- the value was silently discarded on every save and
page refresh reverted the toggle.
Fix by mirroring the existing app-preferences pattern:
- saveSettings (local path): call writeStoredDisabledSkills before
stripping the field from the PATCH payload.
- transformApiResponse: call readStoredDisabledSkills and overlay it
onto the partial Settings returned from the API, so every getSettings
call (cached or fresh) surfaces the stored value.
Export DISABLED_SKILLS_STORAGE_KEY so tests can reference the key
without duplicating the string literal.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: truncate long conversation title on mobile to keep tab toggle visible
When a conversation title was long enough it would overflow and hide the
RightPanelToggle button (the tab toggle) in the top-right of the mobile
header. Fixed by:
- Adding `min-w-0` to the left-side flex container in
ConversationNameWithStatus so it can shrink and the toggle stays in view
- Adding `shrink-0` to the status dot wrapper so it never collapses
- Adding `min-w-0` to ConversationName's root div (flex item) to allow
it to shrink within the parent
- Removing `w-fit max-w-fit` from the title div (both prevented
`truncate` from ever activating) so the title now properly truncates
with an ellipsis when too long
- Adding `shrink-0` to the ellipsis-button wrapper so it is always
accessible regardless of title length
Fixes#848
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(skills): save disabled_skills when settings has no prior disabled_skills field
The hydration effect in SkillsSettingsScreen only set hasHydratedInitialSettings=true
when settings?.disabled_skills was truthy. When no skills had ever been disabled,
the server omits the field entirely (undefined), so the condition was always false:
if (settings?.disabled_skills) { ... } // skipped when field is absent
Because settings?.disabled_skills is undefined both before *and* after settings
load, the dependency array [settings?.disabled_skills] never changed value on
load either, so the effect never ran a second time. hasHydratedInitialSettings
stayed false, and the save effect's early-return guard blocked every toggle.
Fix by gating on settingsLoading instead and defaulting the missing field with
?? []. Also add a localStorage stub for Node.js 25+ which ships a built-in
localStorage that is non-functional without --localstorage-file, breaking the
zustand persist middleware in the test environment.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(tests): use mockResolvedValue(true) for saveSettings spy
saveSettings returns Promise<boolean>, not Promise<void>, so
mockResolvedValue(undefined) caused TS2345 type errors in CI.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(skills): persist disabled_skills to localStorage for local backend
The local agent-server has no concept of disabled_skills and its
PATCH /api/settings strips the field before sending. As a result,
toggling a skill off appeared to work in the UI but was never
persisted -- the value was silently discarded on every save and
page refresh reverted the toggle.
Fix by mirroring the existing app-preferences pattern:
- saveSettings (local path): call writeStoredDisabledSkills before
stripping the field from the PATCH payload.
- transformApiResponse: call readStoredDisabledSkills and overlay it
onto the partial Settings returned from the API, so every getSettings
call (cached or fresh) surfaces the stored value.
Export DISABLED_SKILLS_STORAGE_KEY so tests can reference the key
without duplicating the string literal.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
* feat: pre-flight MCP test before save (#785)
Validate MCP server connectivity via POST /api/mcp/test before saving
in both the marketplace install modal and the custom-server editor.
- Add McpService.testServer() using MCPClient from @openhands/typescript-client
- Add useTestMcpServer() useMutation hook
- InstallServerModal: test before addMcpServer; show inline error on failure,
keep modal open (onClose not called); button shows Verifying… then Saving…
- CustomServerEditor: same pre-flight pattern before add/update; also exposes
a standalone Test Connection button via MCPServerForm's new onTest prop
- MCPServerForm: add onTest/isTestPending/testMessage props; extract buildConfig
helper; add handleTestClick using formRef; render Test Connection button and
testMessage inline above action buttons
- Add i18n keys: MCP$TEST_BUTTON, MCP$VERIFYING, MCP$TEST_SUCCESS,
MCP$TEST_ERROR_TIMEOUT, MCP$TES MCP$TEST_ERROR_TIMEOUT, MCP$TES MCP$TEST_ERROR_TIMEOUT, MCP$TES MCP$TEST_ERROR_TIMEOUT, MCP$TES MCP$TEST_ERROR_TIMEOUT, MCP$TES + keeps modal open, success path, Verifying label)
Closes#785
Co-authored-by: openhands <openhands@all-hands.dev>
* test: stub McpService.testServer in tests that save through the pre-flight
Three test suites click a submit button whose handler now runs the
pre-flight connectivity test before calling saveSettings. None of
them mocked McpService.testServer, so the mutation errored out before
reaching the save step.
Add vi.spyOn(McpService, 'testServer').mockResolvedValue({ ok: true, tools: [] })
to the beforeEach of each affected suite so the test-then-save flow
resolves as expected and the existing assertions remain valid.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: decode HTML entities in MCP test-server error messages
The Python backend passes error strings through html.escape(), so
apostrophes arrive as ' and slashes as /. Add a one-liner
decodeHtmlEntities() helper in McpService that uses a temporary
<textarea> to let the browser's own HTML parser decode the string
before it reaches any UI component.
Decoding happens once, at the API boundary, so every consumer
(InstallServerModal, CustomServerEditor, etc.) automatically gets
clean text without needing its own unescaping logic.
Add a focused McpService unit test (4 cases) that mocks MCPClient
via vi.hoisted + vi.mock to exercise the real decoding path:
- success responses pass through unchanged
- ' / / entities decoded in the error field
- numeric (<) and hex (s) entities decoded
- stdio config mapped to the correct MCPServerSpec shape
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: render MCP error strings as plain text with newlines
Two bugs in the error message display path:
1. HTML-entity encoding: react-i18next escapes interpolated values by
default (\' -> ', / -> /). Fix by using the no-escape
prefix {{-error}} in the MCP$TEST_ERROR_UNKNOWN translation string
so the raw error text is interpolated verbatim.
2. Newlines ignored: the \n in multi-line Python tracebacks was
swallowed by the browser. Fix by adding whitespace-pre-wrap to the
<p> elements in install-server-modal (globalError) and
mcp-server-form (testMessage) so \n renders as a visual line break.
Also revert the previous decodeHtmlEntities approach from the service
layer — the backend does not HTML-escape thlayer — the backend does nwas introduced by i18next, not the server. Update McpService telayer — the backend does not HTML-escape thlayer — the backend dngs in the
translation layer instead.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
* perf(useLocalGitInfo): consolidate git probe into single bash round-trip
Replace the former probeGitInfoAtDir + probeNestedRepoInDir pair (which
issued 2-3 serial bash WebSocket round-trips per poll cycle) with a single
GIT_INFO_COMMAND shell script that handles both the root and nested-repo
cases in one run() call.
The script outputs two newline-separated lines: <remote-url>\n<branch>.
The new probeGitInfo function runs the script and splits on the first
newline to recover the same LocalGitInfo struct as before.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: prettier quote style in use-local-git-info.ts
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
* feat: execute git-info probe commands over bash-events WebSocket
Replace per-poll REST calls in useLocalGitInfo with a persistent
WebSocket connection to /sockets/bash-events.
Previously, useQuery's refetchInterval:10_000 caused
AgentServerRuntimeService.executeCommand to fire individual HTTP
requests on every tick (find + 2 git commands × up to 3 candidate
dirs) to probe the workspace for git metadata.
Changes:
- websocket-url.ts: export buildBashWebSocketUrl() using the same
host/pathPrefix extraction as the conversation-events URL builder
- use-bash-command-runner.ts: new hook that maintains a persistent WS
connection to /sockets/bash-events and exposes runCommand(command,
cwd, timeout) → Promise. Commands queued during CONNECTING are
flushed on open; all in-flight commands are rejected on
close/error/unmount.
- use-local-git-info.ts: swap AgentServerRuntimeService.executeCommand
for useBashCommandRunner; keep refetchInterval:10_000 so branch
changes (e.g. git checkout) are still reflected without a full
refresh, but each probe now reuses the open socket rather than
opening new HTTP connections.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: auto-clear backend health on successful conversation list fetch
When the backend health probe fails 5 times in a row, the disabled flag
is written to localStorage and background polling stops permanently.
This left the status dot red even after the backend recovered, because
no successful API call ever cleared the disabled state.
Fix by wiring a global QueryCache onSuccess handler that calls
recordBackendSuccess(backendId) whenever a query tagged with
meta.backendId succeeds. Tag usePaginatedConversations (which polls
every 10s) with the active backend's id so a recovered backend clears
its stale failure state within one polling cycle automatically.
Any future query that targets a specific backend can opt in by adding
meta: { backendId } to its options.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
Pins @openhands/extensions from 7b33f64 (May 19) to b8c1869 (May 23),
which adds two new automation catalog entries:
- github-repo-monitor: watches GitHub repos for @OpenHands mentions
- slack-channel-monitor: watches Slack channels for @openhands mentions
Updates the popularity-order unit test to reflect the new 7-entry catalog
ranking (github-repo-monitor at rank 98 sits between github-pr-reviewer
at 100 and slack-standup-digest at 94).
Co-authored-by: openhands <openhands@all-hands.dev>
Add openhands-sdk==${VERSION} to the --with list in both PyPI
resolution branches (specific version and default) of
buildAgentServerCommand so all four packages are pinned to the
same versions.agentServer:
openhands-agent-server, openhands-sdk, openhands-tools, openhands-workspace
Without this pin, openhands-sdk floated to the latest release on
PyPI (a transitive dep with no version bound in the published
openhands-agent-server metadata), causing non-reproducible builds
and version skew between the banner and defaults.json.
The local-path (editable) and git-ref branches are unaffected —
they already source all packages from the same checkout/ref.
Update the two affected unit-test cases to expect the new pinned
openhands-sdk entry in the args array.
Fixes#767
Co-authored-by: openhands <openhands@all-hands.dev>
Replace per-poll REST calls in useLocalGitInfo with a persistent
WebSocket connection to /sockets/bash-events.
Previously, useQuery's refetchInterval:10_000 caused
AgentServerRuntimeService.executeCommand to fire individual HTTP
requests on every tick (find + 2 git commands × up to 3 candidate
dirs) to probe the workspace for git metadata.
Changes:
- websocket-url.ts: export buildBashWebSocketUrl() using the same
host/pathPrefix extraction as the conversation-events URL builder
- use-bash-command-runner.ts: new hook that maintains a persistent WS
connection to /sockets/bash-events and exposes runCommand(command,
cwd, timeout) → Promise. Commands queued during CONNECTING are
flushed on open; all in-flight commands are rejected on
close/error/unmount.
- use-local-git-info.ts: swap AgentServerRuntimeService.executeCommand
for useBashCommandRunner; keep refetchInterval:10_000 so branch
changes (e.g. git checkout) are still reflected without a full
refresh, but each probe now reuses the open socket rather than
opening new HTTP connections.
Co-authored-by: openhands <openhands@all-hands.dev>
* Persist home page prompt draft to sessionStorage
Saves the chat input text on the 'Let's Start Building' home page to
sessionStorage (debounced) so it survives navigation and failed
conversation starts.
- Restored when navigating away and back to the home page
- Restored on page refresh within the same browser session
- Cleared only on successful conversation creation (navigate away)
- Discarded automatically when the browser tab is closed
useDraftPersistence: when conversationId is undefined (home page context),
save/restore/clear via sessionStorage[HOME_PROMPT_DRAFT_KEY] instead of
the existing no-op path. Exports HOME_PROMPT_DRAFT_KEY constant.
HomeChatLauncher: remove the sessionStorage key in onSuccess before
navigating to the new conversation, so a fresh home page starts clean.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: flush home-page draft to sessionStorage on unmount
The previous implementation debounced sessionStorage writes (500ms),
which meant text typed and then quickly navigated away from — before
the debounce fired — was never persisted. The unmount cleanup cancelled
the pending timer, leaving sessionStorage with the stale value from the
last full save, which was then incorrectly restored on the next visit.
Fix: in the unmount cleanup, synchronously flush the current input text
to sessionStorage when conversationId is undefined (home-page context).
The debounce still handles the in-flight saves; the flush ensures nothing
is lost when the component tears down with a pending timer.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: save home-page draft synchronously; use ref not DOM in flush
Two bugs caused the wrong prompt to be restored after in-app navigation:
1. React 18 clears ref.current during the synchronous commit phase, before
async useEffect cleanup functions run. The flush-on-unmount was reading
chatInputRef.current — which is null at that point — so it never saved
the updated text, leaving sessionStorage with the stale value.
2. The 500 ms debounce in saveDraft left a window where text typed and
then navigated away from quickly would also not reach sessionStorage.
Fix:
- saveDraft (home-page path): write synchronously on every onInput event;
no debounce needed for a sessionStorage write of a few hundred chars.
Track the written text in lastHomeTextRef.
- Restoration: seed lastHomeTextRef with the restored draft so an
immediate navigate-away (without typing) still preserves it.
- Unmount flush: read from lastHomeTextRef (always valid) instead of
chatInputRef (null by cleanup time).
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: ignore stale messageToSend for home-page draft restoration
useAutoResize has an effect that writes `value.text` (sourced from the
Zustand store's `messageToSend`) directly to the chat input element.
After navigating away from /conversations and back, the store still holds
the stale `messageToSend = { text: '' }` set during the previous visit.
This caused a race with the sessionStorage draft restoration:
1. useDraftPersistence (effect B) restored the saved draft into the element
2. useAutoResize's value-update effect (E) then ran with the stale
empty-string value and set element.textContent = '', erasing the draft
3. React 18 StrictMode double-invokes effects, so the second pass of
the drawer-state effect (D) read the now-empty element and queued
another setMessageToSend(''), keeping the text permanently gone
A page refresh reset the store to messageToSend = null, which caused
useAutoResize to skip its content update (value === undefined), making
the draft visible – hence 'refresh works but navigation back doesn't'.
Fix: on the home page (no conversationId) return null for messageToSend
from useChatInputLogic, keeping value === undefined for useAutoResize
and ensuring the content-update effect is never triggered there.
Also guard the drawer-state effect so it only runs in conversation views.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: don't restore home-page draft on unmount if key was intentionally cleared
The unmount flush in useDraftPersistence wrote lastHomeTextRef.current
back to sessionStorage whenever the component tore down on the home page.
This undid the explicit sessionStorage.removeItem call that
HomeChatLauncher.onSuccess makes immediately before navigating to the new
conversation, so the submitted prompt was still present when the user
navigated back home.
Fix: wrap the sessionStorage.setItem in the unmount cleanup with a
check that the key already exists. saveDraft writes synchronously on
every keystroke, so the key is always present when there is unsaved
text. If the key is absent it was intentionally removed (successful
conversation start), and the flush must not restore it.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
* feat(automations): add Download Tarball option to automation kebab menu
Add a 'Download tarball' menu item to the automation dropdown in both
the list card (AutomationCard) and the detail page header (DetailHeader).
The item appears after 'Turn On/Off' and before 'Delete', and triggers a
browser download via GET /api/automation/v1/{id}/tarball.
- Add download.svg icon (Heroicons outline style)
- Add AUTOMATIONS$DOWNLOAD_TARBALL i18n key with translations for all
15 supported locales
- Wire menu item to the tarball endpoint using a programmatic anchor click
Ported from OpenHands/automation feat/tarball-download-endpoint branch.
Depends on the new GET /{id}/tarball endpoint added in that PR.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(automations): rename tarball label to 'Tarball' and fix auth on download
- Shorten menu item label from 'Download tarball' to 'Tarball' to prevent overflow
- Replace bare anchor-click download (which opened an unauthed browser request)
with AutomationService.downloadTarball(), which fetches via the authenticated
axios client (Bearer token for local, cloud proxy for cloud backends) and
triggers the download from a blob URL
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
The candidateDirs array introduced in #455 appended the Docker-specific
paths "/workspace/project" and "workspace/project" as unconditional
fallbacks after the configured workingDir. For any conversation without
a git repo (or any non-Docker environment), the probe would iterate all
three candidates on every 10-second tick, firing 6+ bash commands each
time — two of which target a path that doesn't exist outside the
container.
Remove the hardcoded fallbacks. The probe now queries only workingDir
(from conversation.workspace.working_dir, or VITE_WORKING_DIR, or the
built-in default). The nested-repo find is retained for the common
local-clone flow where the agent clones a repo into a subdirectory of
the workspace.
Co-authored-by: openhands <openhands@all-hands.dev>
The previous attempt (#603) collapsed the host-side and sandbox-side
agent-server URLs onto a single value, `host.docker.internal:<hostPort>`,
on the assumption that the host's /etc/hosts would alias it back to
loopback. That holds on Docker Desktop macOS but NOT on OrbStack / colima
/ Linux hosts, where `host.docker.internal` only exists inside
containers. On those hosts the automation backend (which runs on the
host via uvx) fails to resolve the URL on its very first `_upload`
call:
[Errno 8] nodename nor servname provided, or not known
so dispatch never reaches `_start_bash`, and every automation run ends
up with `bash_command_id: NULL`.
Fix:
* Restore `AUTOMATION_AGENT_SERVER_URL` to `http://localhost:<hostPort>`
in every mode. localhost on the host always resolves to the published
agent-server port; this is the URL the *backend* uses for HTTP calls.
* Add a new launcher option `sandboxAgentServerUrl` that is exported
as `AUTOMATION_SANDBOX_AGENT_SERVER_URL`. The automation backend
(OpenHands/automation#125) uses this to override the AGENT_SERVER_URL
it exports into the in-sandbox bash chain. In dev:docker this is
`http://127.0.0.1:8000` — the agent-server's in-container loopback,
which avoids bouncing through the host port-forward.
* Plumb `sandboxAgentServerUrl` through dev-with-automation.mjs::main
(mirrors the existing `automationApiHost` / `automationWorkspaceBase`
pattern) and set it in dev-docker.mjs.
* Older automation backends that don't recognise
AUTOMATION_SANDBOX_AGENT_SERVER_URL ignore it and fall back to
AUTOMATION_AGENT_SERVER_URL, so this is forward-compatible.
Dockerless modes (`dev`, `dev:automation`) leave
`sandboxAgentServerUrl` undefined; the backend falls back to
AUTOMATION_AGENT_SERVER_URL, which is correct for them.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(dev:docker): set AUTOMATION_WORKSPACE_BASE to a container-safe path
When agent-canvas is started via `npm run dev:docker`, the agent-server
runs in a container but the automation backend runs on the host via uvx.
The dispatcher resolves `AUTOMATION_WORKSPACE_BASE` on the host (where
it expands to the host's $HOME, e.g. /Users/<you>/.openhands/...) and
then embeds that absolute path into a `mkdir -p ...` shell command
that is executed *inside* the agent-server container. The container
has no /Users directory and the non-root user can't write to /, so
every preset automation fails with:
mkdir: cannot create directory '/Users': Permission denied
Fix:
* Thread a new `automationWorkspaceBase` option through the launcher
(mirroring the existing `viteWorkingDir` pattern).
* `dev-docker.mjs` now passes `CONTAINER_WORKSPACES_DIR` — the same
in-container path already used as the agent-server's working-dir
root, so it's guaranteed to exist and be writable.
* Resolution order for `AUTOMATION_WORKSPACE_BASE` is now:
1) explicit user env var (wins over everything; previously the
hard-coded value silently clobbered user-set values)
2) launcher-provided default (container-safe in docker mode)
3) host-side fallback under config.stateDir (unchanged for dockerless)
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(dev:docker): route AUTOMATION_BASE_URL through host.docker.internal
In `npm run dev:docker`, the automation backend's `AUTOMATION_BASE_URL`
was hard-coded to `http://localhost:${ingressPort}`. That URL is then:
1. propagated into each automation sandbox as `AUTOMATION_API_URL`
(dispatcher.py:213), and
2. consumed by the auto-generated `setup.sh` inside the sandbox to
hit `${AUTOMATION_API_URL}/sdk-version`.
The sandbox is a separate Docker container, so `localhost` resolves to
the sandbox itself, not the host ingress. Every preset automation run
therefore failed at the very first step with:
[setup] ERROR: Failed to fetch SDK version from
http://localhost:8000/api/automation/sdk-version
Reachability check from inside a container in the same network:
http://localhost:8000/api/automation/sdk-version -> 404
http://host.docker.internal:8000/api/automation/sdk-version -> 200
Fix (mirrors the existing `automationWorkspaceBase` plumbing for the
same host-vs-container class of bug):
* New `automationApiHost` launcher option threaded through main() and
stamped onto config.
* `dev-docker.mjs` passes `automationApiHost: "host.docker.internal"`.
* `startAutomationBackend` resolves `AUTOMATION_BASE_URL` with
precedence: user env > launcher-provided host > `localhost`.
Dockerless modes (`dev`, `dev:automation`) are unaffected — they
don't pass `automationApiHost` and fall back to the existing
`http://localhost:${ingressPort}` value.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(dev:docker): expose AGENT_SERVER_URL and SESSION_API_KEY aliases to the sandbox
The OpenHands SDK boilerplate emitted by automation prompt/plugin presets
reads AGENT_SERVER_URL and SESSION_API_KEY in main.py / setup.sh when
calling back into the agent-server from an automation run. The
agent-server itself sets OH_INTERNAL_SERVER_URL at startup and we set
OH_SESSION_API_KEYS_0 via the container/process env, but neither of the
unprefixed aliases the SDK actually reads were defined — so every preset
automation hit ECONNREFUSED / 401 the moment its setup.sh tried to hit
the agent-server.
Mirror the values under their canonical SDK names in both the dockerless
buildAgentServerEnv() (covers dev / dev:automation) and in dev-docker.mjs
containerEnv (covers dev:docker). Inside the dev:docker container the
agent-server always listens on port 8000, and 0.0.0.0 normalises to
127.0.0.1 (matching the agent-server's own OH_INTERNAL_SERVER_URL
construction), so the docker path uses http://127.0.0.1:8000 directly.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(dev:docker): route AUTOMATION_AGENT_SERVER_URL via host.docker.internal
This env var plays a dual role: the automation backend uses it directly
to call the agent-server's upload/bash REST APIs (host-side), and the
same value is exported as AGENT_SERVER_URL into the in-sandbox bash
command that main.py uses to call back. In dev:docker the script runs
inside the agent-server container, so the URL has to be reachable from
both sides.
http://localhost:${agentServerPort} was fine for dockerless modes
(both backend and agent-server live on the host) but broke dev:docker:
from inside the container localhost:${hostPort} doesn't resolve, and
`main.py` failed at the first RemoteWorkspace call.
Reuse the same automationApiHost launcher option already plumbed for
AUTOMATION_BASE_URL (Bug 2): dev:docker passes host.docker.internal, so
the URL works as a loopback alias from the host and bounces back into
the container via the published port-forward from the sandbox side.
Dockerless modes keep the previous `localhost` default.
Co-authored-by: openhands <openhands@all-hands.dev>
* revert: drop SESSION_API_KEY alias from agent-server env
The OpenHands SDK's sanitized_env() strips SESSION_API_KEY from every
bash subprocess as a (cosmetic) defense-in-depth measure, so setting
this alias on the agent-server's process env had no effect on what the
sandbox script actually sees. Worse, it created cross-purposes between
the dev stack (which exports it) and the SDK (which strips it).
Keep the AGENT_SERVER_URL alias — that one is genuinely needed and
isn't subject to filtering. SESSION_API_KEY is now obtained inside the
sandbox script via OH_SESSION_API_KEYS_0 (handled in an accompanying
PR against openhands/automation), which the SDK does not strip.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
* feat(automations): add logs modal to activity log items
Each AutomationRun now surfaces its bash command output via a small
terminal-icon button placed to the left of the run status badge in the
activity log. Clicking the icon opens a modal that fetches the
BashCommand event and all paginated BashOutput events for the run.
- Add bash_command_id to AutomationRun type (and mock data).
- New BashService.getCommandLogs(): cloud-aware reader for bash events
that routes through callCloudProxy on cloud backends (runtime URL +
session-api-key auth) and through BashClient directly on local
backends. Pages through BashOutput events sorted by timestamp.
- New useBashCommandLogs hook: hydrates the run's conversation to
resolve runtime URL + session API key, then drives the BashService
query. Surfaces resolution states (loading, conversation missing,
sandbox gone) so the modal can render meaningful empty states.
- New RunLogsModal: renders interleaved stdout/stderr from the command
with stderr highlighted, exit code, and explicit messaging when the
command is missing or the sandbox is no longer alive.
- Activity-log-item gets a logs button that stopPropagation +
preventDefault on the wrapping conversation link.
- i18n: 8 new AUTOMATIONS$DETAIL$LOGS_* keys across all 15 languages.
- Tests: BashService cloud/local routing + ActivityLogItem button
behaviour.
Co-authored-by: openhands <openhands@all-hands.dev>
* feat(automations): split logs modal into Output/Error tabs
Address review feedback on the run logs modal:
* Rename the modal title from "Run logs" to "Logs".
* Make the modal actually fire the bash-events search request:
- In local mode, the agent-server hosts events under a single root,
so the search query no longer waits for the per-conversation URL
to resolve before firing. The conversation lookup still runs (so
session-api-key and per-conversation URL are honoured when
present), but it no longer gates the fetch.
- In cloud mode the behaviour is unchanged — runtime endpoints
require the conversation_url for the cloud-proxy hostOverride.
* Replace the interleaved stdout/stderr pre-block with two tabs:
Output (stdout, default) and Error (stderr). The body of each tab
is the chronological concatenation (by timestamp + order) of the
matching field across every BashOutput event for the command.
* Simplify BashService — drop the extra BashCommand fetch; only the
BashOutput search (`kind__eq=BashOutput, command_id__eq=<id>`) is
needed for the rendered view. Note that the agent-server API uses
`command_id__eq` (not `bash_command_id__eq`) — see the python
bash_router for the canonical filter name.
i18n: add LOGS_TAB_OUTPUT, LOGS_TAB_ERROR, LOGS_EMPTY across all 15
locales; retranslate LOGS_TITLE to "Logs".
Tests:
* New run-logs-modal.test.tsx covers tab defaults, stdout/stderr
concatenation (with reverse-order inputs to verify the sort),
loading state, and Escape-to-close.
* bash-service.test.ts rewritten for the new listOutputs API and a
cloud-without-conversation-url error path.
Co-authored-by: openhands <openhands@all-hands.dev>
* feat(automations): tolerate paused/missing/unreachable sandboxes in run logs modal
Cloud sandboxes can be in non-RUNNING states (paused, starting,
deleted, errored) and even RUNNING sandboxes can transiently fail at
the network layer. Previously the modal would either show a stuck
'Loading logs...' spinner or dump a raw axios error string. Now each
known-bad state is mapped to a stable `SandboxIssue` code with its
own localized empty-state message.
Behaviour:
* Pre-flight: when `sandbox_status` is MISSING, PAUSED, STARTING, or
ERROR — or the conversation has no runtime URL at all — the bash
query is **disabled** (no doomed request is fired). The modal
renders the matching message instead of a spinner.
* Post-flight: when the request does fire and fails with a 404 or
5xx response, or a network-level error (no response), the failure
is classified as `unreachable` and the modal renders the
'sandbox unreachable' message instead of the raw error. 401/403
are *not* collapsed — those are auth bugs we want to surface.
* Local backends are unchanged: no sandbox lifecycle, so
`sandboxIssue` is always null and errors flow through as-is.
API changes:
* `useBashCommandLogs` exposes a `sandboxIssue` discriminated union
("missing" | "paused" | "starting" | "errored" | "unreachable")
instead of the old `hasNoRuntime` boolean. When an issue is set
the hook clears `error` so the modal doesn't render both an empty
state AND a raw axios string for the same failure.
* The modal switches over the issue codes via a centralized
`SANDBOX_ISSUE_I18N` map.
i18n: replace the single `LOGS_SANDBOX_GONE` key with five specific
keys (one per sandbox issue) across all 15 locales.
Tests:
* New `__tests__/hooks/query/use-bash-command-logs.test.tsx`
exhaustively covers each sandbox_status, the no-runtime-URL case,
conversationMissing, the happy path, 404/5xx → unreachable
classification, the network-error case, and the explicit
401/403-passes-through case.
* `run-logs-modal.test.tsx` extended with a parameterized case
covering all five issue codes; verifies the empty-state message is
rendered and the tab body is suppressed.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
Docker's `--tmpfs` flag defaults its mount option set to
`rw,noexec,nosuid,nodev`. When dev:docker overlays the agent-server
container's `/home/openhands` with a tmpfs (so the mapped host user can
own it), it was inheriting that `noexec` flag unintentionally.
That breaks any stdio MCP server installed via npx -- e.g.
`npx -y @modelcontextprotocol/server-github` caches its binary under
`~/.npm/_npx/<hash>/node_modules/.bin/mcp-server-github`, npx then tries
to exec it, and the kernel returns EACCES regardless of the 0755 mode
bits on the file. The user sees:
sh: 1: mcp-server-github: Permission denied
Failed to connect to MCP server 'github', skipping
...
MCPError: MCP Connection Failure
and the conversation that triggered it aborts during agent init.
Pass `exec` explicitly to override only the `noexec` default. We keep
`nosuid` and `nodev` (the home dir has no business hosting setuid
binaries or device nodes) so we lose no defense-in-depth beyond what's
necessary to make the supported MCP integration actually function.
Co-authored-by: openhands <openhands@all-hands.dev>
* feat(dev): surface dev-stack runtime services in agent system prompt
Add a structured 'runtime services' info object that the dev launchers
(`dev:safe`, `dev:automation`, `dev:docker`, and the published
`agent-canvas` binary) propagate to the frontend via
`VITE_RUNTIME_SERVICES_INFO`. The frontend renders it into a
`<RUNTIME_SERVICES>` markdown block and attaches it as
`AgentContext.system_message_suffix` on every `POST /api/conversations`.
This means agents start each conversation knowing exactly what services
exist in the current dev stack (ingress URL, automation backend URL +
`/api/automation` prefix, auth header, etc.), instead of having to probe
or — worse — assume `localhost:8000` is the automation server when it is
actually the Agent Server they are running inside of.
URLs are written from the agent's point of view: dockerless modes use
`localhost`, `dev:docker` uses `host.docker.internal`. When automation
isn't running in the current mode (e.g. `dev:safe`), the block says so
explicitly so agents know to skip `/api/automation` calls.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(runtime-services): address review feedback on PR #503
- Validate required `agentServerPort` in `buildRuntimeServicesInfo`;
previously a missing port baked `http://localhost:undefined` into
the agent's system prompt.
- Skip the automation entry when the supplied `automation` object has
no `port` (e.g. a bare `{}` from a misconfigured launcher).
- Rename the JSON service key from `vite` to `frontend` and add a
`kind: "vite" | "static"` discriminator + mode-aware description,
so static-build dev stacks (`dev:docker`, the published binary, ...)
no longer surface a misleading "Vite dev server" line in the agent
system prompt. The renderer still accepts the legacy `vite` key.
- Anchor the "don't guess" warning to the actual agent-server URL from
runtime info instead of hardcoded `localhost:8000`, since the
agent-server uses different ports across dev modes (18000 in
dev:safe, 8000 in dev:docker, ...).
- Plumb `frontendKind` through `buildAutomationRuntimeServicesInfo`
and stamp `config.frontendKind` in `dev-with-automation.mjs::main`
so both Vite spawn and static-build paths describe the frontend
correctly.
- Expand AGENTS.md with the JSON schema of `VITE_RUNTIME_SERVICES_INFO`
and a concrete example of the rendered `<RUNTIME_SERVICES>` block.
- Tests: assert the new URL-in-warning behavior, the new `frontend` /
legacy `vite` rendering, the `agentServerPort`-required guard, the
`automation: {}` skip, and the legacy `vitePort` alias.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
Both the agent server's FastAPI docs and the automation backend's docs
are reachable through the local ingress proxy on port 8000, so point
users at them right next to the existing UI URL in both the Docker and
non-Docker quickstart sections.
Co-authored-by: openhands <openhands@all-hands.dev>
* feat(ingress): route /docs to the agent server
Add /docs to the list of prefixes proxied to the agent-server in:
- vite.config.ts (Vite dev server proxy)
- scripts/dev-with-automation.mjs (ingress + static-server fallback)
- scripts/dev-static.mjs (ingress + static-server fallback)
Update the explanatory comment in scripts/static-server.mjs to match.
This exposes the agent-server's FastAPI Swagger UI at `/docs` on the
ingress port, alongside the automation backend's existing
`/api/automation/docs`.
Co-authored-by: openhands <openhands@all-hands.dev>
* feat(ingress): also route /redoc and /openapi.json to the agent server
Without /openapi.json, the Swagger UI page served at /docs (added in the
previous commit) renders but fails to load any spec. /redoc is the
FastAPI-served ReDoc alternative and benefits from the same fix.
Routes are added everywhere /docs already is:
- vite.config.ts (Vite dev server proxy)
- scripts/dev-with-automation.mjs (ingress + static-server fallback)
- scripts/dev-static.mjs (ingress + static-server fallback)
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>