From 2fe0e4dde713120f73b7003e5aae4f0de923bf33 Mon Sep 17 00:00:00 2001 From: dinq Date: Tue, 6 Oct 2026 04:44:23 -0400 Subject: [PATCH 1/6] fix(desktop): store saved SSH hosts in the versioned JSON document Corrupt, invalid, and pre-envelope files fail and stay untouched. Version stays 1. --- apps/desktop/package.json | 1 + apps/desktop/src/main/ssh/desktop-ssh.test.ts | 127 ++++------ apps/desktop/src/main/ssh/desktop-ssh.ts | 229 ++++++------------ apps/desktop/src/main/startup-failure.test.ts | 8 + apps/desktop/src/main/startup-failure.ts | 6 +- docs/host-persistence.md | 17 +- packages/server/src/config/paths.ts | 2 +- pnpm-lock.yaml | 3 + 8 files changed, 150 insertions(+), 243 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4d5247701..547340d75 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -26,6 +26,7 @@ "@getpie/app": "workspace:*", "@getpie/contract": "workspace:*", "@getpie/core": "workspace:*", + "@getpie/effect-json-store": "workspace:*", "@getpie/server": "workspace:*", "@getpie/ssh": "workspace:*", "@getpie/tailscale": "workspace:*", diff --git a/apps/desktop/src/main/ssh/desktop-ssh.test.ts b/apps/desktop/src/main/ssh/desktop-ssh.test.ts index 8a1e54c28..096463aa7 100644 --- a/apps/desktop/src/main/ssh/desktop-ssh.test.ts +++ b/apps/desktop/src/main/ssh/desktop-ssh.test.ts @@ -64,97 +64,61 @@ describe("DesktopSsh saved hosts", () => { expect(saved).toEqual([]); }); - it("reads version-1 hosts and skips malformed entries", async () => { - const saved = await withSsh((dir) => + it("leaves an invalid version-1 file untouched", async () => { + const fileText = JSON.stringify({ + version: 1, + data: { + environments: [ + { + id: "abc123abc123abcd", + alias: "myserver", + hostname: "example.com", + username: "alice", + port: 2222, + }, + { id: "", alias: "bad" }, + ], + }, + }); + const error = await withSsh((dir) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString( - path.join(dir, "ssh-environments.json"), - JSON.stringify({ - version: 1, - data: { - environments: [ - { - id: "abc123abc123abcd", - alias: "myserver", - hostname: "example.com", - username: "alice", - port: 2222, - }, - { id: "", alias: "bad" }, - { - id: "deadbeefdeadbeef", - alias: "other", - hostname: "other.example", - username: null, - port: null, - }, - ], - }, - }), - ); - const ssh = yield* makeDesktopSsh({ persistPath: path.join(dir, "ssh-environments.json") }); - return yield* ssh.listSaved; + const file = path.join(dir, "ssh-environments.json"); + yield* fs.writeFileString(file, fileText); + const opened = yield* makeDesktopSsh({ persistPath: file }).pipe(Effect.flip); + return { opened, raw: yield* fs.readFileString(file) }; }), ); - expect(saved).toEqual([ - { - id: "abc123abc123abcd", - target: { + expect(error.opened._tag).toBe("SshPersistError"); + expect(error.raw).toBe(fileText); + }); + + it("does not adopt a pre-envelope file", async () => { + const fileText = JSON.stringify({ + version: 1, + activeId: "abc123abc123abcd", + environments: [ + { + id: "abc123abc123abcd", alias: "myserver", hostname: "example.com", username: "alice", - port: 2222, - }, - }, - { - id: "deadbeefdeadbeef", - target: { - alias: "other", - hostname: "other.example", - username: null, - port: null, + port: 22, }, - }, - ]); - }); - - it("still reads a pre-envelope version-1 file", async () => { - const saved = await withSsh((dir) => + ], + }); + const error = await withSsh((dir) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString( - path.join(dir, "ssh-environments.json"), - JSON.stringify({ - version: 1, - activeId: "abc123abc123abcd", - environments: [ - { - id: "abc123abc123abcd", - alias: "myserver", - hostname: "example.com", - username: "alice", - port: 22, - }, - ], - }), - ); - const ssh = yield* makeDesktopSsh({ persistPath: path.join(dir, "ssh-environments.json") }); - return yield* ssh.listSaved; + const file = path.join(dir, "ssh-environments.json"); + yield* fs.writeFileString(file, fileText); + const opened = yield* makeDesktopSsh({ persistPath: file }).pipe(Effect.flip); + return { opened, raw: yield* fs.readFileString(file) }; }), ); - expect(saved).toEqual([ - { - id: "abc123abc123abcd", - target: { - alias: "myserver", - hostname: "example.com", - username: "alice", - port: 22, - }, - }, - ]); + expect(error.opened._tag).toBe("SshPersistError"); + expect(error.raw).toBe(fileText); }); it("remove deletes a saved host from disk", async () => { @@ -347,19 +311,18 @@ describe("DesktopSsh saved hosts", () => { } }); - it("fails connect when the saved hosts file cannot be written", async () => { + it("fails to open when the saved hosts file cannot be written", async () => { const error = await withSsh((dir) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const blocker = path.join(dir, "not-a-directory"); yield* fs.writeFileString(blocker, "x"); - const ssh = yield* makeDesktopSsh({ + return yield* makeDesktopSsh({ persistPath: path.join(blocker, "ssh-environments.json"), resolveInput: (raw) => Effect.succeed(parseSshInput(raw)), connectEnvironment: (target) => Effect.succeed(fakeConnected(target)), loadEnvironmentId: () => Effect.succeed("env-1"), - }); - return yield* ssh.connect("alice@example.com").pipe(Effect.flip); + }).pipe(Effect.flip); }), ); expect(error._tag).toBe("SshPersistError"); diff --git a/apps/desktop/src/main/ssh/desktop-ssh.ts b/apps/desktop/src/main/ssh/desktop-ssh.ts index e09f1831f..b14c2c9a0 100644 --- a/apps/desktop/src/main/ssh/desktop-ssh.ts +++ b/apps/desktop/src/main/ssh/desktop-ssh.ts @@ -1,5 +1,4 @@ -import path from "node:path"; - +import { makeJsonDocument } from "@getpie/effect-json-store"; import { resolvePieHome, sshEnvironmentsFile } from "@getpie/server/daemon"; import { connectSshEnvironment, @@ -19,17 +18,7 @@ import { type SshHostDiscoveryError, type SshTarget, } from "@getpie/ssh"; -import { - Context, - Data, - Effect, - FileSystem, - Layer, - Ref, - Scope, - Semaphore, - type PlatformError, -} from "effect"; +import { Context, Data, Effect, FileSystem, Layer, Ref, Schema, Scope, Semaphore } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { LoginShellEnvironment } from "../server/login-shell-environment"; @@ -81,25 +70,6 @@ type LiveSshSession = { readonly connected: SshConnectedEnvironment; }; -type SavedFileData = { - readonly environments: ReadonlyArray<{ - readonly id: string; - readonly alias: string; - readonly hostname: string; - readonly username: string | null; - readonly port: number | null; - }>; -}; - -type SavedFile = { - readonly version: 1; - readonly data: SavedFileData; -}; - -type SavedState = { - readonly environments: readonly SavedSshEnvironment[]; -}; - type SshConnection = DesktopSshConnectResult["connection"]; export type DesktopSshShape = { @@ -159,97 +129,39 @@ const fetchEnvironmentId = (connection: SshConnection): Effect.Effect ({ - id: entry.id, - alias: entry.target.alias, - hostname: entry.target.hostname, - username: entry.target.username, - port: entry.target.port, - })), - }, - }; - return `${JSON.stringify(file, null, 2)}\n`; -} - -const readSaved = (filePath: string): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const raw = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); - return raw.length === 0 ? { environments: [] } : parseSavedFile(raw); - }); - -const writeSaved = ( - filePath: string, - state: SavedState, -): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); - yield* fs.writeFileString(filePath, serializeSavedFile(state)); - yield* fs.chmod(filePath, SAVED_FILE_MODE); +const SavedEnvironmentRecord = Schema.Struct({ + id: Schema.NonEmptyString, + alias: Schema.NonEmptyString, + hostname: Schema.NonEmptyString, + username: Schema.NullOr(Schema.String), + port: Schema.NullOr(Schema.Int), +}); +const SavedEnvironmentsSchema = Schema.Struct({ + environments: Schema.Array(SavedEnvironmentRecord), +}); + +const toSaved = (record: typeof SavedEnvironmentRecord.Type): SavedSshEnvironment => ({ + id: record.id, + target: { + alias: record.alias, + hostname: record.hostname, + username: record.username, + port: record.port, + }, +}); + +const fromSaved = (entry: SavedSshEnvironment): typeof SavedEnvironmentRecord.Type => ({ + id: entry.id, + alias: entry.target.alias, + hostname: entry.target.hostname, + username: entry.target.username, + port: entry.target.port, +}); + +const persistError = (filePath: string, cause: unknown) => + new SshPersistError({ + message: `Failed to persist SSH environments to ${filePath}.`, + cause, }); export function makeDesktopSsh(input: { @@ -264,7 +176,7 @@ export function makeDesktopSsh(input: { ) => Effect.Effect; }): Effect.Effect< DesktopSsh["Service"], - never, + SshPersistError, Scope.Scope | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner > { return Effect.gen(function* () { @@ -273,6 +185,17 @@ export function makeDesktopSsh(input: { FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner >(); const filePath = input.persistPath; + const fs = yield* FileSystem.FileSystem; + const saved = yield* makeJsonDocument({ + path: filePath, + schema: SavedEnvironmentsSchema, + defaults: { environments: [] }, + }).pipe(Effect.mapError((cause) => persistError(filePath, cause))); + // ponytail: library write does not take a mode; pin 0600 after open and each save. + const pinMode = fs + .chmod(filePath, SAVED_FILE_MODE) + .pipe(Effect.mapError((cause) => persistError(filePath, cause))); + yield* pinMode; const liveRef = yield* Ref.make(new Map()); const persistGate = yield* Semaphore.make(1); const cli = { env: input.env }; @@ -283,23 +206,19 @@ export function makeDesktopSsh(input: { mutate: (environments: readonly SavedSshEnvironment[]) => readonly SavedSshEnvironment[], ) => persistGate.withPermit( - Effect.gen(function* () { - const saved = yield* readSaved(filePath); - yield* writeSaved(filePath, { environments: mutate(saved.environments) }).pipe( + saved + .update((current) => ({ + environments: mutate(current.environments.map(toSaved)).map(fromSaved), + })) + .pipe( Effect.tapError(() => Effect.logError("ssh.environments.persist.failed").pipe( Effect.annotateLogs({ path: filePath }), ), ), - Effect.mapError( - (error) => - new SshPersistError({ - message: `Failed to persist SSH environments to ${filePath}.`, - cause: error, - }), - ), - ); - }).pipe(Effect.provide(platform)), + Effect.andThen(pinMode), + Effect.mapError((cause) => persistError(filePath, cause)), + ), ); const resultFromLive = (live: LiveSshSession): DesktopSshConnectResult => ({ @@ -344,10 +263,7 @@ export function makeDesktopSsh(input: { return DesktopSsh.of({ client, - listSaved: readSaved(filePath).pipe( - Effect.provide(platform), - Effect.map((state) => state.environments), - ), + listSaved: saved.get.pipe(Effect.map((state) => state.environments.map(toSaved))), connect: (raw) => Effect.gen(function* () { const missingMessage = client.available ? undefined : client.message; @@ -392,25 +308,22 @@ export function makeDesktopSsh(input: { ); return resultFromLive(live); } - yield* writeSaved(filePath, { - environments: [ - ...(yield* readSaved(filePath)).environments.filter((entry) => entry.id !== id), - { id, target }, - ], - }).pipe( - Effect.tapError(() => - Effect.logError("ssh.environments.persist.failed").pipe( - Effect.annotateLogs({ path: filePath }), + yield* saved + .update((current) => ({ + environments: [ + ...current.environments.filter((entry) => entry.id !== id), + fromSaved({ id, target }), + ], + })) + .pipe( + Effect.tapError(() => + Effect.logError("ssh.environments.persist.failed").pipe( + Effect.annotateLogs({ path: filePath }), + ), ), - ), - Effect.mapError( - (error) => - new SshPersistError({ - message: `Failed to persist SSH environments to ${filePath}.`, - cause: error, - }), - ), - ); + Effect.andThen(pinMode), + Effect.mapError((cause) => persistError(filePath, cause)), + ); yield* adoptLive({ id, target, environmentId, connected }); yield* Effect.log("ssh.environment.connected").pipe( Effect.annotateLogs({ diff --git a/apps/desktop/src/main/startup-failure.test.ts b/apps/desktop/src/main/startup-failure.test.ts index 62990e19d..96fb44376 100644 --- a/apps/desktop/src/main/startup-failure.test.ts +++ b/apps/desktop/src/main/startup-failure.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { ProtocolRegistrationError } from "./electron/app-protocol"; +import { SshPersistError } from "./ssh/desktop-ssh"; import { formatStartupFailure } from "./startup-failure"; describe("formatStartupFailure", () => { @@ -10,4 +11,11 @@ describe("formatStartupFailure", () => { ); expect(message).toContain("internal protocol"); }); + + it("describes an unreadable SSH hosts file", () => { + const message = formatStartupFailure( + new SshPersistError({ message: "Failed to persist SSH environments to /tmp/ssh.json." }), + ); + expect(message).toContain("saved SSH hosts"); + }); }); diff --git a/apps/desktop/src/main/startup-failure.ts b/apps/desktop/src/main/startup-failure.ts index d21c35f78..6ca87b172 100644 --- a/apps/desktop/src/main/startup-failure.ts +++ b/apps/desktop/src/main/startup-failure.ts @@ -1,13 +1,17 @@ import type { Config } from "effect"; import type { ProtocolRegistrationError } from "./electron/app-protocol"; +import type { SshPersistError } from "./ssh/desktop-ssh"; /** Turn a typed shell startup failure into the user-facing error dialog body. */ export function formatStartupFailure( - error: ProtocolRegistrationError | Config.ConfigError, + error: ProtocolRegistrationError | Config.ConfigError | SshPersistError, ): string { if (error._tag === "ConfigError") { return `The desktop could not read its configuration.\n\n${error.message}`; } + if (error._tag === "SshPersistError") { + return `The desktop could not read saved SSH hosts.\n\n${error.message}`; + } return `The desktop could not register its internal protocol.\n\n${error.message}`; } diff --git a/docs/host-persistence.md b/docs/host-persistence.md index a4d729770..5d0f5656d 100644 --- a/docs/host-persistence.md +++ b/docs/host-persistence.md @@ -50,7 +50,8 @@ $PIE_HOME/ ├── storage/ │ ├── projects.json │ ├── sessions//.json -│ └── schedules/.json +│ ├── schedules/.json +│ └── ssh-environments.json ├── worktrees/// ├── logs/ │ ├── pie.log @@ -118,6 +119,20 @@ Renderer `localStorage pie:theme` remains a FOUC cache of `appearance.theme` (se `path` is an absolute registered workspace path and is the only persisted `projectId -> path` mapping. +### Saved SSH hosts + +| Property | Current contract | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Path | `$PIE_HOME/storage/ssh-environments.json` | +| Owner | Desktop `DesktopSsh`. The path is declared in `paths.ts`; the CLI does not read or write it. | +| Data | Version 1 `{ environments: [{ id, alias, hostname, username, port }] }`. Saved targets only. No token, no live tunnel. | +| Write points | First open seeds `[]`. Connect and remove rewrite the array. | +| Compatibility | Current `{ version, data }` envelope only. A pre-envelope file, corrupt JSON, invalid entry, or newer version fails and is left untouched. | +| Extension | Stay on version 1 until a field change requires an explicit migration. | +| Retention | Retained with `$PIE_HOME`. Remove deletes one array entry. No uninstall cleanup. | +| Permissions | `0600`, pinned after open and after each save. | +| Atomicity | `makeJsonDocument`: sibling temp file, then rename. Process-local write lock. | + ### Allocated project folders A draft send with no selected Project calls `project.allocateChatProjectDir`, which creates diff --git a/packages/server/src/config/paths.ts b/packages/server/src/config/paths.ts index 30f4a2d65..726ae5186 100644 --- a/packages/server/src/config/paths.ts +++ b/packages/server/src/config/paths.ts @@ -28,7 +28,7 @@ export class Paths extends Context.Service< readonly logsDir: string; /** `~/Pie` — parent for `project.allocateChatProjectDir` chat folders (`type: "chat"`). */ readonly chatProjectsDir: string; - /** `storage/ssh-environments.json` — Desktop and CLI saved SSH hosts. */ + /** `storage/ssh-environments.json` — Desktop saved SSH hosts. */ readonly sshEnvironmentsFile: string; } >()("Paths") {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43db8528f..3687625d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -552,6 +552,9 @@ importers: '@getpie/core': specifier: workspace:* version: link:../../packages/core + '@getpie/effect-json-store': + specifier: workspace:* + version: link:../../packages/effect-json-store '@getpie/server': specifier: workspace:* version: link:../../packages/server From 75be3876c39260621f771bd2c3beae3063f1b7aa Mon Sep 17 00:00:00 2001 From: dinq Date: Tue, 6 Oct 2026 13:24:14 -0400 Subject: [PATCH 2/6] fix(desktop): bundle the JSON store into the Electron main process electron-vite externalizes production deps. Leaving the store external made Main import its TypeScript source, so the window never opened. --- apps/desktop/electron.vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index bb1137c60..e80b97372 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -70,6 +70,7 @@ export default defineConfig({ exclude: [ "@getpie/contract", "@getpie/core", + "@getpie/effect-json-store", "@getpie/server", "@getpie/ssh", "@getpie/tailscale", From ebfb2e5d892b15bbb0b05b453c6672c90aa7df4b Mon Sep 17 00:00:00 2001 From: dinq Date: Wed, 7 Oct 2026 09:03:50 -0400 Subject: [PATCH 3/6] fix(desktop): adopt saved SSH hosts without blocking startup A missing file is not created until the first change. A pre-envelope file is migrated. A corrupt or newer file is left untouched and only SSH hosts are unavailable. --- .../connections/connection-switcher.tsx | 7 +- apps/app/src/platform.ts | 1 + .../main/application/desktop-application.ts | 8 +- apps/desktop/src/main/ssh/desktop-ssh.test.ts | 70 +++++++--- apps/desktop/src/main/ssh/desktop-ssh.ts | 130 ++++++++++++++---- apps/desktop/src/main/startup-failure.test.ts | 8 -- apps/desktop/src/main/startup-failure.ts | 6 +- apps/desktop/src/shared/desktop-rpc.ts | 1 + docs/host-persistence.md | 22 +-- packages/effect-json-store/src/document.ts | 9 +- .../effect-json-store/test/document.test.ts | 19 +++ 11 files changed, 213 insertions(+), 68 deletions(-) diff --git a/apps/app/src/features/connections/connection-switcher.tsx b/apps/app/src/features/connections/connection-switcher.tsx index e301ed149..ada4fd63e 100644 --- a/apps/app/src/features/connections/connection-switcher.tsx +++ b/apps/app/src/features/connections/connection-switcher.tsx @@ -104,7 +104,12 @@ export function ConnectionSwitcher(): ReactElement | null { {environments.remotes.length > 0 || environments.connecting.length > 0 ? ( ) : null} - {launch.available ? ( + {environments.hostsError === undefined ? null : ( + + {environments.hostsError} + + )} + {environments.hostsError !== undefined ? null : launch.available ? ( { diff --git a/apps/app/src/platform.ts b/apps/app/src/platform.ts index 8b31fb138..ca73162a8 100644 --- a/apps/app/src/platform.ts +++ b/apps/app/src/platform.ts @@ -22,6 +22,7 @@ export type EnvironmentSnapshot = { readonly revision: number; readonly connecting: readonly ConnectingSshHost[]; readonly remotes: readonly SshRemoteEnvironment[]; + readonly hostsError?: string; }; export type DiscoveredSshHost = { diff --git a/apps/desktop/src/main/application/desktop-application.ts b/apps/desktop/src/main/application/desktop-application.ts index c36e1311d..5e25ba3f4 100644 --- a/apps/desktop/src/main/application/desktop-application.ts +++ b/apps/desktop/src/main/application/desktop-application.ts @@ -69,11 +69,12 @@ export type DesktopApplicationDependencies = { readonly scope: Scope.Scope; }; -function emptySnapshot(): EnvironmentSnapshot { +function emptySnapshot(hostsError?: string): EnvironmentSnapshot { return { revision: 0, connecting: [], remotes: [], + ...(hostsError === undefined ? undefined : { hostsError }), }; } @@ -85,7 +86,7 @@ export function makeDesktopApplication({ scope, }: DesktopApplicationDependencies): DesktopApplication["Service"] { const environmentsRef = Effect.runSync( - SubscriptionRef.make(emptySnapshot()), + SubscriptionRef.make(emptySnapshot(ssh.savedHostsMessage)), ); const visible = Effect.runSync(SubscriptionRef.make(false)); @@ -93,6 +94,7 @@ export function makeDesktopApplication({ updater: (current: EnvironmentSnapshot) => Omit, ): Effect.Effect => SubscriptionRef.updateAndGet(environmentsRef, (current) => ({ + hostsError: current.hostsError, ...updater(current), revision: current.revision + 1, })); @@ -104,7 +106,7 @@ export function makeDesktopApplication({ return current; } return { - connecting: current.connecting, + ...current, remotes: current.remotes.filter((entry) => entry.id !== remote.id), revision: current.revision + 1, }; diff --git a/apps/desktop/src/main/ssh/desktop-ssh.test.ts b/apps/desktop/src/main/ssh/desktop-ssh.test.ts index 096463aa7..00eac4d12 100644 --- a/apps/desktop/src/main/ssh/desktop-ssh.test.ts +++ b/apps/desktop/src/main/ssh/desktop-ssh.test.ts @@ -9,7 +9,7 @@ import { Effect, FileSystem, Layer, Scope } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { describe, expect, it } from "vitest"; -import { makeDesktopSsh } from "./desktop-ssh"; +import { makeDesktopSsh, SAVED_HOSTS_UNAVAILABLE } from "./desktop-ssh"; function fakeConnected(target: SshTarget): SshConnectedEnvironment { return { @@ -57,14 +57,17 @@ describe("DesktopSsh saved hosts", () => { it("returns no hosts when the persist file is missing", async () => { const saved = await withSsh((dir) => Effect.gen(function* () { - const ssh = yield* makeDesktopSsh({ persistPath: path.join(dir, "ssh-environments.json") }); - return yield* ssh.listSaved; + const file = path.join(dir, "ssh-environments.json"); + const ssh = yield* makeDesktopSsh({ persistPath: file }); + const fs = yield* FileSystem.FileSystem; + return { saved: yield* ssh.listSaved, exists: yield* fs.exists(file) }; }), ); - expect(saved).toEqual([]); + expect(saved.saved).toEqual([]); + expect(saved.exists).toBe(false); }); - it("leaves an invalid version-1 file untouched", async () => { + it("starts when a version-1 file is invalid and leaves it untouched", async () => { const fileText = JSON.stringify({ version: 1, data: { @@ -85,16 +88,25 @@ describe("DesktopSsh saved hosts", () => { const fs = yield* FileSystem.FileSystem; const file = path.join(dir, "ssh-environments.json"); yield* fs.writeFileString(file, fileText); - const opened = yield* makeDesktopSsh({ persistPath: file }).pipe(Effect.flip); - return { opened, raw: yield* fs.readFileString(file) }; + const ssh = yield* makeDesktopSsh({ persistPath: file }); + const listed = yield* ssh.listSaved; + const connect = yield* ssh.connect("alice@example.com").pipe(Effect.flip); + return { + listed, + connect, + raw: yield* fs.readFileString(file), + message: ssh.savedHostsMessage, + }; }), ); - expect(error.opened._tag).toBe("SshPersistError"); + expect(error.listed).toEqual([]); + expect(error.connect.message).toBe(SAVED_HOSTS_UNAVAILABLE); + expect(error.message).toBe(SAVED_HOSTS_UNAVAILABLE); expect(error.raw).toBe(fileText); }); - it("does not adopt a pre-envelope file", async () => { + it("adopts a pre-envelope file without dropping valid hosts", async () => { const fileText = JSON.stringify({ version: 1, activeId: "abc123abc123abcd", @@ -113,12 +125,37 @@ describe("DesktopSsh saved hosts", () => { const fs = yield* FileSystem.FileSystem; const file = path.join(dir, "ssh-environments.json"); yield* fs.writeFileString(file, fileText); - const opened = yield* makeDesktopSsh({ persistPath: file }).pipe(Effect.flip); - return { opened, raw: yield* fs.readFileString(file) }; + const ssh = yield* makeDesktopSsh({ persistPath: file }); + const listed = yield* ssh.listSaved; + return { listed, raw: yield* fs.readFileString(file) }; }), ); - expect(error.opened._tag).toBe("SshPersistError"); - expect(error.raw).toBe(fileText); + expect(error.listed).toEqual([ + { + id: "abc123abc123abcd", + target: { + alias: "myserver", + hostname: "example.com", + username: "alice", + port: 22, + }, + }, + ]); + const parsed: unknown = JSON.parse(error.raw); + expect(parsed).toEqual({ + version: 1, + data: { + environments: [ + { + id: "abc123abc123abcd", + alias: "myserver", + hostname: "example.com", + username: "alice", + port: 22, + }, + ], + }, + }); }); it("remove deletes a saved host from disk", async () => { @@ -311,18 +348,19 @@ describe("DesktopSsh saved hosts", () => { } }); - it("fails to open when the saved hosts file cannot be written", async () => { + it("fails connect when the saved hosts file cannot be written", async () => { const error = await withSsh((dir) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const blocker = path.join(dir, "not-a-directory"); yield* fs.writeFileString(blocker, "x"); - return yield* makeDesktopSsh({ + const ssh = yield* makeDesktopSsh({ persistPath: path.join(blocker, "ssh-environments.json"), resolveInput: (raw) => Effect.succeed(parseSshInput(raw)), connectEnvironment: (target) => Effect.succeed(fakeConnected(target)), loadEnvironmentId: () => Effect.succeed("env-1"), - }).pipe(Effect.flip); + }); + return yield* ssh.connect("alice@example.com").pipe(Effect.flip); }), ); expect(error._tag).toBe("SshPersistError"); diff --git a/apps/desktop/src/main/ssh/desktop-ssh.ts b/apps/desktop/src/main/ssh/desktop-ssh.ts index b14c2c9a0..b01d8a58e 100644 --- a/apps/desktop/src/main/ssh/desktop-ssh.ts +++ b/apps/desktop/src/main/ssh/desktop-ssh.ts @@ -18,7 +18,18 @@ import { type SshHostDiscoveryError, type SshTarget, } from "@getpie/ssh"; -import { Context, Data, Effect, FileSystem, Layer, Ref, Schema, Scope, Semaphore } from "effect"; +import { + Context, + Data, + Effect, + Exit, + FileSystem, + Layer, + Ref, + Schema, + Scope, + Semaphore, +} from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { LoginShellEnvironment } from "../server/login-shell-environment"; @@ -39,6 +50,9 @@ export type { const SAVED_FILE_MODE = 0o600; const ENVIRONMENT_ID_TIMEOUT_MS = 8_000; +export const SAVED_HOSTS_UNAVAILABLE = + "Saved SSH hosts could not be read. The file was left unchanged."; + export class SshPersistError extends Data.TaggedError("SshPersistError")<{ readonly message: string; readonly cause?: unknown; @@ -75,6 +89,8 @@ type SshConnection = DesktopSshConnectResult["connection"]; export type DesktopSshShape = { readonly client: SshClientAvailability; readonly listSaved: Effect.Effect; + /** Set when the hosts file cannot be read. Desktop still starts. */ + readonly savedHostsMessage?: string; readonly connect: ( raw: string, ) => Effect.Effect; @@ -139,6 +155,41 @@ const SavedEnvironmentRecord = Schema.Struct({ const SavedEnvironmentsSchema = Schema.Struct({ environments: Schema.Array(SavedEnvironmentRecord), }); +const LegacySavedFile = Schema.Struct({ + version: Schema.optionalKey(Schema.Int), + activeId: Schema.optionalKey(Schema.String), + environments: Schema.Array(Schema.Unknown), +}); +const LegacyHost = Schema.Struct({ + id: Schema.NonEmptyString, + alias: Schema.NonEmptyString, + hostname: Schema.NonEmptyString, + username: Schema.optionalKey(Schema.NullOr(Schema.String)), + port: Schema.optionalKey(Schema.NullOr(Schema.Int)), +}); + +const adoptLegacyHosts = (file: { readonly environments: readonly unknown[] }) => ({ + environments: file.environments.flatMap((raw) => { + const decoded = Schema.decodeUnknownExit(LegacyHost)(raw); + if (Exit.isFailure(decoded)) return []; + return [ + { + id: decoded.value.id, + alias: decoded.value.alias, + hostname: decoded.value.hostname, + username: decoded.value.username ?? null, + port: decoded.value.port ?? null, + }, + ]; + }), +}); + +const currentEnvelope = Schema.Struct({ + version: Schema.Literal(1), + data: SavedEnvironmentsSchema, +}); + +const unavailableError = () => new SshPersistError({ message: SAVED_HOSTS_UNAVAILABLE }); const toSaved = (record: typeof SavedEnvironmentRecord.Type): SavedSshEnvironment => ({ id: record.id, @@ -176,7 +227,7 @@ export function makeDesktopSsh(input: { ) => Effect.Effect; }): Effect.Effect< DesktopSsh["Service"], - SshPersistError, + never, Scope.Scope | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner > { return Effect.gen(function* () { @@ -186,39 +237,66 @@ export function makeDesktopSsh(input: { >(); const filePath = input.persistPath; const fs = yield* FileSystem.FileSystem; - const saved = yield* makeJsonDocument({ + const documentOptions = { path: filePath, schema: SavedEnvironmentsSchema, defaults: { environments: [] }, - }).pipe(Effect.mapError((cause) => persistError(filePath, cause))); - // ponytail: library write does not take a mode; pin 0600 after open and each save. - const pinMode = fs - .chmod(filePath, SAVED_FILE_MODE) - .pipe(Effect.mapError((cause) => persistError(filePath, cause))); - yield* pinMode; + seedMissing: false, + legacy: { schema: LegacySavedFile, migrate: adoptLegacyHosts }, + } as const; + const exists = yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)); + const opened = exists ? yield* Effect.exit(makeJsonDocument(documentOptions)) : undefined; + let saved = opened !== undefined && opened._tag === "Success" ? opened.value : undefined; + const unavailable = opened !== undefined && opened._tag === "Failure"; + if (saved !== undefined) { + const raw = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); + let current = false; + try { + const parsed: unknown = JSON.parse(raw); + current = Exit.isSuccess(Schema.decodeUnknownExit(currentEnvelope)(parsed)); + } catch { + current = false; + } + if (!current) yield* fs.chmod(filePath, SAVED_FILE_MODE).pipe(Effect.ignore); + } const liveRef = yield* Ref.make(new Map()); const persistGate = yield* Semaphore.make(1); const cli = { env: input.env }; const client = yield* probeSshClient(cli); const loadEnvironmentId = input.loadEnvironmentId ?? fetchEnvironmentId; + const pinMode = fs.chmod(filePath, SAVED_FILE_MODE).pipe(Effect.ignore); + const hosts = saved === undefined ? [] : (yield* saved.get).environments.map(toSaved); + + const requireSaved = Effect.gen(function* () { + if (unavailable) return yield* unavailableError(); + if (saved !== undefined) return saved; + const created = yield* makeJsonDocument(documentOptions).pipe( + Effect.mapError((cause) => persistError(filePath, cause)), + ); + saved = created; + return created; + }); const modifySaved = ( mutate: (environments: readonly SavedSshEnvironment[]) => readonly SavedSshEnvironment[], ) => persistGate.withPermit( - saved - .update((current) => ({ - environments: mutate(current.environments.map(toSaved)).map(fromSaved), - })) - .pipe( - Effect.tapError(() => - Effect.logError("ssh.environments.persist.failed").pipe( - Effect.annotateLogs({ path: filePath }), + Effect.gen(function* () { + const document = yield* requireSaved; + yield* document + .update((current) => ({ + environments: mutate(current.environments.map(toSaved)).map(fromSaved), + })) + .pipe( + Effect.tapError(() => + Effect.logError("ssh.environments.persist.failed").pipe( + Effect.annotateLogs({ path: filePath }), + ), ), - ), - Effect.andThen(pinMode), - Effect.mapError((cause) => persistError(filePath, cause)), - ), + Effect.andThen(pinMode), + Effect.mapError((cause) => persistError(filePath, cause)), + ); + }), ); const resultFromLive = (live: LiveSshSession): DesktopSshConnectResult => ({ @@ -263,9 +341,14 @@ export function makeDesktopSsh(input: { return DesktopSsh.of({ client, - listSaved: saved.get.pipe(Effect.map((state) => state.environments.map(toSaved))), + savedHostsMessage: unavailable ? SAVED_HOSTS_UNAVAILABLE : undefined, + listSaved: Effect.gen(function* () { + if (saved === undefined) return hosts; + return (yield* saved.get).environments.map(toSaved); + }), connect: (raw) => Effect.gen(function* () { + if (unavailable) return yield* unavailableError(); const missingMessage = client.available ? undefined : client.message; if (missingMessage !== undefined && input.connectEnvironment === undefined) { return yield* new SshClientMissingError({ @@ -308,7 +391,8 @@ export function makeDesktopSsh(input: { ); return resultFromLive(live); } - yield* saved + const document = yield* requireSaved; + yield* document .update((current) => ({ environments: [ ...current.environments.filter((entry) => entry.id !== id), diff --git a/apps/desktop/src/main/startup-failure.test.ts b/apps/desktop/src/main/startup-failure.test.ts index 96fb44376..62990e19d 100644 --- a/apps/desktop/src/main/startup-failure.test.ts +++ b/apps/desktop/src/main/startup-failure.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { ProtocolRegistrationError } from "./electron/app-protocol"; -import { SshPersistError } from "./ssh/desktop-ssh"; import { formatStartupFailure } from "./startup-failure"; describe("formatStartupFailure", () => { @@ -11,11 +10,4 @@ describe("formatStartupFailure", () => { ); expect(message).toContain("internal protocol"); }); - - it("describes an unreadable SSH hosts file", () => { - const message = formatStartupFailure( - new SshPersistError({ message: "Failed to persist SSH environments to /tmp/ssh.json." }), - ); - expect(message).toContain("saved SSH hosts"); - }); }); diff --git a/apps/desktop/src/main/startup-failure.ts b/apps/desktop/src/main/startup-failure.ts index 6ca87b172..d21c35f78 100644 --- a/apps/desktop/src/main/startup-failure.ts +++ b/apps/desktop/src/main/startup-failure.ts @@ -1,17 +1,13 @@ import type { Config } from "effect"; import type { ProtocolRegistrationError } from "./electron/app-protocol"; -import type { SshPersistError } from "./ssh/desktop-ssh"; /** Turn a typed shell startup failure into the user-facing error dialog body. */ export function formatStartupFailure( - error: ProtocolRegistrationError | Config.ConfigError | SshPersistError, + error: ProtocolRegistrationError | Config.ConfigError, ): string { if (error._tag === "ConfigError") { return `The desktop could not read its configuration.\n\n${error.message}`; } - if (error._tag === "SshPersistError") { - return `The desktop could not read saved SSH hosts.\n\n${error.message}`; - } return `The desktop could not register its internal protocol.\n\n${error.message}`; } diff --git a/apps/desktop/src/shared/desktop-rpc.ts b/apps/desktop/src/shared/desktop-rpc.ts index 9198c1c6a..a5e547926 100644 --- a/apps/desktop/src/shared/desktop-rpc.ts +++ b/apps/desktop/src/shared/desktop-rpc.ts @@ -45,6 +45,7 @@ export const EnvironmentSnapshotSchema = Schema.Struct({ revision: Schema.Natural, connecting: Schema.Array(ConnectingSshHostSchema), remotes: Schema.Array(SshRemoteEnvironmentSchema), + hostsError: Schema.optionalKey(Schema.String), }); export type EnvironmentSnapshot = typeof EnvironmentSnapshotSchema.Type; diff --git a/docs/host-persistence.md b/docs/host-persistence.md index 5d0f5656d..8bcc0bc7b 100644 --- a/docs/host-persistence.md +++ b/docs/host-persistence.md @@ -121,17 +121,17 @@ Renderer `localStorage pie:theme` remains a FOUC cache of `appearance.theme` (se ### Saved SSH hosts -| Property | Current contract | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| Path | `$PIE_HOME/storage/ssh-environments.json` | -| Owner | Desktop `DesktopSsh`. The path is declared in `paths.ts`; the CLI does not read or write it. | -| Data | Version 1 `{ environments: [{ id, alias, hostname, username, port }] }`. Saved targets only. No token, no live tunnel. | -| Write points | First open seeds `[]`. Connect and remove rewrite the array. | -| Compatibility | Current `{ version, data }` envelope only. A pre-envelope file, corrupt JSON, invalid entry, or newer version fails and is left untouched. | -| Extension | Stay on version 1 until a field change requires an explicit migration. | -| Retention | Retained with `$PIE_HOME`. Remove deletes one array entry. No uninstall cleanup. | -| Permissions | `0600`, pinned after open and after each save. | -| Atomicity | `makeJsonDocument`: sibling temp file, then rename. Process-local write lock. | +| Property | Current contract | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Path | `$PIE_HOME/storage/ssh-environments.json` | +| Owner | Desktop `DesktopSsh`. The path is declared in `paths.ts`; the CLI does not read or write it. | +| Data | Version 1 `{ environments: [{ id, alias, hostname, username, port }] }`. Saved targets only. No token, no live tunnel. | +| Write points | Missing file is not created at startup. Connect and remove write the array. A pre-envelope file is rewritten as version 1 on first read. | +| Compatibility | Pre-envelope `{ version, activeId, environments }` is adopted. Valid hosts are kept; `activeId` is dropped. Corrupt JSON, a version-1 file that fails the schema, or a newer version is left untouched. Desktop still starts; SSH hosts are unavailable and show a fixed error. | +| Extension | Stay on version 1 until a field change requires an explicit migration. | +| Retention | Retained with `$PIE_HOME`. Remove deletes one array entry. No uninstall cleanup. | +| Permissions | `0600` after a write (adoption or mutation). A read of the current envelope does not change the file. | +| Atomicity | `makeJsonDocument`: sibling temp file, then rename. Process-local write lock. Missing files are not seeded. | ### Allocated project folders diff --git a/packages/effect-json-store/src/document.ts b/packages/effect-json-store/src/document.ts index d467f8181..0258f814d 100644 --- a/packages/effect-json-store/src/document.ts +++ b/packages/effect-json-store/src/document.ts @@ -60,6 +60,11 @@ export interface JsonDocumentOptions< readonly legacy?: MigrationStep; /** Seed value written when the file does not exist yet. Treated as immutable. */ readonly defaults: Latest["Type"]; + /** + * When false, a missing file stays missing and `defaults` live only in memory + * until `set` or `update`. Defaults to true. + */ + readonly seedMissing?: boolean; } /** @@ -105,7 +110,9 @@ export const makeJsonDocument = < .pipe( Effect.flatMap((value) => value === undefined - ? codec.save(file, defaults).pipe(Effect.as(defaults)) + ? options.seedMissing === false + ? Effect.succeed(defaults) + : codec.save(file, defaults).pipe(Effect.as(defaults)) : isDocumentValue(value) ? Effect.succeed(value) : Effect.die(new TypeError("json document codec returned an invalid value")), diff --git a/packages/effect-json-store/test/document.test.ts b/packages/effect-json-store/test/document.test.ts index fa3aaf10c..d984bb94f 100644 --- a/packages/effect-json-store/test/document.test.ts +++ b/packages/effect-json-store/test/document.test.ts @@ -23,6 +23,25 @@ it.effect("seeds a missing file with defaults immediately, creating parent direc ), ); +it.effect("does not create a missing file when seeding is disabled", () => + withTmp((dir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const file = path.join(dir, "config.json"); + const store = yield* makeJsonDocument({ + path: file, + schema, + defaults, + seedMissing: false, + }); + assert.deepEqual(yield* store.get, defaults); + assert.equal(yield* fs.exists(file), false); + yield* store.set({ theme: "dark", count: 1 }); + assert.equal(yield* fs.exists(file), true); + }), + ), +); + it.effect("set persists atomically and a reopened store reads the value back", () => withTmp((dir) => Effect.gen(function* () { From 04dfcb2985b7f3e65efda122060b80773c1cc630 Mon Sep 17 00:00:00 2001 From: dinq Date: Wed, 7 Oct 2026 09:48:49 -0400 Subject: [PATCH 4/6] fix(desktop): omit empty hostsError and pin SSH host file mode An explicit undefined hostsError failed RPC output validation after a list update. Every hosts-file write now passes 0600 to the atomic writer. --- .../application/desktop-application.test.ts | 12 +++++++--- .../main/application/desktop-application.ts | 24 ++++++++++++------- apps/desktop/src/main/ssh/desktop-ssh.test.ts | 4 +++- apps/desktop/src/main/ssh/desktop-ssh.ts | 20 +--------------- packages/effect-json-store/src/codec.ts | 3 ++- packages/effect-json-store/src/document.ts | 14 ++++++----- 6 files changed, 38 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/main/application/desktop-application.test.ts b/apps/desktop/src/main/application/desktop-application.test.ts index 2baa9af76..8c53bfd86 100644 --- a/apps/desktop/src/main/application/desktop-application.test.ts +++ b/apps/desktop/src/main/application/desktop-application.test.ts @@ -1,7 +1,11 @@ -import { Deferred, Effect, Option, Scope, Stream, SubscriptionRef } from "effect"; +import { Deferred, Effect, Option, Schema, Scope, Stream, SubscriptionRef } from "effect"; import { describe, expect, it } from "vitest"; -import type { ServerConnection, ServerStatusSnapshot } from "../../shared/desktop-rpc"; +import { + EnvironmentSnapshotSchema, + type ServerConnection, + type ServerStatusSnapshot, +} from "../../shared/desktop-rpc"; import type { LocalServer } from "../server/local-server"; import { disabledDesktopSsh, SshHostDiscoveryError } from "../ssh/desktop-ssh"; import { disabledDesktopTailscale } from "../tailscale/desktop-tailscale"; @@ -162,7 +166,8 @@ describe("DesktopApplication", () => { await expect(Effect.runPromise(h.application.serverConnection)).resolves.toEqual( localConnection, ); - await expect(Effect.runPromise(h.application.environmentSnapshot)).resolves.toMatchObject({ + const snapshot = await Effect.runPromise(h.application.environmentSnapshot); + expect(snapshot).toMatchObject({ connecting: [], remotes: [ { @@ -172,6 +177,7 @@ describe("DesktopApplication", () => { }, ], }); + expect(Schema.encodeSync(EnvironmentSnapshotSchema)(snapshot)).not.toHaveProperty("hostsError"); }); it("reports a missing OpenSSH client on bootstrap", async () => { diff --git a/apps/desktop/src/main/application/desktop-application.ts b/apps/desktop/src/main/application/desktop-application.ts index 5e25ba3f4..65df95c8d 100644 --- a/apps/desktop/src/main/application/desktop-application.ts +++ b/apps/desktop/src/main/application/desktop-application.ts @@ -70,12 +70,13 @@ export type DesktopApplicationDependencies = { }; function emptySnapshot(hostsError?: string): EnvironmentSnapshot { - return { + const snapshot = { revision: 0, connecting: [], remotes: [], - ...(hostsError === undefined ? undefined : { hostsError }), }; + if (hostsError === undefined) return snapshot; + return { ...snapshot, hostsError }; } export function makeDesktopApplication({ @@ -93,11 +94,14 @@ export function makeDesktopApplication({ const updateEnvironments = ( updater: (current: EnvironmentSnapshot) => Omit, ): Effect.Effect => - SubscriptionRef.updateAndGet(environmentsRef, (current) => ({ - hostsError: current.hostsError, - ...updater(current), - revision: current.revision + 1, - })); + SubscriptionRef.updateAndGet(environmentsRef, (current) => { + const next = { + ...updater(current), + revision: current.revision + 1, + }; + if (current.hostsError === undefined) return next; + return { ...next, hostsError: current.hostsError }; + }); const dropRemoteIfCurrent = (remote: SshRemoteEnvironment) => SubscriptionRef.updateAndGet(environmentsRef, (current) => { @@ -105,11 +109,13 @@ export function makeDesktopApplication({ if (existing === undefined || existing.connection !== remote.connection) { return current; } - return { - ...current, + const next = { + connecting: current.connecting, remotes: current.remotes.filter((entry) => entry.id !== remote.id), revision: current.revision + 1, }; + if (current.hostsError === undefined) return next; + return { ...next, hostsError: current.hostsError }; }); const connectSsh = (target: string, options?: { readonly background?: boolean }) => diff --git a/apps/desktop/src/main/ssh/desktop-ssh.test.ts b/apps/desktop/src/main/ssh/desktop-ssh.test.ts index 23ef613fb..f4fa7946d 100644 --- a/apps/desktop/src/main/ssh/desktop-ssh.test.ts +++ b/apps/desktop/src/main/ssh/desktop-ssh.test.ts @@ -127,7 +127,8 @@ describe("DesktopSsh saved hosts", () => { yield* fs.writeFileString(file, fileText); const ssh = yield* makeDesktopSsh({ persistPath: file }); const listed = yield* ssh.listSaved; - return { listed, raw: yield* fs.readFileString(file) }; + const info = yield* fs.stat(file); + return { listed, raw: yield* fs.readFileString(file), mode: (info.mode ?? 0) & 0o777 }; }), ); expect(error.listed).toEqual([ @@ -156,6 +157,7 @@ describe("DesktopSsh saved hosts", () => { ], }, }); + expect(error.mode).toBe(0o600); }); it("remove deletes a saved host from disk", async () => { diff --git a/apps/desktop/src/main/ssh/desktop-ssh.ts b/apps/desktop/src/main/ssh/desktop-ssh.ts index 3db67f32c..1e24f1e40 100644 --- a/apps/desktop/src/main/ssh/desktop-ssh.ts +++ b/apps/desktop/src/main/ssh/desktop-ssh.ts @@ -184,11 +184,6 @@ const adoptLegacyHosts = (file: { readonly environments: readonly unknown[] }) = }), }); -const currentEnvelope = Schema.Struct({ - version: Schema.Literal(1), - data: SavedEnvironmentsSchema, -}); - const unavailableError = () => new SshPersistError({ message: SAVED_HOSTS_UNAVAILABLE }); const toSaved = (record: typeof SavedEnvironmentRecord.Type): SavedSshEnvironment => ({ @@ -242,29 +237,18 @@ export function makeDesktopSsh(input: { schema: SavedEnvironmentsSchema, defaults: { environments: [] }, seedMissing: false, + mode: SAVED_FILE_MODE, legacy: { schema: LegacySavedFile, migrate: adoptLegacyHosts }, } as const; const exists = yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)); const opened = exists ? yield* Effect.exit(makeJsonDocument(documentOptions)) : undefined; let saved = opened !== undefined && opened._tag === "Success" ? opened.value : undefined; const unavailable = opened !== undefined && opened._tag === "Failure"; - if (saved !== undefined) { - const raw = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); - let current = false; - try { - const parsed: unknown = JSON.parse(raw); - current = Exit.isSuccess(Schema.decodeUnknownExit(currentEnvelope)(parsed)); - } catch { - current = false; - } - if (!current) yield* fs.chmod(filePath, SAVED_FILE_MODE).pipe(Effect.ignore); - } const liveRef = yield* Ref.make(new Map()); const persistGate = yield* Semaphore.make(1); const cli = { env: input.env }; const client = yield* probeSshClient(cli); const loadEnvironmentId = input.loadEnvironmentId ?? fetchEnvironmentId; - const pinMode = fs.chmod(filePath, SAVED_FILE_MODE).pipe(Effect.ignore); const hosts = saved === undefined ? [] : (yield* saved.get).environments.map(toSaved); const requireSaved = Effect.gen(function* () { @@ -293,7 +277,6 @@ export function makeDesktopSsh(input: { Effect.annotateLogs({ path: filePath }), ), ), - Effect.andThen(pinMode), Effect.mapError((cause) => persistError(filePath, cause)), ); }), @@ -405,7 +388,6 @@ export function makeDesktopSsh(input: { Effect.annotateLogs({ path: filePath }), ), ), - Effect.andThen(pinMode), Effect.mapError((cause) => persistError(filePath, cause)), ); yield* adoptLive({ id, target, environmentId, connected }); diff --git a/packages/effect-json-store/src/codec.ts b/packages/effect-json-store/src/codec.ts index 3d034a5c9..a1cce5d2b 100644 --- a/packages/effect-json-store/src/codec.ts +++ b/packages/effect-json-store/src/codec.ts @@ -63,6 +63,7 @@ export const makeFileCodec = ( schema: AnySchema, migrations: ReadonlyArray>, legacy?: MigrationStep, + mode?: number, ): FileCodec => { const latestVersion = migrations.length + 1; @@ -77,7 +78,7 @@ export const makeFileCodec = ( try: () => `${JSON.stringify(envelope, null, 2)}\n`, catch: (cause) => cause, }); - yield* writeFileAtomic(fs, file, text); + yield* writeFileAtomic(fs, file, text, mode === undefined ? undefined : { mode }); }, (effect, file) => effect.pipe(Effect.mapError((cause) => new JsonStoreWriteError({ file, cause }))), diff --git a/packages/effect-json-store/src/document.ts b/packages/effect-json-store/src/document.ts index 0258f814d..22c8134b1 100644 --- a/packages/effect-json-store/src/document.ts +++ b/packages/effect-json-store/src/document.ts @@ -65,15 +65,17 @@ export interface JsonDocumentOptions< * until `set` or `update`. Defaults to true. */ readonly seedMissing?: boolean; + /** Pinned after each atomic write. Omitted files keep the process umask. */ + readonly mode?: number; } /** * Open a single versioned JSON document — a standalone file with a seed value, - * loaded eagerly: a missing file is seeded with `defaults` immediately, an - * outdated file is migrated step by step in memory (each step's output is - * validated against the next version's schema) and written back once, and a - * file from a newer version fails with {@link JsonStoreVersionTooNewError} - * without ever being touched. Corrupt files fail loudly and are never reset. + * loaded eagerly. A missing file is seeded with `defaults` unless + * `seedMissing` is false, in which case the defaults stay in memory until the + * first `set` or `update`. An outdated file is migrated in memory and written + * back once. A newer file fails with {@link JsonStoreVersionTooNewError} + * without being touched. Corrupt files fail loudly and are never reset. * * For dynamic sets of keyed records, use `makeJsonCollection` instead. * @@ -103,7 +105,7 @@ export const makeJsonDocument = < const { defaults, path: file, schema } = options; const migrations = options.migrations ?? []; const fs = yield* FileSystem.FileSystem; - const codec = makeFileCodec(fs, schema, migrations, options.legacy); + const codec = makeFileCodec(fs, schema, migrations, options.legacy, options.mode); const loadFromDisk: Effect.Effect = codec .load(file) From a0eecf71a5b7ddbfee1dae2f651e33546567f08f Mon Sep 17 00:00:00 2001 From: dinq Date: Wed, 7 Oct 2026 09:54:11 -0400 Subject: [PATCH 5/6] fix(desktop): abort SSH host adoption when any legacy entry is invalid A pre-envelope file with one bad entry stays byte-for-byte unchanged. SSH hosts stay unavailable instead of dropping that entry. --- apps/desktop/src/main/ssh/desktop-ssh.test.ts | 38 +++++++++++++++++++ apps/desktop/src/main/ssh/desktop-ssh.ts | 30 ++++++++------- docs/host-persistence.md | 22 +++++------ 3 files changed, 65 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/main/ssh/desktop-ssh.test.ts b/apps/desktop/src/main/ssh/desktop-ssh.test.ts index f4fa7946d..7ec4729ff 100644 --- a/apps/desktop/src/main/ssh/desktop-ssh.test.ts +++ b/apps/desktop/src/main/ssh/desktop-ssh.test.ts @@ -160,6 +160,44 @@ describe("DesktopSsh saved hosts", () => { expect(error.mode).toBe(0o600); }); + it("does not adopt a pre-envelope file when any entry is invalid", async () => { + const fileText = JSON.stringify({ + version: 1, + activeId: "abc123abc123abcd", + environments: [ + { + id: "abc123abc123abcd", + alias: "myserver", + hostname: "example.com", + username: "alice", + port: 22, + }, + { id: "", alias: "bad" }, + ], + }); + const result = await withSsh((dir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const file = path.join(dir, "ssh-environments.json"); + yield* fs.writeFileString(file, fileText); + const ssh = yield* makeDesktopSsh({ persistPath: file }); + const listed = yield* ssh.listSaved; + const connect = yield* ssh.connect("alice@example.com").pipe(Effect.flip); + return { + listed, + connect, + raw: yield* fs.readFileString(file), + message: ssh.savedHostsMessage, + }; + }), + ); + + expect(result.listed).toEqual([]); + expect(result.connect.message).toBe(SAVED_HOSTS_UNAVAILABLE); + expect(result.message).toBe(SAVED_HOSTS_UNAVAILABLE); + expect(result.raw).toBe(fileText); + }); + it("remove deletes a saved host from disk", async () => { const remaining = await withSsh((dir) => Effect.gen(function* () { diff --git a/apps/desktop/src/main/ssh/desktop-ssh.ts b/apps/desktop/src/main/ssh/desktop-ssh.ts index 1e24f1e40..01352379f 100644 --- a/apps/desktop/src/main/ssh/desktop-ssh.ts +++ b/apps/desktop/src/main/ssh/desktop-ssh.ts @@ -168,21 +168,23 @@ const LegacyHost = Schema.Struct({ port: Schema.optionalKey(Schema.NullOr(Schema.Int)), }); -const adoptLegacyHosts = (file: { readonly environments: readonly unknown[] }) => ({ - environments: file.environments.flatMap((raw) => { +const adoptLegacyHosts = (file: { readonly environments: readonly unknown[] }) => { + const environments = []; + for (const raw of file.environments) { const decoded = Schema.decodeUnknownExit(LegacyHost)(raw); - if (Exit.isFailure(decoded)) return []; - return [ - { - id: decoded.value.id, - alias: decoded.value.alias, - hostname: decoded.value.hostname, - username: decoded.value.username ?? null, - port: decoded.value.port ?? null, - }, - ]; - }), -}); + if (Exit.isFailure(decoded)) { + throw new Error("legacy SSH host entry failed validation"); + } + environments.push({ + id: decoded.value.id, + alias: decoded.value.alias, + hostname: decoded.value.hostname, + username: decoded.value.username ?? null, + port: decoded.value.port ?? null, + }); + } + return { environments }; +}; const unavailableError = () => new SshPersistError({ message: SAVED_HOSTS_UNAVAILABLE }); diff --git a/docs/host-persistence.md b/docs/host-persistence.md index 8bcc0bc7b..84f324df5 100644 --- a/docs/host-persistence.md +++ b/docs/host-persistence.md @@ -121,17 +121,17 @@ Renderer `localStorage pie:theme` remains a FOUC cache of `appearance.theme` (se ### Saved SSH hosts -| Property | Current contract | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Path | `$PIE_HOME/storage/ssh-environments.json` | -| Owner | Desktop `DesktopSsh`. The path is declared in `paths.ts`; the CLI does not read or write it. | -| Data | Version 1 `{ environments: [{ id, alias, hostname, username, port }] }`. Saved targets only. No token, no live tunnel. | -| Write points | Missing file is not created at startup. Connect and remove write the array. A pre-envelope file is rewritten as version 1 on first read. | -| Compatibility | Pre-envelope `{ version, activeId, environments }` is adopted. Valid hosts are kept; `activeId` is dropped. Corrupt JSON, a version-1 file that fails the schema, or a newer version is left untouched. Desktop still starts; SSH hosts are unavailable and show a fixed error. | -| Extension | Stay on version 1 until a field change requires an explicit migration. | -| Retention | Retained with `$PIE_HOME`. Remove deletes one array entry. No uninstall cleanup. | -| Permissions | `0600` after a write (adoption or mutation). A read of the current envelope does not change the file. | -| Atomicity | `makeJsonDocument`: sibling temp file, then rename. Process-local write lock. Missing files are not seeded. | +| Property | Current contract | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Path | `$PIE_HOME/storage/ssh-environments.json` | +| Owner | Desktop `DesktopSsh`. The path is declared in `paths.ts`; the CLI does not read or write it. | +| Data | Version 1 `{ environments: [{ id, alias, hostname, username, port }] }`. Saved targets only. No token, no live tunnel. | +| Write points | Missing file is not created at startup. Connect and remove write the array. A fully valid pre-envelope file is rewritten as version 1 on first read. | +| Compatibility | A pre-envelope file is adopted only when every entry validates. `activeId` is dropped. One invalid entry aborts adoption and leaves the file byte-for-byte unchanged. Corrupt JSON, a version-1 file that fails the schema, a newer version, or an aborted adoption is left untouched. Desktop still starts; SSH hosts are unavailable and show a fixed error. | +| Extension | Stay on version 1 until a field change requires an explicit migration. | +| Retention | Retained with `$PIE_HOME`. Remove deletes one array entry. No uninstall cleanup. | +| Permissions | `0600` after a write (adoption or mutation). A read of the current envelope does not change the file. | +| Atomicity | `makeJsonDocument`: sibling temp file, then rename. Process-local write lock. Missing files are not seeded. | ### Allocated project folders From ca0ccfb075f800721f755b769e9e5abcc2c68cce Mon Sep 17 00:00:00 2001 From: dinq Date: Wed, 7 Oct 2026 09:58:41 -0400 Subject: [PATCH 6/6] fix(desktop): type the legacy SSH host migration list An untyped empty array was never[], so pushing a host failed typecheck. --- apps/desktop/src/main/ssh/desktop-ssh.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/main/ssh/desktop-ssh.ts b/apps/desktop/src/main/ssh/desktop-ssh.ts index 01352379f..e6efb3ee3 100644 --- a/apps/desktop/src/main/ssh/desktop-ssh.ts +++ b/apps/desktop/src/main/ssh/desktop-ssh.ts @@ -169,7 +169,7 @@ const LegacyHost = Schema.Struct({ }); const adoptLegacyHosts = (file: { readonly environments: readonly unknown[] }) => { - const environments = []; + const environments: Array = []; for (const raw of file.environments) { const decoded = Schema.decodeUnknownExit(LegacyHost)(raw); if (Exit.isFailure(decoded)) {