v2.7.2
Jun 4, 2026
See CHANGELOG.md for the full release notes.
opendray updateReadable release notes from GitHub, designed for humans.
Jun 4, 2026
See CHANGELOG.md for the full release notes.
opendray updateSee CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
Security + bug fix rollup on top of v2.7.0. No API, config, or schema changes — drop in.
internal/session (#296).
syscall.SIGTERM / syscall.SIGKILL are undefined on Windows.
New build-tagged signals_{unix,windows}.go helpers abstract the
difference — Unix preserves the prior SIGTERM → grace → SIGKILL
ladder; Windows falls through to proc.Kill() (TerminateProcess)
since the platform has no SIGTERM equivalent. Documented in code.go test -race ./... failing on Windows (#296). Test compat
across auth, backup, cliacct, catalog, mcp, session,
app packages: USERPROFILE setenv alongside HOME for
os.UserHomeDir, Unix-perm asserts skipped on Windows
(os.Chmod doesn't enforce them there), symlink tests skip when
os.Symlink lacks privilege, shell-script fake MCP server
replaced with TestMain-as-fake-server pattern, app_test.go
uses an existing file as fake parent dir for cross-platform
os.MkdirAll failure. Full suite now passes on Windows
(44 packages, 0 failures).prefix.replace(/\/$/, '/') stripped a trailing slash and
replaced it with the same slash — the author meant to ensure a
trailing slash. Rewrote as
endsWith('/') ? prefix : prefix + '/'.README.fa.md 10-way language switcher backfill (#293). The
Persian README still listed only English / 简体中文 / فارسی; now
matches the ten-way switcher the other nine READMEs got via #282.enable-cli-updates.sh discoverable from the failure path
(#297). The in-app guidance toast (returned when the npm global
prefix is read-only by the service user) and scripts/README.md
now name the helper script that resolves the situation, so
operators don't have to grep for it. Closes #262.Security + bug-fix rollup on top of v2.7.0. No API, config, or schema changes — drop in.
replace(/\.\.\/+/g, '') was bypassable by overlap (....//
collapses to ../ after one pass). Replaced with split-on-slash +
filter-out ../. + rejoin, which cannot be bypassed that way.backup local target (#296).
filepath.IsAbs("/foo") returns false on Windows, so the prior
absolute-path reject left a gap. LocalTarget.resolve() now also
rejects paths with a leading / or \, and rejects any colon on
Windows to catch drive-relative forms like C:foo / C:..\evil.console.log. Dropped the fingerprint entirely;
integration_id already identifies which credential is in use.internal/session (#296).
syscall.SIGTERM / syscall.SIGKILL are undefined on Windows.
New build-tagged signals_{unix,windows}.go helpers abstract the
difference — Unix preserves the prior SIGTERM → grace → SIGKILL
ladder; Windows falls through to proc.Kill() (TerminateProcess)
since the platform has no SIGTERM equivalent. Documented in code.go test -race ./... failing on Windows (#296). Test compat
across auth, backup, cliacct, catalog, mcp, session,
app packages: USERPROFILE setenv alongside HOME for
os.UserHomeDir, Unix-perm asserts skipped on Windows
(os.Chmod doesn't enforce them there), symlink tests skip when
os.Symlink lacks privilege, shell-script fake MCP server
replaced with TestMain-as-fake-server pattern, app_test.go
uses an existing file as fake parent dir for cross-platform
os.MkdirAll failure. Full suite now passes on Windows
(44 packages, 0 failures).prefix.replace(/\/$/, '/') stripped a trailing slash and
replaced it with the same slash — the author meant to ensure a
trailing slash. Rewrote as
endsWith('/') ? prefix : prefix + '/'.README.fa.md 10-way language switcher backfill (#293). The
Persian README still listed only English / 简体中文 / فارسی; now
matches the ten-way switcher the other nine READMEs got via #282.enable-cli-updates.sh discoverable from the failure path
(#297). The in-app guidance toast (returned when the npm global
prefix is read-only by the service user) and scripts/README.md
now name the helper script that resolves the situation, so
operators don't have to grep for it. Closes #262.The Flutter mobile app catches up to web. Features that landed on the web dashboard but never reached mobile are now at parity: Telegram two way channel config, Claude account metadata, a gateway version check, and most recently used session ordering.
owner_user_ids), two-way chat toggle
(chat_enabled), typing indicator (chat_typing), activity
notifications (notify_enabled), and reply length cap
(reply_max_chars). Booleans render as switches and serialize to
the same config shape the gateway expects.The Flutter mobile app catches up to web. Features that landed on the web dashboard but never reached mobile are now at parity: Telegram two-way channel config, Claude account metadata, a gateway version check, and most-recently-used session ordering.
owner_user_ids), two-way chat toggle
(chat_enabled), typing indicator (chat_typing), activity
notifications (notify_enabled), and reply length cap
(reply_max_chars). Booleans render as switches and serialize to
the same config shape the gateway expects.Web/mobile gain a real PR detail surface and a read only Issues surface, both backed by the existing git provider plumbing.
git.PullRequests
provider interface the list view already uses — no new API
surface for the host, just a deeper read against what the
provider returns.opendray as an npm package (#280). npm i -g opendray or npx opendray now works — the package wraps the
platform-appropriate binary from the GitHub release. Useful for
operators on Node-heavy fleets who'd rather not script a curl
install. The binary itself is unchanged; npm is just another
delivery channel alongside the existing tarballs.main via #283 after a cross-fork
rebase-conflict workaround. See Credits.main via #283 after a rebase-conflict workaround.[email protected] was not published in this release: the
Publish to npm job in release.yml
failed with NODE_AUTH_TOKEN not set. The NPM_TOKEN repository
secret needs to be configured (see #280 for the workflow). The npm
publish can be retried by running the workflow_dispatch on
Publish to npm once the secret is set, or it will publish
automatically on the next tag. Run-of-shame:
https://github.com/Opendray/opendray/actions/runs/26753088400Web/mobile gain a real PR detail surface and a read-only Issues
surface, both backed by the existing git provider plumbing. The
gateway binary is now also distributable via npm — npx opendray
runs without a Go toolchain on the box. Plus a small dropdown-
positioning fix and a handful of new README translations.
git.PullRequests
provider interface the list view already uses — no new API
surface for the host, just a deeper read against what the
provider returns.opendray as an npm package (#280). npm i -g opendray or npx opendray now works — the package wraps the
platform-appropriate binary from the GitHub release. Useful for
operators on Node-heavy fleets who'd rather not script a curl
install. The binary itself is unchanged; npm is just another
delivery channel alongside the existing tarballs.main via #283 after a cross-fork
rebase-conflict workaround. See Credits.main via #283 after a rebase-conflict workaround.[email protected] was not published in this release: the
Publish to npm job in release.yml
failed with NODE_AUTH_TOKEN not set. The NPM_TOKEN repository
secret needs to be configured (see #280 for the workflow). The npm
publish can be retried by running the workflow_dispatch on
Publish to npm once the secret is set, or it will publish
automatically on the next tag. Run-of-shame:
https://github.com/Opendray/opendray/actions/runs/26753088400Phase 2 Tier A of the multi Claude account work: rate limit aware auto failover. A Claude session that hits its account quota can now automatically switch itself to the next non throttled enabled account, with the conver
auto_failover_enabled, default false). pumpStdout scans each
Claude session's PTY for the You've hit your session limit · resets HH:MM (UTC) banner. On a match:ThrottleStore (lazy GC of expired entries).PickFailoverClaudeAccount picks the next enabled non-throttled
account by the same least-loaded heuristic auto-assign already
uses.SwitchClaudeAccount runs end-to-end — transcript JSONL
hard-linked, PTY respawned with --resume, conversation
continues on the new identity.session.auto_switched on
success, session.auto_failover_no_target when the fleet is
exhausted, session.auto_failover_failed when the switch
itself errors.
5s cooldown per session + 4 KiB rolling window so a persistent
banner can't drive the regex on every chunk. Opt-in by design —
defaults off so existing operators aren't surprised by silent
account switches.RELEASING.md runbook at the repo root documenting the release
ceremony end-to-end: the chain diagram, the "tag-after-changelog-
merges" gotcha, recovery procedures (empty release body, pulled-
back release), pre/post-release checklists, roadmap to
release-please automation and pre-release -rc.N channels.enabled<2 guard in
PickAutoAssignClaudeAccount. The SQL filter
(WHERE ca.enabled = true) and the explicit-pin validation path
were already covered by live integration + handler tests; this
closes the last gap.ClaudeAccountResolver interface methods:
MarkClaudeAccountThrottled, IsClaudeAccountThrottled,
PickFailoverClaudeAccount. pickLeastLoaded SQL gains variadic
exclude ...string (parameterized via NOT (ca.id = ANY($1::text[]))).[providers.claude] auto_failover_enabled (default false).
Opt-in for the runtime rate-limit-driven account switching.-, |) for the middle
bullet are already covered.~/.claude) are skipped
by the failover path for the MVP — mapping the default to a real
account row needs a resolver round-trip we haven't exposed yet.
After auto-assign kicks in for ≥2 enabled accounts, most new
sessions are pinned to a named account anyway, so this gap shrinks
to zero in practice.Phase 2 Tier A of the multi-Claude-account work: rate-limit-aware auto-failover. A Claude session that hits its account quota can now automatically switch itself to the next non-throttled enabled account, with the conversation continuing seamlessly on the new identity. Plus documentation polish around the release ceremony so the next operator cutting a release doesn't have to re-discover today's gotchas.
auto_failover_enabled, default false). pumpStdout scans each
Claude session's PTY for the You've hit your session limit · resets HH:MM (UTC) banner. On a match:ThrottleStore (lazy GC of expired entries).PickFailoverClaudeAccount picks the next enabled non-throttled
account by the same least-loaded heuristic auto-assign already
uses.SwitchClaudeAccount runs end-to-end — transcript JSONL
hard-linked, PTY respawned with --resume, conversation
continues on the new identity.session.auto_switched on
success, session.auto_failover_no_target when the fleet is
exhausted, session.auto_failover_failed when the switch
itself errors.
5s cooldown per session + 4 KiB rolling window so a persistent
banner can't drive the regex on every chunk. Opt-in by design —
defaults off so existing operators aren't surprised by silent
account switches.RELEASING.md runbook at the repo root documenting the release
ceremony end-to-end: the chain diagram, the "tag-after-changelog-
merges" gotcha, recovery procedures (empty release body, pulled-
back release), pre/post-release checklists, roadmap to
release-please automation and pre-release -rc.N channels.enabled<2 guard in
PickAutoAssignClaudeAccount. The SQL filter
(WHERE ca.enabled = true) and the explicit-pin validation path
were already covered by live integration + handler tests; this
closes the last gap.ClaudeAccountResolver interface methods:
MarkClaudeAccountThrottled, IsClaudeAccountThrottled,
PickFailoverClaudeAccount. pickLeastLoaded SQL gains variadic
exclude ...string (parameterized via NOT (ca.id = ANY($1::text[]))).[providers.claude] auto_failover_enabled (default false).
Opt-in for the runtime rate-limit-driven account switching.-, |) for the middle
bullet are already covered.~/.claude) are skipped
by the failover path for the MVP — mapping the default to a real
account row needs a resolver round-trip we haven't exposed yet.
After auto-assign kicks in for ≥2 enabled accounts, most new
sessions are pinned to a named account anyway, so this gap shrinks
to zero in practice.Multi Claude account UX, two way Telegram channel, and a clutch of session quality fixes.
~/.claude-accounts/<name>/
is now monitored with fsnotify; a new .credentials.json (the
result of CLAUDE_CONFIG_DIR=… claude login) registers an account
row automatically. 500ms debounce, backoff-on-error reattach loop,
symlink rejection at every level.default row. ~/.claude/.credentials.json
(the CLI's own home) now surfaces as a row named default so the
primary identity is visible in the panel without forcing the
named-account login flow.subscription_type, rate_limit_tier, active_sessions,
last_used_at, and oauth_email — all derived server-side from
<configDir>/.credentials.json + <configDir>/.claude.json + a
single JOIN against the sessions table. No new chrome.POST /sessions arrives with provider=claude and empty
claude_account_id (and ≥2 accounts are enabled), the gateway
picks the enabled account with the fewest non-terminal sessions
(alphabetical tiebreaker). Removes the "everything piles onto
default" bias. Explicit operator pin still wins.oauthAccount.emailAddress per account is recorded under
~/.opendray/cliacct-identity.json (chmod 0600). On every List/Get,
the current on-disk email is compared; mismatch surfaces
identity_drift=true and previous_email on the Account row,
rendered as a red "identity changed: was X · accept" chip.
POST /api/v1/claude-accounts/{id}/accept-identity updates the
baseline so the chip clears.PATCH /api/v1/sessions/{id}/claude-account now hard-links the
Claude transcript JSONL from <old_config_dir>/projects/<workspace>/ <session_id>.jsonl into <new_config_dir>/projects/<workspace>/
before respawning. Claude --resume then finds and replays the
conversation under the new account. Hard-link shares one inode so
switching back-and-forth keeps both views synchronized.scripts/enable-cli-updates.sh helper for
the non-root install path.claude_account_id validation is now enforced at session create
AND at switch — bogus or disabled ids return HTTP 400 BEFORE the
row is persisted (create) or BEFORE the live PTY is stopped (switch).token_filled previously only checked the legacy
<accountsDir>/tokens/<name>.token file, so every config-dir
account (the documented flow!) showed "NO TOKEN YET" despite having
working credentials. Now reports true when either source has usable
credentials.chats/*.jsonl instead of scraping
the screen, eliminating screen-dump noise in Telegram forwards.os.Lstat and reject
symlinks (<accountsDir>/<name>/, <configDir>/.credentials.json,
<configDir>/.claude.json, the legacy token file). Defense in
depth against an attacker who can write under the accounts tree.migrateClaudeTranscript Lstat-rejects symlinked sources before
os.Link so a planted symlink can't be hardlinked into the new
account's tree and read as conversation history by claude --resume.POST /api/v1/claude-accounts/{id}/accept-identity — clears
the identity-drift baseline by recording the current on-disk email
as the new accepted identity.[providers.claude] watcher_enabled (default true). Set to
false to disable the fsnotify watcher; the Import-local button
still works on demand.Multi-Claude-account UX, two-way Telegram channel, and a clutch of session-quality fixes. The big new capability: a single OpenDray gateway can now manage multiple Anthropic identities side-by-side and let an operator switch a live Claude session between them without losing the conversation.
~/.claude-accounts/<name>/
is now monitored with fsnotify; a new .credentials.json (the
result of CLAUDE_CONFIG_DIR=… claude login) registers an account
row automatically. 500ms debounce, backoff-on-error reattach loop,
symlink rejection at every level.default row. ~/.claude/.credentials.json
(the CLI's own home) now surfaces as a row named default so the
primary identity is visible in the panel without forcing the
named-account login flow.subscription_type, rate_limit_tier, active_sessions,
last_used_at, and oauth_email — all derived server-side from
<configDir>/.credentials.json + <configDir>/.claude.json + a
single JOIN against the sessions table. No new chrome.POST /sessions arrives with provider=claude and empty
claude_account_id (and ≥2 accounts are enabled), the gateway
picks the enabled account with the fewest non-terminal sessions
(alphabetical tiebreaker). Removes the "everything piles onto
default" bias. Explicit operator pin still wins.oauthAccount.emailAddress per account is recorded under
~/.opendray/cliacct-identity.json (chmod 0600). On every List/Get,
the current on-disk email is compared; mismatch surfaces
identity_drift=true and previous_email on the Account row,
rendered as a red "identity changed: was X · accept" chip.
POST /api/v1/claude-accounts/{id}/accept-identity updates the
baseline so the chip clears.PATCH /api/v1/sessions/{id}/claude-account now hard-links the
Claude transcript JSONL from <old_config_dir>/projects/<workspace>/ <session_id>.jsonl into <new_config_dir>/projects/<workspace>/
before respawning. Claude --resume then finds and replays the
conversation under the new account. Hard-link shares one inode so
switching back-and-forth keeps both views synchronized.scripts/enable-cli-updates.sh helper for
the non-root install path.claude_account_id validation is now enforced at session create
AND at switch — bogus or disabled ids return HTTP 400 BEFORE the
row is persisted (create) or BEFORE the live PTY is stopped (switch).token_filled previously only checked the legacy
<accountsDir>/tokens/<name>.token file, so every config-dir
account (the documented flow!) showed "NO TOKEN YET" despite having
working credentials. Now reports true when either source has usable
credentials.chats/*.jsonl instead of scraping
the screen, eliminating screen-dump noise in Telegram forwards.os.Lstat and reject
symlinks (<accountsDir>/<name>/, <configDir>/.credentials.json,
<configDir>/.claude.json, the legacy token file). Defense in
depth against an attacker who can write under the accounts tree.migrateClaudeTranscript Lstat-rejects symlinked sources before
os.Link so a planted symlink can't be hardlinked into the new
account's tree and read as conversation history by claude --resume.POST /api/v1/claude-accounts/{id}/accept-identity — clears
the identity-drift baseline by recording the current on-disk email
as the new accepted identity.[providers.claude] watcher_enabled (default true). Set to
false to disable the fsnotify watcher; the Import-local button
still works on demand.[v2.3.4] — 2026 05 29 Fixed Language toggle in the web Topbar moved its checkmark but UI strings didn't switch.
Language toggle in the web Topbar moved its checkmark but UI
strings didn't switch. The zustand → i18next bridge ran as a
module-level useLocale.subscribe(...) in i18n.ts that mounted
before React. Under React 19 StrictMode + Vite HMR + zustand persist
hydration the subscription could end up registered against a store
snapshot React never re-reconciled with, so picking a language moved
the dropdown's checkmark (which reads from the store) without
triggering i18n.changeLanguage(). Moved the bridge into a
<LocaleSync /> React effect under QueryClientProvider so it
shares the same lifecycle as every other useTranslation()
consumer and they update in lockstep (#267).
Nine UI strings rendered their placeholders literally —
"update available → {{version}}", "Suggested ({{count}})", "Updated
{{from}} → {{to}}", "connected · {{count}} tools", and the three
About-panel version-toaster lines all showed the {{var}} template
instead of the substituted value. The web i18next interpolation is
configured for single-brace {name} but those particular keys were
authored with the i18next default {{name}}. Normalized them across
both locales (#261).
Mobile flutter build apk failed with hundreds of parser errors
after slang codegen. Mobile's slang config uses
string_interpolation: braces (matching the web) but the same
{{var}} typos that produced literal placeholders on web produced
invalid Dart on mobile — ({required Object {version}) and
${{version}} — that wouldn't compile. Same normalization as #261,
plus a refresh of the generated strings*.g.dart outputs and
alignment of app/mobile/pubspec.yaml to the product version
(#264).
App icons now show the new wooden-cart wordmark glyph instead of
the old pink-gradient "D". README was already updated to the
opendray.dev wordmark, but the running surfaces — web favicon,
Android launcher mipmaps, the full iOS AppIcon.appiconset, and
the repo-root assets/icons/logo/ set — hadn't caught up, so a
fresh install showed the new brand on GitHub and the old brand on
the device. Regenerated every square icon surface from a single
1024×1024 source so proportions stay consistent across sizes
(#266).
The Providers page now asks for confirmation before upgrading a
CLI that has live sessions on it. Linux file-replacement
semantics mean an already-loaded session keeps the old binary in
memory, but a long session with lazy / dynamic imports or in-flight
subprocess work can pick up new code mid-run. When n > 0
non-terminal sessions are using the provider, clicking Update opens
a dialog with the count and an honest explanation of the trade-off;
with no live sessions Update still fires immediately, as before.
Update-check responses also stay fresh for an hour now (matching
the server-side npm cache) instead of being re-fetched on every tab
switch (#263).
Language toggle in the web Topbar moved its checkmark but UI
strings didn't switch. The zustand → i18next bridge ran as a
module-level useLocale.subscribe(...) in i18n.ts that mounted
before React. Under React 19 StrictMode + Vite HMR + zustand persist
hydration the subscription could end up registered against a store
snapshot React never re-reconciled with, so picking a language moved
the dropdown's checkmark (which reads from the store) without
triggering i18n.changeLanguage(). Moved the bridge into a
<LocaleSync /> React effect under QueryClientProvider so it
shares the same lifecycle as every other useTranslation()
consumer and they update in lockstep (#267).
Nine UI strings rendered their placeholders literally —
"update available → {{version}}", "Suggested ({{count}})", "Updated
{{from}} → {{to}}", "connected · {{count}} tools", and the three
About-panel version-toaster lines all showed the {{var}} template
instead of the substituted value. The web i18next interpolation is
configured for single-brace {name} but those particular keys were
authored with the i18next default {{name}}. Normalized them across
both locales (#261).
Mobile flutter build apk failed with hundreds of parser errors
after slang codegen. Mobile's slang config uses
string_interpolation: braces (matching the web) but the same
{{var}} typos that produced literal placeholders on web produced
invalid Dart on mobile — ({required Object {version}) and
${{version}} — that wouldn't compile. Same normalization as #261,
plus a refresh of the generated strings*.g.dart outputs and
alignment of app/mobile/pubspec.yaml to the product version
(#264).
App icons now show the new wooden-cart wordmark glyph instead of
the old pink-gradient "D". README was already updated to the
opendray.dev wordmark, but the running surfaces — web favicon,
Android launcher mipmaps, the full iOS AppIcon.appiconset, and
the repo-root assets/icons/logo/ set — hadn't caught up, so a
fresh install showed the new brand on GitHub and the old brand on
the device. Regenerated every square icon surface from a single
1024×1024 source so proportions stay consistent across sizes
(#266).
The Providers page now asks for confirmation before upgrading a
CLI that has live sessions on it. Linux file-replacement
semantics mean an already-loaded session keeps the old binary in
memory, but a long session with lazy / dynamic imports or in-flight
subprocess work can pick up new code mid-run. When n > 0
non-terminal sessions are using the provider, clicking Update opens
a dialog with the count and an honest explanation of the trade-off;
with no live sessions Update still fires immediately, as before.
Update-check responses also stay fresh for an hour now (matching
the server-side npm cache) instead of being re-fetched on every tab
switch (#263).
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
See CHANGELOG.md for the full release notes.
Operator polish, reconnect stability, memory improvements, and clearer docs.