diff --git a/CHANGELOG.md b/CHANGELOG.md index 997ae7fd..604b24b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Desktop/Remote instances:** adding an SSH connection now starts from the hosts in your SSH config instead of a blank command field. Ports, install method and passwords moved behind Advanced settings, and each connection shows Connected, Connecting, or Needs attention with the failure text and a button that resolves it. +- Desktop/Remote instances: connecting to a remote machine now works when bun, OpenChamber or the opencode CLI live in your home directory rather than on the system path. Installing no longer fails with a permission error, and a missing opencode CLI is now reported before the connection starts instead of as a stack trace. +- Desktop/Remote instances: a managed remote server can now also be published to the remote machine's own network, so other devices there reach it without the SSH tunnel. It requires a UI password, and stays private to the tunnel otherwise. +- Desktop/Remote instances: disconnecting from a connection set to not keep the server running now actually stops that remote server. - **Chat sessions:** start chats without choosing a project. They live in their own Chats section, rather than inheriting a project's repository and worktree context. - **Skills catalog:** browse curated GitHub skill collections in a card-based catalog with cross-source search, skill counts, stars, recent updates, and links back to each skill's repository. - **Diff:** the context-panel diff can now show every change on the current branch against its base branch. OpenChamber detects the base when Git knows it, or lets you choose one once when it does not. diff --git a/packages/electron/ssh-manager.mjs b/packages/electron/ssh-manager.mjs index e0da931f..390c2a38 100644 --- a/packages/electron/ssh-manager.mjs +++ b/packages/electron/ssh-manager.mjs @@ -10,6 +10,25 @@ import { replaceFileWithRetry } from './windows-file-replace.mjs'; const LOCAL_HOST_ID = 'local'; const DEFAULT_CONNECTION_TIMEOUT_SEC = 60; const DEFAULT_LOCAL_BIND_HOST = '127.0.0.1'; +// Global npm prefixes are root-owned on most distributions, so `npm install -g` +// fails with EACCES for a normal SSH user. Everything we install goes to a +// prefix inside the user's home instead. +const REMOTE_USER_PREFIX = '$HOME/.openchamber/npm-global'; +const REMOTE_BUN_CANDIDATE = '"${BUN_INSTALL:-$HOME/.bun}/bin/bun"'; +// The opencode CLI usually installs into the user's home, which an SSH login +// shell does not have on PATH. The remote server only looks at OPENCODE_BINARY +// and PATH, so resolve the CLI here and hand it over explicitly. +const REMOTE_OPENCODE_CANDIDATES = [ + '"$HOME/.opencode/bin/opencode"', + '"${BUN_INSTALL:-$HOME/.bun}/bin/opencode"', + '"$HOME/.local/bin/opencode"', + '"$HOME/.openchamber/npm-global/bin/opencode"', +]; +const REMOTE_PATH_PREFIX = '$HOME/.opencode/bin:${BUN_INSTALL:-$HOME/.bun}/bin:$HOME/.local/bin:$HOME/.openchamber/npm-global/bin'; +const REMOTE_BIN_CANDIDATES = [ + '"$HOME/.openchamber/npm-global/bin/openchamber"', + '"${BUN_INSTALL:-$HOME/.bun}/bin/openchamber"', +]; const DEFAULT_CONTROL_PERSIST_SEC = 300; const DEFAULT_READY_TIMEOUT_SEC = 30; const DEFAULT_RECONNECT_MAX_ATTEMPTS = 5; @@ -585,6 +604,7 @@ export class ElectronSshManager { localUrl: null, localPort: null, remotePort: null, + remoteBinPath: null, startedByUs: false, retryAttempt: 0, requiresUserAction: false, @@ -811,9 +831,10 @@ export class ElectronSshManager { mode: instance?.remoteOpenchamber?.mode === 'external' ? 'external' : 'managed', keepRunning: instance?.remoteOpenchamber?.keepRunning !== false, ...(Number.isFinite(instance?.remoteOpenchamber?.preferredPort) ? { preferredPort: Number(instance.remoteOpenchamber.preferredPort) } : {}), - installMethod: ['npm', 'bun', 'download_release', 'upload_bundle'].includes(instance?.remoteOpenchamber?.installMethod) + installMethod: ['auto', 'npm', 'bun'].includes(instance?.remoteOpenchamber?.installMethod) ? instance.remoteOpenchamber.installMethod - : 'bun', + : 'auto', + bindHost: instance?.remoteOpenchamber?.bindHost === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1', uploadBundleOverSsh: Boolean(instance?.remoteOpenchamber?.uploadBundleOverSsh), }, localForward: { @@ -988,38 +1009,77 @@ export class ElectronSshManager { return secret?.enabled && typeof secret.value === 'string' && secret.value.trim() ? secret.value.trim() : null; } - async remoteCommandExists(parsed, controlPath, commandName) { - try { - const output = await this.runRemoteCommand(parsed, controlPath, `command -v ${commandName} >/dev/null 2>&1 && echo yes || echo no`); - return output.trim() === 'yes'; - } catch { - return false; - } - } + // A login shell over SSH does not source the user's interactive rc files, so + // tools installed into a home directory (bun above all) are missing from PATH + // even when they exist. Look at their known install locations too. + async resolveRemoteTool(parsed, controlPath, commandName, extraCandidates = []) { + const candidateList = [...extraCandidates, `"$(command -v ${commandName} 2>/dev/null)"`].join(' '); + const script = [ + `for candidate in ${candidateList}; do`, + ' [ -n "$candidate" ] || continue;', + ' [ -x "$candidate" ] || continue;', + ` printf '%s' "$candidate";`, + ' exit 0;', + 'done', + ].join(' '); - async currentRemoteOpenChamberVersion(parsed, controlPath) { try { - const output = await this.runRemoteCommand(parsed, controlPath, 'openchamber --version 2>/dev/null || true'); - return parseVersionToken(output); + const output = await this.runRemoteCommand(parsed, controlPath, script); + return output.trim() || null; } catch { return null; } } - async installOpenChamberManaged(parsed, controlPath, version, preferred) { - const hasBun = await this.remoteCommandExists(parsed, controlPath, 'bun'); - const hasNpm = await this.remoteCommandExists(parsed, controlPath, 'npm'); - const commands = []; + // Every place OpenChamber may live on the remote host, with the version each + // one reports. Installs land in the user prefix while an older copy can still + // sit on PATH, so the caller picks by version instead of trusting PATH order. + async remoteOpenChamberCandidates(parsed, controlPath) { + const script = [ + `for candidate in ${REMOTE_BIN_CANDIDATES.join(' ')} "$(command -v openchamber 2>/dev/null)"; do`, + ' [ -n "$candidate" ] || continue;', + ' [ -x "$candidate" ] || continue;', + ` printf '%s\t%s\n' "$candidate" "$("$candidate" --version 2>/dev/null | head -n 1)";`, + 'done', + ].join(' '); - if (preferred === 'bun') { - if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`); - if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`); - } else if (preferred === 'npm') { - if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`); - if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`); + let output = ''; + try { + output = await this.runRemoteCommand(parsed, controlPath, script); + } catch { + return []; + } + + const candidates = []; + const seen = new Set(); + for (const line of output.split(/\r?\n/)) { + const [binPath, versionRaw] = line.split('\t'); + const trimmed = (binPath || '').trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + candidates.push({ binPath: trimmed, version: parseVersionToken(versionRaw || '') }); + } + return candidates; + } + + async installOpenChamberManaged(parsed, controlPath, version, preferred) { + const bunPath = await this.resolveRemoteTool(parsed, controlPath, 'bun', [REMOTE_BUN_CANDIDATE]); + const npmPath = await this.resolveRemoteTool(parsed, controlPath, 'npm'); + + // bun's global install already targets ~/.bun; npm is pinned to a prefix in + // the user's home so it never touches the root-owned global directory. + const bunCommand = bunPath ? `${shellQuote(bunPath)} add -g @openchamber/web@${version}` : null; + const npmCommand = npmPath + ? `mkdir -p "${REMOTE_USER_PREFIX}" && ${shellQuote(npmPath)} install -g --prefix "${REMOTE_USER_PREFIX}" @openchamber/web@${version}` + : null; + + const commands = []; + if (preferred === 'npm') { + if (npmCommand) commands.push(npmCommand); + if (bunCommand) commands.push(bunCommand); } else { - if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`); - if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`); + if (bunCommand) commands.push(bunCommand); + if (npmCommand) commands.push(npmCommand); } if (commands.length === 0) { @@ -1079,24 +1139,35 @@ export class ElectronSshManager { } } - async startRemoteServerManaged(parsed, controlPath, instance, desiredPort) { - let envPrefix = 'OPENCHAMBER_RUNTIME=ssh-remote'; + async startRemoteServerManaged(parsed, controlPath, instance, desiredPort, binPath) { + const opencodePath = await this.resolveRemoteTool(parsed, controlPath, 'opencode', REMOTE_OPENCODE_CANDIDATES); + if (!opencodePath) { + throw new Error('The opencode CLI is not installed on the remote machine. Install it there, then connect again'); + } + const secret = this.configuredOpenChamberPassword(instance); + const remoteBindHost = instance.remoteOpenchamber?.bindHost === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1'; + // Binding the remote server to every interface publishes its UI to the + // remote machine's whole network, so it may not run without a password. + if (remoteBindHost === '0.0.0.0' && !secret) { + throw new Error('Exposing the remote server to its network requires a UI password'); + } + + let envPrefix = `PATH="${REMOTE_PATH_PREFIX}:$PATH" OPENCODE_BINARY=${shellQuote(opencodePath)} OPENCHAMBER_RUNTIME=ssh-remote`; if (secret) { envPrefix += ` OPENCHAMBER_UI_PASSWORD=${shellQuote(secret)}`; } - const output = await this.runRemoteCommand(parsed, controlPath, `${envPrefix} openchamber serve --hostname 127.0.0.1 --port ${desiredPort}`); + const output = await this.runRemoteCommand(parsed, controlPath, `${envPrefix} ${shellQuote(binPath)} serve --hostname ${remoteBindHost} --port ${desiredPort}`); const port = output.split(/\s+/).map((token) => Number.parseInt(token, 10)).find((value) => Number.isFinite(value)); return port || desiredPort; } - async stopRemoteServerBestEffort(parsed, controlPath, remotePort) { + // `openchamber stop` owns the daemon lifecycle. The HTTP shutdown route sits + // behind UI authentication, so it cannot stop a password-protected server. + async stopRemoteServerBestEffort(parsed, controlPath, remotePort, remoteBinPath) { + if (!remoteBinPath) return; try { - await this.runRemoteCommand( - parsed, - controlPath, - `if command -v curl >/dev/null 2>&1; then curl -fsS -X POST http://127.0.0.1:${remotePort}/api/system/shutdown >/dev/null 2>&1 || true; elif command -v wget >/dev/null 2>&1; then wget -qO- --method=POST http://127.0.0.1:${remotePort}/api/system/shutdown >/dev/null 2>&1 || true; fi`, - ); + await this.runRemoteCommand(parsed, controlPath, `${shellQuote(remoteBinPath)} stop --port ${remotePort}`); } catch { } } @@ -1150,17 +1221,27 @@ export class ElectronSshManager { const port = instance.remoteOpenchamber.preferredPort; this.setStatus(instance.id, 'server_detecting', 'Probing external OpenChamber server', null, null, port, false, 0, false); await this.probeRemoteSystemInfo(parsed, controlPath, port, this.configuredOpenChamberPassword(instance)); - return { remotePort: port, startedByUs: false }; + return { remotePort: port, startedByUs: false, remoteBinPath: null }; } this.setStatus(instance.id, 'remote_probe', 'Checking remote OpenChamber installation'); - const installedVersion = await this.currentRemoteOpenChamberVersion(parsed, controlPath); - if (!installedVersion) { - this.setStatus(instance.id, 'installing', 'Installing OpenChamber on remote host'); - await this.installOpenChamberManaged(parsed, controlPath, this.appVersion, instance.remoteOpenchamber.installMethod); - } else if (installedVersion !== this.appVersion) { - this.setStatus(instance.id, 'updating', `Updating remote OpenChamber from ${installedVersion} to ${this.appVersion}`); + const installed = await this.remoteOpenChamberCandidates(parsed, controlPath); + let binary = installed.find((candidate) => candidate.version === this.appVersion) || null; + + if (!binary) { + const existing = installed[0] || null; + if (existing) { + this.setStatus(instance.id, 'updating', `Updating remote OpenChamber from ${existing.version || 'unknown'} to ${this.appVersion}`); + } else { + this.setStatus(instance.id, 'installing', 'Installing OpenChamber on remote host'); + } await this.installOpenChamberManaged(parsed, controlPath, this.appVersion, instance.remoteOpenchamber.installMethod); + + const afterInstall = await this.remoteOpenChamberCandidates(parsed, controlPath); + binary = afterInstall.find((candidate) => candidate.version === this.appVersion) || afterInstall[0] || existing; + if (!binary) { + throw new Error('OpenChamber was installed on the remote host but no openchamber binary could be found'); + } } this.setStatus(instance.id, 'server_detecting', 'Detecting managed OpenChamber server'); @@ -1172,13 +1253,13 @@ export class ElectronSshManager { if (!remotePort) { this.setStatus(instance.id, 'server_starting', 'Starting managed OpenChamber server'); const desiredPort = instance.remoteOpenchamber.preferredPort || randomPortCandidate(instance.id); - remotePort = await this.startRemoteServerManaged(parsed, controlPath, instance, desiredPort); + remotePort = await this.startRemoteServerManaged(parsed, controlPath, instance, desiredPort, binary.binPath); startedByUs = true; } if (!(await this.remoteServerRunning(parsed, controlPath, remotePort, this.configuredOpenChamberPassword(instance)))) { throw new Error('Managed OpenChamber server failed to become reachable'); } - return { remotePort, startedByUs }; + return { remotePort, startedByUs, remoteBinPath: binary.binPath }; } async disconnectInternal(id, reportIdle) { @@ -1193,7 +1274,7 @@ export class ElectronSshManager { if (session) { if (session.startedByUs && session.remotePort && session.instance.remoteOpenchamber.mode === 'managed' && !session.instance.remoteOpenchamber.keepRunning) { - await this.stopRemoteServerBestEffort(session.parsed, session.controlPath, session.remotePort); + await this.stopRemoteServerBestEffort(session.parsed, session.controlPath, session.remotePort, session.remoteBinPath); } await this.stopControlMasterBestEffort(session.parsed, session.controlPath); const auth = this.sshAuth.get(session.parsed); @@ -1269,9 +1350,10 @@ export class ElectronSshManager { throw new Error(`Unsupported remote OS: ${remoteOs}`); } - const { remotePort, startedByUs } = await this.ensureRemoteServer(instance, parsed, controlPath); + const { remotePort, startedByUs, remoteBinPath } = await this.ensureRemoteServer(instance, parsed, controlPath); session.remotePort = remotePort; session.startedByUs = startedByUs; + session.remoteBinPath = remoteBinPath; this.setStatus(id, 'forwarding', 'Setting up port forwards', null, null, remotePort, startedByUs, 0, false); const bindHost = sanitizeBindHost(instance.localForward?.bindHost); diff --git a/packages/electron/ssh-manager.test.mjs b/packages/electron/ssh-manager.test.mjs index eb5d1af7..fdbf84ea 100644 --- a/packages/electron/ssh-manager.test.mjs +++ b/packages/electron/ssh-manager.test.mjs @@ -289,4 +289,161 @@ describe('ElectronSshManager', () => { }); expect(settings.desktopHosts).toEqual([{ id: 'ssh-1', label: 'SSH Host', url: localUrl, apiUrl: localUrl, clientToken: 'ssh-client-token' }]); }); + test('installs OpenChamber into a home-owned npm prefix instead of the root-owned global one', async () => { + const commands = []; + const manager = new ElectronSshManager({ + settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'), + appVersion: '1.2.3', + emit: () => undefined, + }); + manager.resolveRemoteTool = async (_parsed, _controlPath, name) => (name === 'npm' ? '/usr/bin/npm' : null); + manager.runRemoteCommand = async (_parsed, _controlPath, script) => { + commands.push(script); + return ''; + }; + + await manager.installOpenChamberManaged({ destination: 'user@example.test', args: [] }, '/tmp/control.sock', '1.2.3', 'auto'); + + expect(commands).toHaveLength(1); + expect(commands[0]).toContain('--prefix "$HOME/.openchamber/npm-global"'); + expect(commands[0]).not.toMatch(/npm install -g @openchamber/); + }); + + test('lists every remote OpenChamber binary with its reported version', async () => { + const manager = new ElectronSshManager({ + settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'), + appVersion: '1.2.3', + emit: () => undefined, + }); + manager.runRemoteCommand = async () => [ + '/home/pi/.openchamber/npm-global/bin/openchamber\t1.2.3', + '/usr/bin/openchamber\t0.9.0', + '', + ].join('\n'); + + const candidates = await manager.remoteOpenChamberCandidates({ destination: 'user@example.test', args: [] }, '/tmp/control.sock'); + + expect(candidates).toEqual([ + { binPath: '/home/pi/.openchamber/npm-global/bin/openchamber', version: '1.2.3' }, + { binPath: '/usr/bin/openchamber', version: '0.9.0' }, + ]); + }); + + test('starts the resolved OpenChamber binary rather than whatever PATH exposes', async () => { + let started = ''; + const manager = new ElectronSshManager({ + settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'), + appVersion: '1.2.3', + emit: () => undefined, + }); + manager.resolveRemoteTool = async () => '/home/pi/.opencode/bin/opencode'; + manager.runRemoteCommand = async (_parsed, _controlPath, script) => { + started = script; + return '4321\n'; + }; + + const instance = { id: 'ssh-1', auth: {}, remoteOpenchamber: { mode: 'managed' } }; + const port = await manager.startRemoteServerManaged( + { destination: 'user@example.test', args: [] }, + '/tmp/control.sock', + instance, + 4321, + '/home/pi/.openchamber/npm-global/bin/openchamber', + ); + + expect(port).toBe(4321); + expect(started).toContain("'/home/pi/.openchamber/npm-global/bin/openchamber' serve"); + expect(started).toContain("OPENCODE_BINARY='/home/pi/.opencode/bin/opencode'"); + expect(started).toContain('$HOME/.opencode/bin:'); + }); + + test('refuses to start when the remote machine has no opencode CLI', async () => { + const manager = new ElectronSshManager({ + settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'), + appVersion: '1.2.3', + emit: () => undefined, + }); + manager.resolveRemoteTool = async () => null; + manager.runRemoteCommand = async () => { + throw new Error('should not start the server without a CLI'); + }; + + await expect(manager.startRemoteServerManaged( + { destination: 'user@example.test', args: [] }, + '/tmp/control.sock', + { id: 'ssh-1', auth: {}, remoteOpenchamber: { mode: 'managed' } }, + 4321, + '/home/pi/.bun/bin/openchamber', + )).rejects.toThrow(/opencode CLI is not installed/); + }); + test('prefers a bun that only exists in the home directory over npm', async () => { + const commands = []; + const manager = new ElectronSshManager({ + settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'), + appVersion: '1.2.3', + emit: () => undefined, + }); + // A login shell over SSH does not put ~/.bun/bin on PATH. + manager.resolveRemoteTool = async (_parsed, _controlPath, name) => + (name === 'bun' ? '/home/pi/.bun/bin/bun' : '/usr/bin/npm'); + manager.runRemoteCommand = async (_parsed, _controlPath, script) => { + commands.push(script); + return ''; + }; + + await manager.installOpenChamberManaged({ destination: 'user@example.test', args: [] }, '/tmp/control.sock', '1.2.3', 'auto'); + + expect(commands).toEqual(["'/home/pi/.bun/bin/bun' add -g @openchamber/web@1.2.3"]); + }); + test('stops a remote server it started through the CLI, not the authenticated HTTP route', async () => { + const scripts = []; + const manager = new ElectronSshManager({ + settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'), + appVersion: '1.2.3', + emit: () => undefined, + }); + manager.runRemoteCommand = async (_parsed, _controlPath, script) => { + scripts.push(script); + return ''; + }; + + await manager.stopRemoteServerBestEffort( + { destination: 'user@example.test', args: [] }, + '/tmp/control.sock', + 41777, + '/home/pi/.bun/bin/openchamber', + ); + + expect(scripts).toEqual(["'/home/pi/.bun/bin/openchamber' stop --port 41777"]); + }); + test('publishes the remote server to its network only with a UI password', async () => { + const manager = new ElectronSshManager({ + settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'), + appVersion: '1.2.3', + emit: () => undefined, + }); + manager.resolveRemoteTool = async () => '/home/pi/.opencode/bin/opencode'; + let started = ''; + manager.runRemoteCommand = async (_parsed, _controlPath, script) => { + started = script; + return '4321\n'; + }; + + const parsed = { destination: 'user@example.test', args: [] }; + const exposed = { + id: 'ssh-1', + auth: {}, + remoteOpenchamber: { mode: 'managed', bindHost: '0.0.0.0' }, + }; + + await expect(manager.startRemoteServerManaged(parsed, '/tmp/control.sock', exposed, 4321, '/bin/openchamber')) + .rejects.toThrow(/requires a UI password/); + + const secured = { + ...exposed, + auth: { openchamberPassword: { enabled: true, value: 'remote-secret', store: 'settings' } }, + }; + await manager.startRemoteServerManaged(parsed, '/tmp/control.sock', secured, 4321, '/bin/openchamber'); + expect(started).toContain('--hostname 0.0.0.0'); + }); }); diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx index b921e586..558bb2ca 100644 --- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx @@ -23,7 +23,9 @@ import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLay import { SettingsSection, SettingsGroupTitle, + SettingsChipGroup, SETTINGS_PAGE_TITLE_CLASS, + SETTINGS_SECTION_TITLE_CLASS, SETTINGS_FIELD_LABEL_CLASS, SETTINGS_SELECT_SIZE, } from '@/components/sections/shared/SettingsSection'; @@ -150,6 +152,58 @@ const isConnectingPhase = (phase?: string): boolean => { return Boolean(phase && CONNECTING_PHASES.has(phase)); }; +// The backend reports 13 lifecycle phases. People only need to know which of +// three situations they are in; the phase stays as the secondary detail line. +type InstanceState = 'idle' | 'connecting' | 'ready' | 'error'; + +const instanceState = (phase?: string): InstanceState => { + if (phase === 'ready') return 'ready'; + if (phase === 'error') return 'error'; + if (phase === 'degraded' || isConnectingPhase(phase)) return 'connecting'; + return 'idle'; +}; + +const instanceStateLabelKey = (state: InstanceState): I18nKey => { + switch (state) { + case 'ready': + return 'settings.remoteInstances.page.state.ready'; + case 'connecting': + return 'settings.remoteInstances.page.state.connecting'; + case 'error': + return 'settings.remoteInstances.page.state.problem'; + default: + return 'settings.remoteInstances.page.state.notConnected'; + } +}; + +// Known backend failures that the user can act on from here. Everything else +// falls back to the raw detail plus the logs button. +type ErrorRemedy = 'uiPassword' | 'localPort' | 'noRuntime' | 'noOpencode' | 'externalPort' | null; + +const errorRemedy = (detail?: string): ErrorRemedy => { + const text = (detail || '').toLowerCase(); + if (!text) return null; + if (text.includes('ui authentication') || text.includes('ui password')) return 'uiPassword'; + if (text.includes('already in use') || text.includes('eaddrinuse')) return 'localPort'; + if (text.includes('neither bun nor npm')) return 'noRuntime'; + if (text.includes('opencode cli is not installed')) return 'noOpencode'; + if (text.includes('requires a ui password')) return 'uiPassword'; + if (text.includes('preferred remote openchamber port')) return 'externalPort'; + return null; +}; + +// Remedies the user resolves on the remote machine: explain, do not offer a button. +const REMEDY_HINT_KEYS = { + noRuntime: 'settings.remoteInstances.page.error.hint.noRuntime', + noOpencode: 'settings.remoteInstances.page.error.hint.noOpencode', +} satisfies Record; + +const remedyHintKey = (remedy: ErrorRemedy): I18nKey | null => { + if (remedy === 'noRuntime') return REMEDY_HINT_KEYS.noRuntime; + if (remedy === 'noOpencode') return REMEDY_HINT_KEYS.noOpencode; + return null; +}; + const phaseDotClass = (phase?: string): string => { if (phase === 'ready') { return 'bg-[var(--status-success)] animate-pulse'; @@ -462,8 +516,11 @@ export const RemoteInstancesPage: React.FC = () => { const [transportOptions, setTransportOptions] = React.useState<{ localUrl: string | null; lanUrl: string | null; relayAvailable: boolean } | null>(null); const revokedClientCount = React.useMemo(() => remoteClients.filter((client) => Boolean(client.revokedAt)).length, [remoteClients]); const [sshAddDialogOpen, setSshAddDialogOpen] = React.useState(false); - const [sshCommandDraft, setSshCommandDraft] = React.useState('ssh user@example.com'); + const [sshAddMode, setSshAddMode] = React.useState<'saved' | 'manual'>('saved'); + const [sshHostSearch, setSshHostSearch] = React.useState(''); + const [sshCommandDraft, setSshCommandDraft] = React.useState(''); const [sshNameDraft, setSshNameDraft] = React.useState(''); + const [advancedOpen, setAdvancedOpen] = React.useState(false); React.useEffect(() => { void load(); @@ -730,7 +787,7 @@ export const RemoteInstancesPage: React.FC = () => { await createFromCommand(id, command, sshNameDraft.trim() || t('settings.remoteInstances.sidebar.newSshInstanceName')); setSelectedId(id); setSshAddDialogOpen(false); - setSshCommandDraft('ssh user@example.com'); + setSshCommandDraft(''); setSshNameDraft(''); toast.success(t('settings.remoteInstances.page.toast.instanceCreated')); } catch (error) { @@ -740,6 +797,12 @@ export const RemoteInstancesPage: React.FC = () => { } }, [createFromCommand, setSelectedId, sshCommandDraft, sshNameDraft, t]); + const openSshAddDialog = React.useCallback(() => { + setSshHostSearch(''); + setSshAddMode(importCandidates.length > 0 ? 'saved' : 'manual'); + setSshAddDialogOpen(true); + }, [importCandidates.length]); + const setDefaultDirectHost = React.useCallback(async (id: string) => { await persistDirectHosts(directHosts, id); }, [directHosts, persistDirectHosts]); @@ -1000,6 +1063,11 @@ export const RemoteInstancesPage: React.FC = () => { setDraft(selectedInstance); }, [selectedInstance]); + // Every instance opens on the simple view; advanced stays a deliberate choice. + React.useEffect(() => { + setAdvancedOpen(false); + }, [selectedId]); + React.useEffect(() => { if (!selectedId) { return; @@ -1064,6 +1132,9 @@ export const RemoteInstancesPage: React.FC = () => { const canDisconnect = isReady || isBusy; const statusAgeMs = status ? Math.max(0, clockMs - status.updatedAtMs) : 0; const reconnectAppearsStuck = isReconnecting && statusAgeMs > 12_000; + const currentState = instanceState(statusPhase); + const currentRemedy = currentState === 'error' ? errorRemedy(status?.detail) : null; + const currentRemedyHintKey = remedyHintKey(currentRemedy); const hasChanges = React.useMemo(() => { if (!draft || !selectedInstance) return false; @@ -1083,6 +1154,25 @@ export const RemoteInstancesPage: React.FC = () => { return; } + // "Already running" cannot pick a port on its own; catching it here keeps + // the failure in the form instead of surfacing it mid-connect. + if (normalized.remoteOpenchamber.mode === 'external' && !normalized.remoteOpenchamber.preferredPort) { + toast.error(t('settings.remoteInstances.page.validation.externalPortRequired')); + setAdvancedOpen(true); + return; + } + + if ( + normalized.remoteOpenchamber.mode === 'managed' && + normalized.remoteOpenchamber.bindHost === '0.0.0.0' && + !normalized.auth.openchamberPassword?.value?.trim() + ) { + toast.error(t('settings.remoteInstances.page.validation.remoteLanNeedsPassword')); + setAdvancedOpen(true); + window.setTimeout(() => uiPasswordRef.current?.focus(), 0); + return; + } + if (normalized.localForward.bindHost === '0.0.0.0') { const allow = window.confirm( t('settings.remoteInstances.page.confirm.bindAllInterfaces'), @@ -1154,6 +1244,7 @@ export const RemoteInstancesPage: React.FC = () => { const handleImportCandidate = React.useCallback( (host: string, pattern: boolean) => { + setSshAddDialogOpen(false); if (pattern) { setPatternHost(host); setPatternDestination(suggestConcreteHost(host)); @@ -1164,6 +1255,25 @@ export const RemoteInstancesPage: React.FC = () => { [createImportedInstance], ); + const filteredImportCandidates = React.useMemo(() => { + const query = sshHostSearch.trim().toLowerCase(); + if (!query) return importCandidates; + return importCandidates.filter((candidate) => { + return candidate.host.toLowerCase().includes(query) || candidate.sshCommand.toLowerCase().includes(query); + }); + }, [importCandidates, sshHostSearch]); + + // Opening a ready instance means pointing this window at the forwarded local + // URL — the same navigation the host switcher performs after its own connect. + const openInstanceUrl = React.useCallback((localUrl?: string) => { + const target = (localUrl || '').trim(); + if (!target) { + toast.error(t('settings.remoteInstances.page.toast.instanceUrlUnavailable')); + return; + } + navigateToUrl(target); + }, [t]); + const handlePatternCreate = React.useCallback(async () => { const host = patternHost; const destination = patternDestination.trim(); @@ -1216,6 +1326,44 @@ export const RemoteInstancesPage: React.FC = () => { } }, [connect, selectedInstance, t, upsertInstance]); + const uiPasswordRef = React.useRef(null); + const remotePortRef = React.useRef(null); + + // Turn a reported failure into the one action that resolves it, instead of + // leaving the raw backend sentence as the whole answer. + const applyErrorRemedy = React.useCallback(async (remedy: ErrorRemedy) => { + if (!selectedInstance) return; + + if (remedy === 'localPort') { + const nextInstance: DesktopSshInstance = { + ...selectedInstance, + localForward: { + ...selectedInstance.localForward, + preferredLocalPort: randomPort(), + }, + }; + try { + await upsertInstance(nextInstance); + await connect(nextInstance.id); + toast.success(t('settings.remoteInstances.sidebar.toast.retriedWithRandomPort')); + } catch (error) { + toast.error(t('settings.remoteInstances.page.toast.connectFailed'), { + description: error instanceof Error ? error.message : String(error), + }); + } + return; + } + + setAdvancedOpen(true); + window.setTimeout(() => { + if (remedy === 'uiPassword') { + uiPasswordRef.current?.focus(); + return; + } + remotePortRef.current?.scrollIntoView({ block: 'center' }); + }, 0); + }, [connect, selectedInstance, t, upsertInstance]); + const readLogsForInstance = React.useCallback(async (id: string) => { const lines = await desktopSshLogs(id, 600); return lines.map((line) => formatLogLine(line)); @@ -1321,6 +1469,24 @@ export const RemoteInstancesPage: React.FC = () => { return; } + if (!canDisconnect && draft.remoteOpenchamber.mode === 'external' && !draft.remoteOpenchamber.preferredPort) { + toast.error(t('settings.remoteInstances.page.validation.externalPortRequired')); + setAdvancedOpen(true); + return; + } + + if ( + !canDisconnect && + draft.remoteOpenchamber.mode === 'managed' && + draft.remoteOpenchamber.bindHost === '0.0.0.0' && + !draft.auth.openchamberPassword?.value?.trim() + ) { + toast.error(t('settings.remoteInstances.page.validation.remoteLanNeedsPassword')); + setAdvancedOpen(true); + window.setTimeout(() => uiPasswordRef.current?.focus(), 0); + return; + } + setIsPrimaryActionPending(true); const operation = canDisconnect ? disconnect(draft.id) : connectWithPortRecovery(); void operation @@ -1774,7 +1940,7 @@ export const RemoteInstancesPage: React.FC = () => { title={t('settings.remoteInstances.sidebar.title')} description={t('settings.remoteInstances.sidebar.total', { count: instances.length })} headerAction={( - @@ -1782,50 +1948,71 @@ export const RemoteInstancesPage: React.FC = () => { contentClassName="space-y-2.5" > {isLoading ? ( -

