Changelog#
All notable changes to OpenDray v2 are documented in this file.
The format is based on Keep a Changelog. Version numbers follow this project's own major-as-generation strategy — major version = product generation, minor = feature iteration, patch = fix / polish. See VERSIONING.md for the full rationale and what triggers a major bump.
[Unreleased]#
[v2.7.2] — 2026-06-04#
Added#
opendray doctor+opendray setup-macos(macOS).setup-macosgives the binary a stable, per-machine self-signed code-signing identity (in a dedicated keychain, fully non-interactive) and re-signs it, so a one-time Full Disk Access grant survives rebuilds/updates instead of macOS re-prompting on every version change.doctoris a read-only health check that flags an ad-hoc signature or a config living in a TCC-protected folder.opendray servewith no-confignow falls back to~/.opendray/config.toml— outside the protected folders — so a fresh install's gateway starts without a privacy prompt.- macOS release binaries are now Developer ID-signed + notarized
(when the signing secrets are configured), so a user's Full Disk
Access grant persists across
opendray update. Signing runs via quill on the Linux release runner and is a no-op when unconfigured. - Telegram
/peekcommand + control-keyboard button to re-send the selected session's latest output on demand; the docked control keyboard now refreshes on/selectand/start. - Mobile: switch a running Claude session's account + agent-CLI update awareness. Rebind a live session to a different account from the session screen (web parity), and see when a provider CLI has an npm update available. The session Tasks tab also reached web parity.
- Spanish (es) translation across web + mobile with in-app language switching, plus a CI translation-parity guard that fails the build on missing/extra keys.
Changed#
- Telegram notifications consolidated on
enabled+muted+ the repeat policy. The redundantnotify_enabledswitch and the per-topicnotify_onpicker were removed — an enabled, unmuted channel notifies once per round, and the web channel card gained the mute toggle that mobile already had.
Fixed#
- Switching a Claude session's account no longer leaves it stopped and
unrestartable. The switch now starts a fresh conversation under the
new account (a session UUID that account's CLI actually knows) instead
of
--resume-ing a UUID minted under the previous account, which failed with "No conversation found" and exited the process. - Web terminal jitter caused by the page's scrollbar — the terminal pane
is now isolated from
<main>. - Mobile: numeric Telegram
chat_idis submitted as a number, not a string, so a Telegram channel configured from the phone starts. - Release pipeline: release notes are written outside the work tree so goreleaser's dirty-tree check passes.
Removed#
ghcr.io/Opendray/opendraycontainer image (all 370 versions / 84 tags). The image was orphaned — the most recent tag wasv2.1.0(five releases behind), no workflow in.github/was building it any more, no docs / installer scripts / discussions referenced it. More importantly, a pullable container contradicted the host- resident-only deploy policy (Discussion #300, README "Choose how to run it" table): opendray runs AI CLIs through PTYs and shares filesystem state (~/.claude, ssh-agent, project files) with them, which container isolation breaks. Operators who landed on the GHCR page following stale links from external blog posts would have pulled v2.1.0 and hit exactly that failure mode. Supported install paths remain the one-line installer, the from-source quickstart, andnpm install -g opendray.
[v2.7.1] — 2026-06-01#
Security + bug-fix rollup on top of v2.7.0. No API, config, or schema changes — drop in.
Security#
- Path-traversal sanitiser bypass in NotesPanel (#294).
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. - Windows path-traversal gap in
backuplocal target (#296).filepath.IsAbs("/foo")returnsfalseon 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 likeC:foo/C:..\evil. - Demo-client API key in log lines (#294). The integration-key
fingerprint embedded in two log sites was emitting bytes from the
secret through
console.log. Dropped the fingerprint entirely;integration_idalready identifies which credential is in use.
Fixed#
- Windows build failure in
internal/session(#296).syscall.SIGTERM/syscall.SIGKILLare undefined on Windows. New build-taggedsignals_{unix,windows}.gohelpers abstract the difference — Unix preserves the priorSIGTERM → grace → SIGKILLladder; Windows falls through toproc.Kill()(TerminateProcess) since the platform has no SIGTERM equivalent. Documented in code. go test -race ./...failing on Windows (#296). Test compat acrossauth,backup,cliacct,catalog,mcp,session,apppackages:USERPROFILEsetenv alongsideHOMEforos.UserHomeDir, Unix-perm asserts skipped on Windows (os.Chmoddoesn't enforce them there), symlink tests skip whenos.Symlinklacks privilege, shell-script fake MCP server replaced withTestMain-as-fake-server pattern,app_test.gouses an existing file as fake parent dir for cross-platformos.MkdirAllfailure. Full suite now passes on Windows (44 packages, 0 failures).- Identity-replacement no-op in NotesPanel (#294).
prefix.replace(/\/$/, '/')stripped a trailing slash and replaced it with the same slash — the author meant to ensure a trailing slash. Rewrote asendsWith('/') ? prefix : prefix + '/'.
Docs#
README.fa.md10-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.shdiscoverable from the failure path (#297). The in-app guidance toast (returned when the npm global prefix is read-only by the service user) andscripts/README.mdnow name the helper script that resolves the situation, so operators don't have to grep for it. Closes #262.
[v2.7.0] — 2026-06-01#
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.
Added#
- Telegram two-way channel config on mobile (#290). The mobile
channel form gained the five Telegram fields the web form already
had: owner allow-list (
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. - Claude account metadata on mobile (#290). The provider page now surfaces the gateway-decorated account fields — subscription tier, rate-limit tier, active session count, last-used time, and OAuth email — as inline chips, plus an identity-drift banner with an Accept action when the on-disk OAuth identity changed.
- Gateway version check on mobile (#290). The About screen shows the running gateway's version, commit, and whether an update is available, with a release-notes link. Read-only — the in-app self-update stays on web / the host shell.
- Most-recently-used session ordering on mobile (#290). The sessions list now sorts most-recently-opened first (recorded per-device, persisted across restarts), mirroring the web list; live sessions still group ahead of ended ones.
[v2.6.0] — 2026-06-01#
Web/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.
Added#
- PR detail surface on web + mobile (#279). Click into a pull
request from the PR list to get description, status (open /
merged / closed), CI check summary, head/base branches, author,
and last-updated timestamp. Backed by the same
git.PullRequestsprovider interface the list view already uses — no new API surface for the host, just a deeper read against what the provider returns. - Read-only Issues surface on web + mobile (#281). List and detail views for repo issues, mirroring the PR layout: title, state, labels, assignee, body, comments thread. Read-only by design — issue creation/edit stays out of scope until the permission model around it is settled.
- Distribute
opendrayas an npm package (#280).npm i -g opendrayornpx opendraynow 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.
Fixed#
- Dropdown menus clamped to the viewport (#284). The account switcher and the session-action menu could overflow off the right edge of narrow viewports (sub-400px mobile, or a side-by-side desktop layout). Both now flip / clamp so the trailing edge stays inside the visible area.
Docs#
- Seven additional README translations (#282): Spanish, Brazilian Portuguese, Japanese, Korean, French, German, Russian. The README switcher row at the top of every translation now lists ten languages.
- Farsi (Persian) README translation (#283), originally
contributed by Majid Allahverdi
in #278; brought to
mainvia #283 after a cross-fork rebase-conflict workaround. See Credits.
Credits#
- Farsi (Persian) README translation by Majid Allahverdi — originally contributed in #278, brought to
mainvia #283 after a rebase-conflict workaround.
[v2.5.0] — 2026-05-31#
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.
Added#
- Rate-limit auto-failover for Claude sessions ([providers.claude]
auto_failover_enabled, default false).pumpStdoutscans each Claude session's PTY for theYou've hit your session limit · resets HH:MM (UTC)banner. On a match:- The current account is marked throttled-until-reset in an
in-memory
ThrottleStore(lazy GC of expired entries). PickFailoverClaudeAccountpicks the next enabled non-throttled account by the same least-loaded heuristic auto-assign already uses.SwitchClaudeAccountruns end-to-end — transcript JSONL hard-linked, PTY respawned with--resume, conversation continues on the new identity.- Bus events for observability:
session.auto_switchedon success,session.auto_failover_no_targetwhen the fleet is exhausted,session.auto_failover_failedwhen 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.
- The current account is marked throttled-until-reset in an
in-memory
RELEASING.mdrunbook 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.Nchannels.
Tests#
- Pinned contract: disabled accounts are excluded from auto-assign.
A regression-safety unit test for the
enabled<2guard inPickAutoAssignClaudeAccount. 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.
Internal#
- New
ClaudeAccountResolverinterface methods:MarkClaudeAccountThrottled,IsClaudeAccountThrottled,PickFailoverClaudeAccount.pickLeastLoadedSQL gains variadicexclude ...string(parameterized viaNOT (ca.id = ANY($1::text[]))).
Config#
- New:
[providers.claude] auto_failover_enabled(default false). Opt-in for the runtime rate-limit-driven account switching.
Honest limitations of Tier A#
- Banner-text fragile — if Claude rephrases the limit message, the
regex needs updating. Fallback separators (
-,|) for the middle bullet are already covered. - No predictive load spread — only reacts to hard limits. Tiers B (active probing) and C (local HTTPS proxy) from the design discussion remain available as upgrades.
- Sessions running on the empty-id default (
~/.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.
[v2.4.0] — 2026-05-31#
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.
Added#
- Claude accounts: filesystem watcher.
~/.claude-accounts/<name>/is now monitored with fsnotify; a new.credentials.json(the result ofCLAUDE_CONFIG_DIR=… claude login) registers an account row automatically. 500ms debounce, backoff-on-error reattach loop, symlink rejection at every level. - Claude accounts: synthetic
defaultrow.~/.claude/.credentials.json(the CLI's own home) now surfaces as a row nameddefaultso the primary identity is visible in the panel without forcing the named-account login flow. - Claude accounts: capacity chips. Each row now shows
subscription_type,rate_limit_tier,active_sessions,last_used_at, andoauth_email— all derived server-side from<configDir>/.credentials.json+<configDir>/.claude.json+ a single JOIN against the sessions table. No new chrome. - Claude accounts: least-loaded auto-assign at session create.
When
POST /sessionsarrives with provider=claude and emptyclaude_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. - Claude accounts: identity drift detection. First-seen
oauthAccount.emailAddressper account is recorded under~/.opendray/cliacct-identity.json(chmod 0600). On every List/Get, the current on-disk email is compared; mismatch surfacesidentity_drift=trueandprevious_emailon the Account row, rendered as a red "identity changed: was X · accept" chip.POST /api/v1/claude-accounts/{id}/accept-identityupdates the baseline so the chip clears. - Session switch preserves conversation.
PATCH /api/v1/sessions/{id}/claude-accountnow hard-links the Claude transcript JSONL from<old_config_dir>/projects/<workspace>/ <session_id>.jsonlinto<new_config_dir>/projects/<workspace>/before respawning. Claude--resumethen finds and replays the conversation under the new account. Hard-link shares one inode so switching back-and-forth keeps both views synchronized. - Telegram: two-way conversational chat. Typing indicator, turn replies, persistent control keyboard acting on the current session, configurable from the dashboard.
- Catalog: warn + confirm before CLI upgrade. The in-app CLI
upgrade button now warns when sessions are using the CLI it's about
to replace, with a new
scripts/enable-cli-updates.shhelper for the non-root install path. - Web: MRU session ordering + Cmd/Ctrl+K palette search.
Changed#
claude_account_idvalidation 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).- Default idle threshold raised 30s → 5m so long-running tool invocations don't get killed by the idle reaper.
- The "Switch Account" confirmation dialog now says "conversation history is preserved" instead of "in-progress conversation state will be lost" — accurate description of what now happens.
Fixed#
token_filledpreviously only checked the legacy<accountsDir>/tokens/<name>.tokenfile, 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.- Gemini reply parsing now reads
chats/*.jsonlinstead of scraping the screen, eliminating screen-dump noise in Telegram forwards. - Session 'shell' provider's chrome stripper is now shell-aware so raw prompt characters don't leak into the channel forwarders.
- Web: copy now works over plain-HTTP LAN (Clipboard API requires HTTPS otherwise), terminal selection-driven copy works, copy pill is anchored at the selection with neutral styling.
Security#
- All disk reads in the cliacct path use
os.Lstatand 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. migrateClaudeTranscriptLstat-rejects symlinked sources beforeos.Linkso a planted symlink can't be hardlinked into the new account's tree and read as conversation history byclaude --resume.- Telegram inbound is gated to the configured owner across all message types, not just control commands.
API#
- New:
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.
Config#
- New:
[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. The zustand → i18next bridge ran as a module-level
useLocale.subscribe(...)ini18n.tsthat 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 triggeringi18n.changeLanguage(). Moved the bridge into a<LocaleSync />React effect underQueryClientProviderso it shares the same lifecycle as every otheruseTranslation()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 apkfailed with hundreds of parser errors after slang codegen. Mobile's slang config usesstring_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 generatedstrings*.g.dartoutputs and alignment ofapp/mobile/pubspec.yamlto the product version (#264).
Changed#
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-rootassets/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 > 0non-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).
[v2.3.3] — 2026-05-24#
Fixed#
- About panel showed no version and the self-update button did
nothing. The dashboard called the version / self-update API at
/versionand/version/updateinstead of/api/v1/..., so the requests 404'd. Added the/api/v1prefix (#251).
[v2.3.2] — 2026-05-24#
Fixed#
- Cross-session memory injection rendered every fact as
- ---. The "Recent project memory" banner took the first line of each memory, which for frontmatter-authored facts is the---YAML delimiter. It now skips the frontmatter and surfaces thedescription(falling back to the first body line) (#250).
[v2.3.1] — 2026-05-24#
Fixed#
- Copy buttons silently failed over plain HTTP (LAN IP / mobile).
navigator.clipboardis only exposed in a secure context. Added a sharedcopyText()helper that falls back toexecCommand('copy')and routed the existing copy callsites through it (#249).
[v2.3.0] — 2026-05-23#
Fixed#
- Live sessions were destroyed by a daemon restart (e.g. a
self-update). Sessions are now marked
interruptedon a gateway shutdown and auto-resumed on the next startup via their stored agent session id (--resume), with bounded-concurrency spawning and an optionalOPENDRAY_AUTO_RESUME_MAXcap. A drain gate warns before a self-update interrupts running work (#247). - 404 page instead of the login screen after a restart. The 401
redirect now respects the dashboard base path (→
/admin/login) and keepsnextrouter-relative (#248). - Brand icons broke under a non-
/adminbase path (#246).
[v2.2.2] — 2026-05-23#
Added#
- Memory: global-scope injection fallback + recency default — a fact told to one session surfaces in another regardless of cwd (#244).
- Transport-aware MCP editor template + "unsupported" badge for Codex (#242).
Fixed#
- Memory endpoints are now scope-gated (admin or
memory:read/memory:write) (#245).
[v2.2.1] — 2026-05-22#
Added#
- Always-visible "Check for updates" + re-install action in the About panel (#243).
Fixed#
- Remote MCP URL normalization (#230).
[v2.2.0] — 2026-05-22#
Added#
- In-dashboard update notification + one-click background self-update (#241).
- Startup warning when W^X (MemoryDenyWriteExecute) blocks executable memory (#240).
Changed#
- Repository renamed
opendray_v2→opendrayacross code/config/docs; install / uninstall URLs updated (#238, #237).
Fixed#
- Dropped
MemoryDenyWriteExecutefrom the systemd unit — it broke Codex / Gemini sessions (#218).
[v2.1.1] — 2026-05-22#
Added#
- Responsive mobile web layout — slide-over nav + inspector with edge handles (#236).
Fixed#
- Telegram channel: handle
/start, and a clearer/listheader for terminated sessions (#235).
[v2.1.0] — 2026-05-22#
Added#
- Per-provider model management from the dashboard (#229).
- Real CLI version + "update available" surfaced in the providers API/UI (#227).
- Interactive session switching via
/select+ Talk-to buttons in channels (#226). - Validate MCP servers from the Plugins page (#233).
- Windows installer: a true one-liner — auto-installs WSL2 + Ubuntu, runs the installer, and persists across reboot (#213).
Changed#
- Hardened the merged Update action — provider mutations are gated and the update path degrades gracefully (#234).
Fixed#
- Session list shows session names in
/listinstead of bare ids (#224). - Spawned CLIs get a color-capable
TERMso Claude/Codex/Gemini render in color (#225). - macOS installer hardening — robust local Postgres provisioning, configured-port binding, idempotent launchd reload, bash 3.2 compatibility, and a launchd PATH that finds brew-installed CLIs (#208, #209, #211, #212, #231, #232).
- Windows installer: OS-build guard, auto-resume after a WSL reboot, PowerShell 5.1 compatibility (#214).
- Installer: validate DB identifiers and don't abort on a free / commented-out Postgres port (#210).
Security#
- Scrubbed dev-internal docs + personal-network references from the public repository (#204).
[v2.0.5] — 2026-05-18#
Added#
- Flutter mobile session terminal now has the URL detector
badge. Same model as the web admin: the PTY byte stream is
scanned for http(s) URLs with the same state-machine extractor
that re-assembles CLI-soft-wrapped OAuth URLs. A floating pill
in the top-right corner of the terminal — primary tap opens the
most recent URL in the OS browser via
url_launcher, secondary⋯button opens a bottom-sheet with every URL (newest first) for picking older ones. Closes the OAuth-on-Flutter-app gap reported alongside the web fix.
Changed#
- Web login no longer pre-fills the username with "admin". The install wizard lets operators pick any username, so seeding the field forced everyone-who-didn't-keep-the-default to backspace before typing. The field is now empty by default and autofocused.
[v2.0.4] — 2026-05-18#
Fixed#
URL extractor now re-assembles CLI-soft-wrapped URLs. AI CLIs (claude-code, codex, gemini) hard-wrap long OAuth URLs at the terminal column width by emitting literal
\ncharacters every ~55 chars. The v2.0.1 / v2.0.2 / v2.0.3 extractor used a[^\s]+regex that stops at\n, so it captured only the first wrapped segment (e.g.https://...&client_). Tapping the badge opened a truncated URL, the OAuth provider rejected it, and the operator couldn't authenticate.The extractor is now a state-machine walker that anchors on
https?://, consumes URL-body characters, and treats a single internal\nas a soft-wrap when the current line is ≥ 40 chars long (matches real CLI wrap width; well above "\n " prose patterns). Paragraph breaks ( \n\n), single newlines followed by non-URL characters, and short prose lines still terminate the URL correctly.Verified against the actual 450-char claude-code OAuth URL that was failing in production: extractor now produces ONE complete URL (vs. two truncated segments).
[v2.0.3] — 2026-05-18#
Fixed#
Terminal URL badge always opens with one tap, regardless of how many URLs the session has accumulated. v2.0.2 made the
N = 1case one-tap, but real sessions usually have ≥ 2 URLs by the time the auth flow runs (the CLI's welcome banner often prints a docs link before the OAuth URL), and that fell back to the two-tap dialog flow. The badge now ALWAYS opens the most recent URL on a single tap — which is the OAuth URL in 100% of theclaude login/gemini auth login/codex logincases.Multi-URL access stays available via a small
⋯button beside the primary anchor — tapping it opens the same list dialog as before, so operators can still grab an older URL when they need it. The dialog row Open buttons are also real anchors (notwindow.open()) for the same popup-blocker reason.This is a web-admin-only fix. The Flutter mobile app's terminal surface doesn't have URL detection yet — separate follow-up.
[v2.0.2] — 2026-05-18#
Added#
- Service-control subcommands:
opendray start,opendray stop,opendray restart,opendray status. Thin wrappers oversystemctl(Linux) andlaunchctl(macOS) so operators don't have to remember the platform-native incantation. On Linux, the binary auto-prependssudoif the caller isn't root. On macOS, defaults to the user LaunchAgent (gui/$UID/com.opendray.opendray); pass--systemto target the LaunchDaemon scope.
Fixed#
- One-tap link open for the OAuth URL badge. When a session has
exactly one detected URL (the common AI-CLI auth case:
claude login/gemini auth login/codex logineach print one OAuth URL), the floating "🔗 1 link" badge is now itself an<a target="_blank">— a single tap goes straight to the browser, no intermediate dialog. The dialog still appears when ≥ 2 URLs are detected, so multi-link sessions still get the disambiguating UI. In the dialog, the "Open" button is also a real anchor now, which avoids popup-blocker gating on some mobile browsers.
[v2.0.1] — 2026-05-18#
Removed#
- Docker deployment path. opendray is a host-resident gateway —
it spawns AI CLIs via PTYs and shares process state (
~/.claude, ssh-agent, project files) with them, which is incompatible with the container isolation a production Docker deploy would impose. RemovedDockerfile,docker-compose.yml,docker-compose.test.yml,.dockerignore,.env.example, the GHCR push job from the release workflow, and the Docker-Compose sections from README / docs. - In-app Tutorial page. All 84 markdown sections plus
Tutorial.tsxremoved; docs now live in a dedicated repo that will publish independently. Sidebar entry,/tutorialroute, and i18n keys (nav.tutorial,web.providers.claudeAccounts.tutorialTooltip,web.providers.claudeAccounts.architectureLink) removed in parallel.
Fixed#
- "No Claude accounts" empty state (Providers page + Spawn dialog,
web + mobile) now tells operators the actual setup path: spawn a
session and run
claude loginin the terminal. The previous wording pointed at the gateway-host shell workflow (works only for SSH- capable operators) and incorrectly implied a systemANTHROPIC_API_KEYfallback. The shell workflow remains available in the Providers page text for power-users juggling multiple identities; it's just no longer the headline instruction.
Changed#
- Brand: web favicon, docs hero, iOS
AppIcon.appiconset(15 sizes), Android mipmap (5 densities), andapp/mobile/assets/brand/launcher source refreshed from a new canonical set inassets/icons/logo/. Now tracked in-repo so a future refresh is onecp+ the existingsipsresize loop.
Added — install / uninstall / update tooling#
Lifecycle scripts and binary subcommands that grew out of a fresh-
LXC end-to-end install test. Everything below is curl | bash–
reachable, idempotent, and works on Linux (Ubuntu / Debian) +
macOS; Windows is funneled through WSL2.
- One-line installer wizard (#185 #186)
scripts/install.sh— dual-mode entry: dispatches to the OS installer in a local checkout, or shallow-clones the repo and re-execs when piped fromcurl.scripts/install-linux.sh— apt + systemd; walks the operator through Postgres (existing or freshpostgresql-16+pgvectorinstall), AI-CLI choice, admin credentials, listen address, release-tarball binary install, schema migration, and a hardened systemd unit. Optional--from-sourcebuilds the binary + web bundle from a checkout instead.scripts/install-macos.sh— brew + LaunchAgent (or--launchd-daemonfor system-wide), same flow. Detects Apple Silicon vs Intel for the right release asset.scripts/install-windows.ps1— PowerShell helper for WSL2: detects existing WSL, otherwise prints the install command + reboot guidance, then hands off to the Linux installer inside Ubuntu.
- One-line uninstaller (#191)
- Default mode stops + removes the gateway runtime but keeps
config.toml, data directory (bcrypt keyfile, sessions, notes, vault), logs, and the PostgreSQL database — so a re-install picks up where you left off. --purge(orOPENDRAY_PURGE=1) drops the DB + role, deletes config / data / logs, removes the service user.- Post-purge verification step: walks the standard install
paths and bails loudly with
ls -laoutput if anything survived. "No trace left" gets checked, not assumed.
- Default mode stops + removes the gateway runtime but keeps
opendray updatesubcommand (#194)- Fetches the latest GitHub release, picks the goreleaser
asset matching this host's
GOOS/GOARCH, verifies SHA-256 against the release'sSHA256SUMS, then atomically replaces/proc/self/exevia temp+rename. - Flags:
--check(probe only),--force(re-install same version),--yes(skip confirm),--restart(systemctl restart opendrayafter replace, Linux only). - Fails fast with a "try with sudo" hint when it can't write the install directory — no silent no-op.
- Fetches the latest GitHub release, picks the goreleaser
asset matching this host's
opendray providers <list|update>(#194)- Detects installed AI CLIs (
claude,gemini,codex), prints versions + paths. updatere-runsnpm install -gper CLI;--checkshells out tonpm view <pkg> versionto compare current vs npm-latest.--only claude,geminirestricts to a subset;--jsononlistfor scripted consumers.
- Detects installed AI CLIs (
Security#
- Secrets out of
config.toml(#192). The wizard now writes the database URL + admin bootstrap password to a separate file:- Linux:
/etc/opendray/opendray.env(mode0640 root:opendray), consumed by systemd viaEnvironmentFile=. - macOS:
~/.opendray/opendray.env(mode0600), consumed by a tiny launcher wrapper (~/.opendray/bin/opendray-launcher.sh) that the LaunchAgent'sProgramArgumentsinvokes — launchd has noEnvironmentFileequivalent. config.tomlis now0644and contains only non-secrets (listen, log config,[admin].user, runtime data dir).- Existing opendray env-var override layer
(
OPENDRAY_DATABASE_URL,OPENDRAY_ADMIN_PASSWORD, etc.) does the actual wiring — no Go changes needed.
- Linux:
Fixed (install wizard, all reported during the LXC walkthrough)#
curl | bashprompts work — wizard re-attaches stdin to/dev/ttyso EOF on the pipe doesn't make everyreadfail underset -e(#187).run_priv -E …/run_priv -u …no longer trip "command not found" when running as root — newrun_priv_env/run_priv_ashelpers handle both root + non-root paths (#188).- pnpm moved to the
--from-sourcebranch only; default-path Node install no longer hangs on corepack's silent download (#189). - AI CLI install shows npm's progress bar instead of `--silent
/dev/null` (so a 90-second download doesn't look like a hang) (#189).
- Admin login works after install: wizard writes
[admin].userin addition to the password; matches opendray's auth contract (#190). - Customisable admin username (was hard-coded to "admin") (#190).
- Final-summary URL resolves the host's LAN IP for
0.0.0.0listens instead of printing the<this-host>placeholder (#190). - Colour codes render in the summary block — colour vars use ANSI-C quoting so heredoc interpolation carries real ESC bytes (#190).
uninstall --purgedeletions are unconditional now; survived the previous flag-gated logic that occasionally leftconfig.tomlon disk (#192).- Env-var alternative for the purge flag (
OPENDRAY_PURGE=1 bash) — survivesbash -s -- --flagpaste-newline weirdness (#193).
Documentation#
- README hero: typographic v2 logo + status / license / CI /
GHCR badges + "What is opendray?" five-bullet section + paired
EN / ZH
README.md/README.zh.md(#180 #181 #182). - One-liner install / uninstall snippets at the top of
## Installon both READMEs (#186 #192 #193). docs/getting-started.md(+.zh.md) — 15-minute end-to-end walkthrough that mirrors what the wizard does (#183).docs/operator-guide.mdstrengthened on Docker-deploy scope — decision-question framing makes the "no session spawn" limit unmissable (#184).scripts/README.mddocuments the wizard, file layout (now including the secrets / config split), troubleshooting table, and the env-var alternatives for the purge / yes flags.
Branding#
- Unified launcher icons across web favicon, iOS
AppIcon.appiconset(15 sizes), and Android mipmap densities (5) using the cropped typographic v2 logo (#182).
[v2.0.0] — 2026-05-17#
Versioning realignment#
- Re-tagged from the previous
v1.0.0tag (issue #165). The major version now reflects this codebase's identity as the second generation of the opendray product (opendray_v2). The previousv1.0.0tag was deleted (had three duplicate draft releases on GitHub, all deleted; no published release; no downstream installers depend on it). - New VERSIONING.md documents the major-as-generation policy and what triggers future bumps.
Added#
- Per-session bypass toggle in the Spawn dialog (mobile + web).
Provider-aware: Claude →
--dangerously-skip-permissions, Codex →--ask-for-approval never, Gemini →--yolo. Off by default; the previous all-or-nothing provider config setting still works for "always bypass" deployments.
Changed#
- Spawn dialog's Claude account picker now appears immediately on open (mobile + web). Previously it waited for the operator to re-tap the provider dropdown because the parent state's provider id stayed unset.
- When 2+ Claude accounts are registered, the
Default (env / system)option disappears from the Claude account picker; the first enabled account auto-selects. Single-account setups retain the Default option.
Fixed#
- Release workflow's
ghcrjob now produces image tags onworkflow_dispatch.docker/metadata-actionwas readinggithub.ref(a branch when dispatched manually), sotype=semverrules emitted zero tags and buildx failed with "tag is needed when pushing to registry". Each rule now passesvalue=${{ env.TAG }}so the same ruleset works for bothpush:tagsandworkflow_dispatchentry points.
Added#
Release workflow gains a
ghcrjob that builds the multi-arch Dockerfile (linux/amd64 + linux/arm64) and pushes toghcr.io/opendray/opendrayon every tag release. Job-scopedpackages: write(the parentreleasejob stays at contents+id-token least-privilege). Tag set covers:1.0.0,:1.0,:v1.0.0, plus:latestfor non-prerelease semver. SHA-pinned actions throughout, matching the existing release- pipeline pattern..github/workflows/release.yml— automated release pipeline. Triggers onv*tag push (or manually via workflow_dispatch with a tag input). Produces a goreleaser draft release with:- cross-compiled archives (linux/darwin × amd64/arm64) +
SHA256SUMS - cosign keyless OIDC signatures (
SHA256SUMS.sig,SHA256SUMS.pem) via Sigstore Fulcio — no long-lived key - SPDX SBOM via anchore/sbom-action
Permissions limited to
contents: write(release upload) andid-token: write(cosign OIDC). Supply-chain hardening: SHA-pinned cosign-installer, sbom-action, and goreleaser-action; fail-fast tag-format validation on workflow_dispatch.
- cross-compiled archives (linux/darwin × amd64/arm64) +
deploy/directory with reference deploy artefacts:deploy/systemd/opendray.service— production-ready systemd unit with sandboxing (NoNewPrivileges,ProtectSystem=strict, etc.),migrate-then-servestartup, 20s graceful-stop window.deploy/lxc/proxmox-pty-notes.md— Proxmox-specific guide covering privileged vs unprivileged container PTY behaviour, the cgroup + bind-mount config required for unprivileged LXCs, networking + pgvector + pg_dump-version checks, and a pre-go-live checklist.deploy/README.md— index pointing operators at the right artefact for their topology.- operator-guide.md "Where to look next" section now links to
deploy/.
ADR 0016 (Proposed): backup-format v2 design for per-install PBKDF2 salt. Captures the four binding decisions (in-header storage, version-byte bump 1→2, per-Seal salt provenance, indefinite v1 read compat) and the three-PR rollout. Implementation pending.
LICENSE file (Apache 2.0) — previously declared in README only.
SECURITY.md — threat model, default posture, deployment checklist, report channel.
CONTRIBUTING.md — dev setup, test commands, PR + commit conventions.
CHANGELOG.md — this file.
Changed#
internal/backup/cipher.go: 6-line comment onkdfSaltflagging it as a frozen v1 protocol constant and pointing at ADR 0016. No code behaviour change.- Renumbered ADR
0011-memory-subsystem.md→0014-memory-subsystem.mdto resolve the duplicate-0011 collision with0011-channel-rich-content-and-bridge.md. Updated cross-references in README, ADR 0013, and the embed-onnx stub.
[v1.0.0 — retracted] — 2026-05-09#
Note. This tag was retracted on 2026-05-17 and the work it covered is folded into v2.0.0 above. See issue #165 and VERSIONING.md for the rationale. Original section preserved verbatim below for historical context.
First stable release. Tagged at commit fe96fd8 on main. Web frontend
- backend feature-complete; mobile + Slack inbound + automated release
workflow deferred to v1.x per the post-v1.0 roadmap. v1
(
Opendray/opendray) keeps running in production through this quarter per ADR 0001.
The feature inventory below was originally captured under
[v1.0-rc] — 2026-05-05; section was promoted to [v1.0.0] on tag.
Added (since the greenfield start)#
- M0 — composition root:
internal/app/, config loader (internal/config/), pgx pool + hand-rolled migration runner (internal/store/), event bus (internal/eventbus/), structured logging via slog. - M1 — sessions: PTY lifecycle, ring-buffer streaming, WS handler, resume-via-reconnect (per ADR 0003).
- M2 — CLI catalog: provider manifests + per-id user config
(
internal/catalog/). - M2.5 — admin auth: bearer tokens with constant-time password compare
and 24h TTL (
internal/auth/). - M3 — integrations: external-app registry,
/api/v1/proxy/{prefix}/*reverse proxy, integration call log (internal/integration/, ADR 0006, ADR 0010). - M4 — channels: channel hub + Telegram, Slack, Discord, DingTalk,
Feishu, WeChat, WeCom (
internal/channel/, ADR 0005, ADR 0011-channel). - Memory: built-in pgvector cross-CLI memory layer
(
internal/memory/, ADR 0014). Three-CLI mirror keeps Claude / Codex / Gemini transcripts aligned. ONNX local-embedding optional via-tags local_onnx. - Ambient memory: auto-capture from active sessions + auto-injection on session start (ADR 0013).
- Backup + export: AES-256-GCM encrypted PostgreSQL dumps,
S3/WebDAV/SFTP/rclone targets, admin export/import bundles
(
internal/backup/, ADR 0012). - Web admin (W0–W5): React 19 + Vite + Tailwind v4 + shadcn/ui +
TanStack Router/Query + Zustand + xterm.js. Single SPA bundled into
the Go binary via
go:embed(ADR 0007, ADR 0008). - Events stream: admin-bearer-authed
/api/v1/integrations/_eventsWebSocket (ADR 0009).
Deferred to post-v1.0#
- Mobile (Flutter) client — replaced by responsive web in v2 phase 2.
- Slack inbound (M5+).
- Deploy automation (release toolchain — goreleaser, Dockerfile, systemd unit) lands in a follow-up PR.
- e2e Playwright harness.