From e9701e7050259920f9a4f69a4f9c4632ad9eefe4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 22:13:34 -0700 Subject: [PATCH 1/5] fix(server): skip device hosts that resolve to the local machine (#11698) (cherry picked from commit 1ced38a6647b7f466b535b4b413ca4a74303da8e) --- apps/server/src/device/DeviceService.ts | 16 +++- .../src/device/localSshDeviceHost.test.ts | 87 +++++++++++++++++++ apps/server/src/device/localSshDeviceHost.ts | 71 +++++++++++++++ apps/server/src/ws.ts | 18 +++- 4 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/device/localSshDeviceHost.test.ts create mode 100644 apps/server/src/device/localSshDeviceHost.ts diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 17d01de52b..823c6d97b6 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -59,6 +59,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import * as ServerSettings from "../serverSettings.ts"; import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import { isLocalSshDeviceHost, remoteSshDeviceHosts } from "./localSshDeviceHost.ts"; import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -1032,11 +1033,17 @@ export const make = Effect.gen(function* () { }; const probeContext = yield* Effect.context>>(); + const localTargetContext = + yield* Effect.context>>(); const service = yield* makeWithHosts( hosts, (host) => - SshDeviceHost.probe(host).pipe( - Effect.provide(probeContext), + Effect.gen(function* () { + if (yield* isLocalSshDeviceHost(host).pipe(Effect.provide(localTargetContext))) { + return yield* localHost.summary; + } + return yield* SshDeviceHost.probe(host).pipe(Effect.provide(probeContext)); + }).pipe( Effect.mapError( (error) => new DeviceOperationError({ @@ -1051,8 +1058,11 @@ export const make = Effect.gen(function* () { const hostContext = yield* Effect.context>>(); const configured = new Map(); - const reconcile = (next: ReadonlyArray) => + const reconcile = (configuredHosts: ReadonlyArray) => Effect.gen(function* () { + const next = yield* remoteSshDeviceHosts(configuredHosts).pipe( + Effect.provide(localTargetContext), + ); const removed = yield* service.withLifecycleLock( Effect.gen(function* () { const removed: Array<{ id: string; scope: Scope.Closeable }> = []; diff --git a/apps/server/src/device/localSshDeviceHost.test.ts b/apps/server/src/device/localSshDeviceHost.test.ts new file mode 100644 index 0000000000..d4d202950a --- /dev/null +++ b/apps/server/src/device/localSshDeviceHost.test.ts @@ -0,0 +1,87 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { + isLocalSshDeviceHost, + LocalDeviceHostAddresses, + remoteSshDeviceHosts, +} from "./localSshDeviceHost.ts"; + +const host = (target: string, port?: number) => ({ + id: target, + label: target, + target, + ...(port ? { port } : {}), +}); +const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command"); + // Any attempt to actually connect fails this test. + expect(command.args).toContain("-G"); + const target = command.args.at(-1); + const configs: Record = { + "mac-mini": "hostname 100.65.180.100\nport 22\n", + remote: "hostname 192.0.2.1\nport 22\n", + loopback: "hostname 127.0.1.1\nport 22\n", + ipv6: "hostname ::1\nport 22\n", + forwarded: "hostname 127.0.0.1\nport 2222\n", + proxy: "hostname 127.0.0.1\nport 22\nproxyjump bastion\n", + command: "hostname 127.0.0.1\nport 22\nproxycommand nc remote 22\n", + unresolved: "hostname example.invalid\nport 22\n", + }; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.make(new TextEncoder().encode(configs[target ?? ""] ?? "")), + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + }), +); +const provide = ( + effect: Effect.Effect>>, +) => + effect.pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(LocalDeviceHostAddresses, new Set(["100.65.180.100"])), + Effect.provide(NodeServices.layer), + ); + +it.effect("skips SSH aliases resolving to this machine, including loopback", () => + provide( + Effect.gen(function* () { + for (const target of ["mac-mini", "loopback", "ipv6"]) { + expect(yield* isLocalSshDeviceHost(host(target))).toBe(true); + } + }), + ), +); + +it.effect("keeps remote, forwarded, proxied, and unresolved destinations", () => + provide( + Effect.gen(function* () { + for (const target of ["remote", "forwarded", "proxy", "command", "unresolved"]) { + expect(yield* isLocalSshDeviceHost(host(target))).toBe(false); + } + }), + ), +); + +it.effect("removes only self targets from a fanned-out host list", () => + provide( + Effect.gen(function* () { + expect( + yield* remoteSshDeviceHosts([host("mac-mini"), host("remote"), host("forwarded")]), + ).toEqual([host("remote"), host("forwarded")]); + }), + ), +); diff --git a/apps/server/src/device/localSshDeviceHost.ts b/apps/server/src/device/localSshDeviceHost.ts new file mode 100644 index 0000000000..0d1f615e17 --- /dev/null +++ b/apps/server/src/device/localSshDeviceHost.ts @@ -0,0 +1,71 @@ +import * as NodeDnsPromises from "node:dns/promises"; +import * as NodeNet from "node:net"; +import type { SshDeviceHostConfig } from "@t3tools/contracts"; +import * as NodeOS from "node:os"; +import * as Context from "effect/Context"; +import { runSshCommand } from "@t3tools/ssh/command"; +import * as Effect from "effect/Effect"; + +export const LocalDeviceHostAddresses = Context.Reference>( + "LocalDeviceHostAddresses", + { + defaultValue: () => + new Set( + Object.values(NodeOS.networkInterfaces()).flatMap( + (entries) => entries?.map((entry) => entry.address) ?? [], + ), + ), + }, +); + +/** Resolve aliases on the owning environment without opening an SSH connection. */ +export const isLocalSshDeviceHost = Effect.fn("isLocalSshDeviceHost")(function* ( + host: SshDeviceHostConfig, +) { + const result = yield* runSshCommand( + { alias: host.target, hostname: host.target, username: null, port: host.port ?? null }, + { + preHostArgs: ["-G", ...(host.identityFile ? ["-i", host.identityFile] : [])], + timeoutMs: 5000, + }, + ).pipe(Effect.result); + if (result._tag === "Failure") return false; + const config = new Map( + result.success.stdout.split("\n").map((line) => { + const separator = line.indexOf(" "); + return [line.slice(0, separator), line.slice(separator + 1).trim()]; + }), + ); + // A local forwarded port or a proxy can lead to a different machine. + if ( + config.get("port") !== "22" || + ["proxycommand", "proxyjump"].some((key) => config.has(key) && config.get(key) !== "none") + ) + return false; + const hostname = config.get("hostname")?.replace(/^\[|\]$/g, ""); + if (!hostname) return false; + const addresses = NodeNet.isIP(hostname) + ? [hostname] + : yield* Effect.tryPromise(() => NodeDnsPromises.lookup(hostname, { all: true })).pipe( + Effect.map((entries) => entries.map((entry) => entry.address)), + Effect.timeout("2 seconds"), + Effect.orElseSucceed(() => [] as string[]), + ); + const localAddresses = yield* LocalDeviceHostAddresses; + return ( + addresses.length > 0 && + addresses.every( + (address) => localAddresses.has(address) || address === "::1" || address.startsWith("127."), + ) + ); +}); + +export const remoteSshDeviceHosts = Effect.fn("remoteSshDeviceHosts")(function* ( + hosts: ReadonlyArray, +) { + return yield* Effect.filter( + hosts, + (host) => isLocalSshDeviceHost(host).pipe(Effect.map((local) => !local)), + { concurrency: 4 }, + ); +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9d4328a8cc..77241430bd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -150,6 +150,7 @@ import * as TerminalManager from "./terminal/Manager.ts"; import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as DeviceService from "./device/DeviceService.ts"; +import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; @@ -558,6 +559,8 @@ const makeWsRpcLayer = ( const terminalManager = yield* TerminalManager.TerminalManager; const previewManager = yield* PreviewManager.PreviewManager; const deviceService = yield* DeviceService.DeviceService; + const deviceHostContext = + yield* Effect.context>>(); const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerService = yield* ProviderService.ProviderService; @@ -2849,9 +2852,18 @@ const makeWsRpcLayer = ( serverSettings.getSettings.pipe( Effect.map((current) => providerSettingsMutationInstanceIds(current, patch)), ), - serverSettings - .updateSettings(patch) - .pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), + Effect.gen(function* () { + const deviceHosts = patch.deviceHosts + ? yield* remoteSshDeviceHosts(patch.deviceHosts).pipe( + Effect.provide(deviceHostContext), + ) + : undefined; + const settings = yield* serverSettings.updateSettings({ + ...patch, + ...(deviceHosts ? { deviceHosts } : {}), + }); + return ServerSettings.redactServerSettingsForClient(settings); + }), ), { "rpc.aggregate": "server", From 807ae5ee8cfc00b70c027b726f29a981aacbe268 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 02:37:09 -0600 Subject: [PATCH 2/5] fix(web): check SSH device hosts across selected environments Adapt upstream 8984f8103d0836c0b340fe70cd053428d622a8c3 to Pylon's selected-environment host ownership and retry UI. --- .../settings/DeviceHostsSettings.tsx | 46 ++++++---- .../deviceHostConnectionChecks.test.ts | 85 +++++++++++++++++++ .../settings/deviceHostConnectionChecks.ts | 61 +++++++++++++ .../settings/useHostConnectionChecks.ts | 46 ++++++++++ docs/user/devices.md | 6 +- 5 files changed, 225 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/components/settings/deviceHostConnectionChecks.test.ts create mode 100644 apps/web/src/components/settings/deviceHostConnectionChecks.ts create mode 100644 apps/web/src/components/settings/useHostConnectionChecks.ts diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx index 67db0088d8..94694642de 100644 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -20,6 +20,9 @@ import { Input } from "../ui/input"; import { MoreVertical, PlusIcon } from "lucide-react"; import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu"; import { SettingsRow } from "./settingsLayout"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { useHostConnectionChecks } from "./useHostConnectionChecks"; +import { deviceHostConnectionKey } from "./deviceHostConnectionChecks"; /** Host names and identity paths belong to the selected environment, never all environments. */ export function DeviceHostsSettings(props: { @@ -30,6 +33,14 @@ export function DeviceHostsSettings(props: { const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false }); const retry = useAtomCommand(deviceEnvironment.list); const { state } = useDeviceState(props.environmentId); + const { environments } = useSettingsScope(); + const targets = environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + connected: environment.connection.phase === "connected", + })); + const { checks: environmentChecks, testConnection: testAcrossEnvironments } = + useHostConnectionChecks(targets); const [editing, setEditing] = useState(null); const [busy, setBusy] = useState(false); const [retrying, setRetrying] = useState(null); @@ -327,7 +338,7 @@ export function DeviceHostsSettings(props: { !editing.target.trim() || !validPort(editing.port) } - onClick={() => void testConnection(editing)} + onClick={() => void testAcrossEnvironments(editing)} > Test connection @@ -343,23 +354,22 @@ export function DeviceHostsSettings(props: { Cancel - {checks[editing.id]?.pending ? ( - - - Checking connection… - - ) : null} - {checks[editing.id]?.platforms ? ( - - ) : null} - {checks[editing.id]?.error ? ( -

- {checks[editing.id]?.error} -

- ) : null} + {targets.map((target) => { + const result = + environmentChecks[deviceHostConnectionKey(editing)]?.[target.environmentId]; + if (!result) return null; + return ( +
+ {target.label}: + {result.status === "pending" ? "Checking…" : null} + {result.status === "local" ? "Already available locally" : null} + {result.status === "failed" ? result.error : null} + {result.status === "connected" ? ( + + ) : null} +
+ ); + })} ) : null} diff --git a/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts b/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts new file mode 100644 index 0000000000..f9b003812b --- /dev/null +++ b/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts @@ -0,0 +1,85 @@ +import * as Option from "effect/Option"; +import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, type DeviceHostSummary } from "@t3tools/contracts"; +import { + checkDeviceHostConnections, + parseDeviceHostDraft, + deviceHostConnectionKey, + type DeviceHostCheck, +} from "./deviceHostConnectionChecks"; + +const host = { id: "mac", label: "Mac mini", target: "user@mac" }; +const ids = ["a", "b", "c", "d"].map((id) => EnvironmentId.make(id)); +const targets = ids.map((environmentId, index) => ({ + environmentId, + label: environmentId, + connected: index !== 3, +})); +const summary: DeviceHostSummary = { + id: "mac", + label: "Mac mini", + kind: "ssh", + platforms: [{ platform: "ios", available: true }], + hubInstalled: false, + agentDeviceInstalled: false, +}; + +describe("device host connection checks", () => { + it("starts all connected environments and retains success, local, failure, and offline results", async () => { + const calls: string[] = []; + const pending = new Map< + string, + { resolve: (value: DeviceHostSummary) => void; reject: (error: Error) => void } + >(); + const results = new Map(); + const run = checkDeviceHostConnections( + targets, + host, + (environmentId) => { + calls.push(environmentId); + return new Promise((resolve, reject) => pending.set(environmentId, { resolve, reject })); + }, + (environmentId, result) => results.set(environmentId, result), + ); + expect(calls).toEqual(ids.slice(0, 3)); + expect(results.get(ids[0]!)).toEqual({ status: "pending" }); + pending.get(ids[0]!)!.resolve(summary); + pending.get(ids[1]!)!.resolve({ ...summary, id: "local", kind: "local" }); + pending.get(ids[2]!)!.reject(new Error("SSH key rejected")); + await run; + expect([...results.values()]).toEqual([ + { status: "connected", platforms: summary.platforms }, + { status: "local" }, + { status: "failed", error: "SSH key rejected" }, + { status: "failed", error: "Environment disconnected" }, + ]); + }); + + it("does not reuse results after editing a destination or SSH options", () => { + const key = deviceHostConnectionKey(host); + for (const changed of [ + { ...host, target: "other" }, + { ...host, port: 2222 }, + { ...host, identityFile: "~/.ssh/other" }, + ]) { + expect(deviceHostConnectionKey(changed)).not.toBe(key); + } + expect( + deviceHostConnectionKey({ ...host, id: "another-environment-id", label: "Renamed" }), + ).toBe(key); + }); + it("validates SSH targets and normalizes optional identity files through the host contract", () => { + for (const target of ["-invalid", "user@bad host", " "]) { + expect(parseDeviceHostDraft({ ...host, target })._tag).toBe("None"); + } + for (const port of [0, 65536, 1.5]) { + expect(parseDeviceHostDraft({ ...host, port })._tag).toBe("None"); + } + expect(parseDeviceHostDraft({ ...host, target: " user@mac ", identityFile: " " })).toEqual( + Option.some(host), + ); + expect(parseDeviceHostDraft({ ...host, identityFile: " ~/.ssh/device " })).toEqual( + Option.some({ ...host, identityFile: "~/.ssh/device" }), + ); + }); +}); diff --git a/apps/web/src/components/settings/deviceHostConnectionChecks.ts b/apps/web/src/components/settings/deviceHostConnectionChecks.ts new file mode 100644 index 0000000000..97312b6715 --- /dev/null +++ b/apps/web/src/components/settings/deviceHostConnectionChecks.ts @@ -0,0 +1,61 @@ +import { + type DeviceHostSummary, + type DevicePlatformAvailability, + type EnvironmentId, + SshDeviceHostConfig, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export interface DeviceHostCheckTarget { + environmentId: EnvironmentId; + label: string; + connected: boolean; +} +export type DeviceHostCheck = + | { status: "pending" } + | { status: "local" } + | { status: "connected"; platforms: ReadonlyArray } + | { status: "failed"; error: string }; + +const decodeDeviceHostDraft = Schema.decodeUnknownOption(SshDeviceHostConfig); + +export function parseDeviceHostDraft(host: SshDeviceHostConfig) { + const { identityFile, ...rest } = host; + return decodeDeviceHostDraft({ + ...rest, + ...(identityFile?.trim() ? { identityFile: identityFile.trim() } : {}), + }); +} + +export function deviceHostConnectionKey(host: SshDeviceHostConfig) { + return JSON.stringify([host.target.trim(), host.port, host.identityFile?.trim() || undefined]); +} + +/** Each environment settles independently so one failure cannot hide the other results. */ +export async function checkDeviceHostConnections( + targets: ReadonlyArray, + host: SshDeviceHostConfig, + probe: (environmentId: EnvironmentId, host: SshDeviceHostConfig) => Promise, + report: (environmentId: EnvironmentId, result: DeviceHostCheck) => void, +) { + await Promise.all( + targets.map(async (target) => { + report(target.environmentId, { status: "pending" }); + try { + if (!target.connected) throw new Error("Environment disconnected"); + const result = await probe(target.environmentId, host); + report( + target.environmentId, + result.kind === "local" + ? { status: "local" } + : { status: "connected", platforms: result.platforms }, + ); + } catch (error) { + report(target.environmentId, { + status: "failed", + error: error instanceof Error ? error.message : String(error), + }); + } + }), + ); +} diff --git a/apps/web/src/components/settings/useHostConnectionChecks.ts b/apps/web/src/components/settings/useHostConnectionChecks.ts new file mode 100644 index 0000000000..7d642fa62a --- /dev/null +++ b/apps/web/src/components/settings/useHostConnectionChecks.ts @@ -0,0 +1,46 @@ +import { useRef, useState } from "react"; +import * as Cause from "effect/Cause"; +import type { SshDeviceHostConfig } from "@t3tools/contracts"; +import { deviceEnvironment } from "../../state/device"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + checkDeviceHostConnections, + deviceHostConnectionKey, + type DeviceHostCheck, + type DeviceHostCheckTarget, +} from "./deviceHostConnectionChecks"; + +export function useHostConnectionChecks(targets: ReadonlyArray) { + const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false }); + const [checks, setChecks] = useState>>({}); + const running = useRef(new Set()); + const testConnection = async (host: SshDeviceHostConfig) => { + const key = deviceHostConnectionKey(host); + if (running.current.has(key)) return; + running.current.add(key); + setChecks((current) => ({ ...current, [key]: {} })); + const results: Record = {}; + try { + await checkDeviceHostConnections( + targets, + host, + async (environmentId, input) => { + const result = await test({ environmentId, input }); + if (result._tag === "Failure") throw new Error(Cause.pretty(result.cause)); + return result.value; + }, + (environmentId, result) => { + results[environmentId] = result; + setChecks((current) => ({ + ...current, + [key]: { ...current[key], [environmentId]: result }, + })); + }, + ); + return results; + } finally { + running.current.delete(key); + } + }; + return { checks, testConnection }; +} diff --git a/docs/user/devices.md b/docs/user/devices.md index fb26486e96..60409a1025 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -82,7 +82,11 @@ so use the SSH configuration and keys available there. Password prompts are not supported. **Test connection** checks SSH, Node, npm, and platform tools without installing -anything. The first device listing installs pinned device tools on the host. +anything. When adding or editing a host, the check reports a result for each environment +selected in Settings; saving changes the host list on the environment shown in +the Devices section. An SSH alias that resolves to the environment server's own +machine is skipped, unless it uses a forwarded port or SSH proxy. The first +device listing installs pinned device tools on the host. Node 22 or newer and npm must be available to non-interactive SSH commands. Pylon checks common Homebrew and Android SDK locations; custom installations need the appropriate PATH and ANDROID_HOME on the host. From b39fc1a30e7c53eca33b65f058cf920cabc1bb2b Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 02:42:56 -0600 Subject: [PATCH 3/5] fix(web): validate SSH host draft and fence scoped checks --- .../settings/DeviceHostsSettings.tsx | 41 +++++++++---------- .../deviceHostConnectionChecks.test.ts | 11 +++++ .../settings/deviceHostConnectionChecks.ts | 10 +++++ .../settings/useHostConnectionChecks.ts | 4 +- 4 files changed, 42 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx index 94694642de..c1841e1660 100644 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -10,6 +10,7 @@ import type { SshDeviceHostConfig, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; import { randomUUID } from "../../lib/utils"; import { useState } from "react"; import { deviceEnvironment, useDeviceState } from "../../state/device"; @@ -22,7 +23,7 @@ import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu"; import { SettingsRow } from "./settingsLayout"; import { useSettingsScope } from "./SettingsScopeContext"; import { useHostConnectionChecks } from "./useHostConnectionChecks"; -import { deviceHostConnectionKey } from "./deviceHostConnectionChecks"; +import { deviceHostChecksKey, parseDeviceHostDraft } from "./deviceHostConnectionChecks"; /** Host names and identity paths belong to the selected environment, never all environments. */ export function DeviceHostsSettings(props: { @@ -42,10 +43,10 @@ export function DeviceHostsSettings(props: { const { checks: environmentChecks, testConnection: testAcrossEnvironments } = useHostConnectionChecks(targets); const [editing, setEditing] = useState(null); + const parsedEditing = editing ? parseDeviceHostDraft(editing) : Option.none(); + const validEditing = Option.isSome(parsedEditing) && editing?.label.trim() !== ""; const [busy, setBusy] = useState(false); const [retrying, setRetrying] = useState(null); - const validPort = (port: number | undefined) => - port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535); const [checks, setChecks] = useState< Record< string, @@ -261,7 +262,12 @@ export function DeviceHostsSettings(props: { className="space-y-3 border-t border-border/50 py-3" onSubmit={(event) => { event.preventDefault(); - void save([...props.hosts.filter((host) => host.id !== editing.id), editing]); + if (Option.isSome(parsedEditing)) { + void save([ + ...props.hosts.filter((host) => host.id !== editing.id), + parsedEditing.value, + ]); + } }} >
- @@ -356,7 +351,9 @@ export function DeviceHostsSettings(props: {
{targets.map((target) => { const result = - environmentChecks[deviceHostConnectionKey(editing)]?.[target.environmentId]; + environmentChecks[deviceHostChecksKey(editing, targets)]?.[ + target.environmentId + ]; if (!result) return null; return (
diff --git a/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts b/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts index f9b003812b..76fe0769a0 100644 --- a/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts +++ b/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts @@ -5,6 +5,7 @@ import { checkDeviceHostConnections, parseDeviceHostDraft, deviceHostConnectionKey, + deviceHostChecksKey, type DeviceHostCheck, } from "./deviceHostConnectionChecks"; @@ -68,6 +69,16 @@ describe("device host connection checks", () => { deviceHostConnectionKey({ ...host, id: "another-environment-id", label: "Renamed" }), ).toBe(key); }); + it("does not reuse results when selected environments or connectivity change", () => { + const key = deviceHostChecksKey(host, targets); + expect(deviceHostChecksKey(host, targets.slice(0, 2))).not.toBe(key); + expect( + deviceHostChecksKey( + host, + targets.map((target) => ({ ...target, connected: true })), + ), + ).not.toBe(key); + }); it("validates SSH targets and normalizes optional identity files through the host contract", () => { for (const target of ["-invalid", "user@bad host", " "]) { expect(parseDeviceHostDraft({ ...host, target })._tag).toBe("None"); diff --git a/apps/web/src/components/settings/deviceHostConnectionChecks.ts b/apps/web/src/components/settings/deviceHostConnectionChecks.ts index 97312b6715..9a8179e175 100644 --- a/apps/web/src/components/settings/deviceHostConnectionChecks.ts +++ b/apps/web/src/components/settings/deviceHostConnectionChecks.ts @@ -31,6 +31,16 @@ export function deviceHostConnectionKey(host: SshDeviceHostConfig) { return JSON.stringify([host.target.trim(), host.port, host.identityFile?.trim() || undefined]); } +export function deviceHostChecksKey( + host: SshDeviceHostConfig, + targets: ReadonlyArray, +) { + return JSON.stringify([ + deviceHostConnectionKey(host), + targets.map((target) => [target.environmentId, target.connected]), + ]); +} + /** Each environment settles independently so one failure cannot hide the other results. */ export async function checkDeviceHostConnections( targets: ReadonlyArray, diff --git a/apps/web/src/components/settings/useHostConnectionChecks.ts b/apps/web/src/components/settings/useHostConnectionChecks.ts index 7d642fa62a..f7ba43d275 100644 --- a/apps/web/src/components/settings/useHostConnectionChecks.ts +++ b/apps/web/src/components/settings/useHostConnectionChecks.ts @@ -5,7 +5,7 @@ import { deviceEnvironment } from "../../state/device"; import { useAtomCommand } from "../../state/use-atom-command"; import { checkDeviceHostConnections, - deviceHostConnectionKey, + deviceHostChecksKey, type DeviceHostCheck, type DeviceHostCheckTarget, } from "./deviceHostConnectionChecks"; @@ -15,7 +15,7 @@ export function useHostConnectionChecks(targets: ReadonlyArray>>({}); const running = useRef(new Set()); const testConnection = async (host: SshDeviceHostConfig) => { - const key = deviceHostConnectionKey(host); + const key = deviceHostChecksKey(host, targets); if (running.current.has(key)) return; running.current.add(key); setChecks((current) => ({ ...current, [key]: {} })); From 362950388b78798e458a613f11224e93d2445cd4 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 02:45:03 -0600 Subject: [PATCH 4/5] fix(web): guard SSH host submit against blank labels --- apps/web/src/components/settings/DeviceHostsSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx index c1841e1660..c55cbe93f4 100644 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -262,7 +262,7 @@ export function DeviceHostsSettings(props: { className="space-y-3 border-t border-border/50 py-3" onSubmit={(event) => { event.preventDefault(); - if (Option.isSome(parsedEditing)) { + if (validEditing && Option.isSome(parsedEditing)) { void save([ ...props.hosts.filter((host) => host.id !== editing.id), parsedEditing.value, From f134b4e149ca64d05f6bf79211a4bda98a667e75 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 03:01:12 -0600 Subject: [PATCH 5/5] test(web): include scoped environments in integrations fixture --- .../src/components/settings/IntegrationsSettings.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/src/components/settings/IntegrationsSettings.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.test.tsx index 8b1a4585f7..17d18f1ad0 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.test.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.test.tsx @@ -57,6 +57,12 @@ vi.mock("./SettingsScopeContext", () => ({ } : null, connectedEnvironments: selectedDeviceEnvironment.aggregate ? [{}, {}] : [], + environments: selectedDeviceEnvironment.aggregate + ? [ + { environmentId: "remote", label: "Selected remote", connection: { phase: "connected" } }, + { environmentId: "other", label: "Other", connection: { phase: "connected" } }, + ] + : [], targets: [], }), useOptionalSettingsScope: () => null,