{t('settings.remoteInstances.page.import.loading')}

+

{t('settings.remoteInstances.page.state.loadingInstances')}

) : instances.length === 0 ? ( -

{t('settings.remoteInstances.page.import.noneFound')}

+

+ {importCandidates.length === 1 + ? t('settings.remoteInstances.page.empty.noInstancesWithOneImport') + : importCandidates.length > 1 + ? t('settings.remoteInstances.page.empty.noInstancesWithImports', { count: importCandidates.length }) + : t('settings.remoteInstances.page.empty.noInstances')} +

) : instances.map((instance) => { const instanceStatus = statusesById[instance.id]; const title = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id; const phase = instanceStatus?.phase; const ready = phase === 'ready'; + const state = instanceState(phase); + const failureDetail = state === 'error' ? instanceStatus?.detail : undefined; return ( -
-
-
- -

{title}

+
+
+
+
+ +

{title}

+
+

+ {t(instanceStateLabelKey(state))} + {state === 'connecting' ? ` · ${t(phaseLabelKey(phase))}` : ''} + {ready && instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''} +

+
+
+ {ready ? ( + + ) : null} + + +
-

- {t(phaseLabelKey(phase))}{instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''} -

-
-
- - -
+ {failureDetail ? ( +

{failureDetail}

+ ) : null}
); })} @@ -1835,52 +2022,70 @@ export const RemoteInstancesPage: React.FC = () => { {t('settings.remoteInstances.sidebar.actions.addSshInstance')} - {t('settings.remoteInstances.page.section.instanceDescription')} + {t('settings.remoteInstances.page.addDialog.description')} -
{ event.preventDefault(); void createSshInstanceFromDialog(); }}> - setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} /> - setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus /> -
- - + + {sshAddMode === 'saved' ? ( +
+ setSshHostSearch(event.target.value)} + placeholder={t('settings.remoteInstances.page.addDialog.searchPlaceholder')} + autoFocus + /> + {isImportsLoading ? ( +

{t('settings.remoteInstances.page.import.loading')}

+ ) : importCandidates.length === 0 ? ( +

{t('settings.remoteInstances.page.addDialog.emptySaved')}

+ ) : filteredImportCandidates.length === 0 ? ( +

{t('settings.remoteInstances.page.addDialog.searchEmpty')}

+ ) : ( +
+ {filteredImportCandidates.map((candidate) => ( +
+
+
+ {candidate.host} + {candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''} +
+
{candidate.sshCommand}
+
+ +
+ ))} +
+ )}
- + ) : ( +
{ event.preventDefault(); void createSshInstanceFromDialog(); }}> + setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} /> + setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus /> +
+ + +
+
+ )} : null} - {showInstanceManagement ? - {isImportsLoading ? ( -

{t('settings.remoteInstances.page.import.loading')}

- ) : importCandidates.length === 0 ? ( -

{t('settings.remoteInstances.page.import.noneFound')}

- ) : ( -
- {importCandidates.map((candidate) => ( -
-
-
- {candidate.host} - {candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''} -
-
{candidate.sshCommand}
-
- -
- ))} -
- )} -
: null} - { @@ -1925,6 +2130,10 @@ export const RemoteInstancesPage: React.FC = () => { } const isManagedMode = draft.remoteOpenchamber.mode === 'managed'; + // Publishing the remote server to its network turns the UI password from an + // option into the only thing standing in front of it. + const remoteLanExposed = isManagedMode && draft.remoteOpenchamber.bindHost === '0.0.0.0'; + const uiPasswordMissing = remoteLanExposed && !draft.auth.openchamberPassword?.value?.trim(); const instanceTitle = draft.nickname?.trim() || draft.sshParsed?.destination || draft.id; return ( @@ -1934,7 +2143,8 @@ export const RemoteInstancesPage: React.FC = () => {

{instanceTitle}

- {t(phaseLabelKey(statusPhase))} + {t(instanceStateLabelKey(currentState))} + {currentState === 'connecting' ? {t(phaseLabelKey(statusPhase))} : null} {status?.localUrl ? {status.localUrl} : null} {reconnectAppearsStuck ? {t('settings.remoteInstances.page.status.reconnectStale')} : null}
@@ -2005,6 +2215,29 @@ export const RemoteInstancesPage: React.FC = () => { {t('settings.remoteInstances.sidebar.actions.remove')}
+ {currentState === 'error' && status?.detail ? ( +
+

{status.detail}

+ {currentRemedyHintKey ? ( +

{t(currentRemedyHintKey)}

+ ) : null} + {currentRemedy && !currentRemedyHintKey ? ( + + ) : null} +
+ ) : null} {status?.localUrl ? (
{t('settings.remoteInstances.page.status.currentLocalUrl')} @@ -2046,30 +2279,6 @@ export const RemoteInstancesPage: React.FC = () => { placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} />
-
- {t('settings.remoteInstances.page.field.connectionTimeoutSeconds')} - { - updateDraft((current) => ({ - ...current, - connectionTimeoutSec: Number.isFinite(next) ? next : current.connectionTimeoutSec, - })); - }} - /> -
- - -
{
+ + + + + {t('settings.remoteInstances.page.section.advanced')} + + + +

{t('settings.remoteInstances.page.section.advancedHint')}

+
+ {t('settings.remoteInstances.page.field.connectionTimeoutSeconds')} + { + updateDraft((current) => ({ + ...current, + connectionTimeoutSec: Number.isFinite(next) ? next : current.connectionTimeoutSec, + })); + }} + /> +
+ +
-
+
{ ...current, remoteOpenchamber: { ...current.remoteOpenchamber, - installMethod: - value === 'npm' || value === 'download_release' || value === 'upload_bundle' - ? value - : 'bun', + installMethod: value === 'npm' || value === 'bun' ? value : 'auto', }, })) } @@ -2162,15 +2400,45 @@ export const RemoteInstancesPage: React.FC = () => { + {t('settings.remoteInstances.page.field.installMethodAuto')} bun npm - {t('settings.remoteInstances.page.field.installMethodDownloadRelease')} - {t('settings.remoteInstances.page.field.installMethodUploadBundle')}
) : null} + {isManagedMode ? ( +
+
+
+ +
+ + updateDraft((current) => ({ + ...current, + remoteOpenchamber: { + ...current.remoteOpenchamber, + bindHost: checked ? '0.0.0.0' : '127.0.0.1', + }, + })) + } + aria-label={t('settings.remoteInstances.page.field.remoteLanAccess')} + /> +
+ {remoteLanExposed ? ( +

+ {t('settings.remoteInstances.page.field.remoteLanAccessWarning')} +

+ ) : null} +
+ ) : null} + {isManagedMode ? (
@@ -2227,13 +2495,13 @@ export const RemoteInstancesPage: React.FC = () => { })); }} > - + - 127.0.0.1 - localhost - 0.0.0.0 + {t('settings.remoteInstances.page.field.bindHostOption.loopback')} + {t('settings.remoteInstances.page.field.bindHostOption.localhost')} + {t('settings.remoteInstances.page.field.bindHostOption.lan')}
@@ -2293,6 +2561,13 @@ export const RemoteInstancesPage: React.FC = () => {
+ +
+

{t('settings.remoteInstances.page.tunnelPreview.caption')}

+

+ {`${draft.localForward.bindHost}:${draft.localForward.preferredLocalPort || 'auto'} → ${draft.sshParsed?.destination || draft.nickname || 'remote'}:${draft.remoteOpenchamber.preferredPort || 'auto'}`} +

+
{ contentClassName="space-y-3" >
- {t('settings.remoteInstances.page.field.sshPasswordOptional')} +
+ +
{
- {t('settings.remoteInstances.page.field.uiPasswordOptional')} +
+ +
updateDraft((current) => ({ @@ -2345,6 +2636,11 @@ export const RemoteInstancesPage: React.FC = () => { placeholder={t('settings.remoteInstances.page.field.uiPasswordPlaceholder')} />
+ {uiPasswordMissing ? ( +

+ {t('settings.remoteInstances.page.field.uiPasswordMissingForLan')} +

+ ) : null}
{ + + +