From f6e215ac7a8c9eb8a207f0aad58d1f919d023a82 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Wed, 19 Aug 2026 22:12:07 -0500 Subject: [PATCH 01/91] feat(coolify): set up a Coolify server over SSH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dyad can already deploy to an existing Coolify instance. This adds the step before it: pointing Dyad at a bare Linux server and getting a working, signed-in Coolify onto it. The user provides an address, an email, and optionally a domain they own. Dyad shows a public key to install on the server, then connects, checks the machine, runs Coolify's installer, waits for the dashboard, ensures an admin account exists, tries to put the instance on HTTPS, and mints an API token for the existing deploy flow. A failure reports what the server said rather than an exit code. Without a domain, HTTPS goes through sslip.io. With one, Dyad checks it resolves to the server before applying it, since Coolify will not issue a certificate for a name that does not point at it. An address that cannot have a certificate at all — loopback, private, or IPv6 — finishes on plain HTTP and says so. A Coolify too old to mint a token finishes too, handing over the sign-in details instead. **Several setup steps drive Coolify's internals rather than a supported interface, because no supported interface exists.** Coolify has no way to enable API access, mint a token, create or find the first user, set the instance domain, or state its version before its API is reachable — so each of those runs a short PHP script through `php artisan tinker` in the Coolify container. This is the least durable part of the PR: it depends on model and config names that Coolify is free to change. Every one of these call sites is marked WORKAROUND with a TODO naming what an official API would replace, and the hope is to delete them as Coolify grows real support. The setup runs as a state machine in the main process, per rules/state-machines.md, so an install survives leaving the panel. Covered by unit tests, integration tests driving the real flow against a real ssh2 server, and two Playwright tests. **This PR adds `ssh2` (`^1.17.0`) as a runtime dependency of the desktop app**, along with `@types/ssh2` as a dev dependency. It is the only new runtime dependency, and it holds the private key and sees the admin password, so it is worth a deliberate look. Why a library rather than shelling out to `ssh`: - No assumption that an `ssh` binary exists, is on PATH, and behaves the same on Windows, macOS and Linux. - The private key stays in memory. Shelling out means writing it to a temp file with the right permissions and removing it on every failure path. - Failures arrive as values. Telling an auth rejection from an unreachable host by parsing stderr breaks the first time the wording changes. - Host key verification happens in process, before any credential is sent. - Commands stream output, end with an exit status, and can be aborted, with no PTY to scrape. - Scripts go over stdin, so there is no shell quoting layer to get wrong. On supply chain: - `ssh2` is long established, pure JavaScript at its core, with two small runtime dependencies (`asn1`, `bcrypt-pbkdf`). Its native pieces (`cpu-features`, `nan`) are optional and installs proceed without them. - `package-lock.json` pins 1.17.0 with a sha512 integrity hash, and CI installs from the lockfile. The caret matters only on a deliberate update. - Releases are infrequent — 1.15.0 in December 2023, 1.16.0 in September 2024, 1.17.0 in August 2025 — so there is little pressure to move off the pin. That is not a guarantee. If the dependency ever has to go, every SSH call goes through src/ipc/utils/ssh_client.ts behind `connectSsh`, `run` and `end`, so reimplementing it over the system `ssh` binary would not touch the flow, the state machine, or the UI. Not included: IPv6 addresses install but get no certificate; registering further servers from inside Dyad; setting a wildcard domain on the server, so deployed apps get names under it instead of sslip.io addresses — Dyad already reads one when Coolify has it configured. Co-Authored-By: Claude Opus 5 --- e2e-tests/coolify_deploy.spec.ts | 2 + e2e-tests/coolify_setup.spec.ts | 165 +++++ e2e-tests/helpers/fake_ssh_server.ts | 283 ++++++++ forge.config.ts | 20 +- package-lock.json | 100 +++ package.json | 2 + src/components/CoolifyConnector.test.tsx | 323 ++++++++- src/components/CoolifyConnector.tsx | 386 ++++++---- src/components/CoolifyCredentials.test.tsx | 186 +++++ src/components/CoolifyCredentials.tsx | 115 +++ src/components/CoolifyServerSetup.test.tsx | 659 ++++++++++++++++++ src/components/CoolifyServerSetup.tsx | 517 ++++++++++++++ src/coolify_setup/admin_credentials.test.ts | 47 ++ src/coolify_setup/admin_credentials.ts | 77 ++ src/coolify_setup/api_token.test.ts | 192 +++++ src/coolify_setup/api_token.ts | 234 +++++++ src/coolify_setup/capabilities.test.ts | 42 ++ src/coolify_setup/capabilities.ts | 32 + src/coolify_setup/controller.test.ts | 301 ++++++++ src/coolify_setup/controller.ts | 205 ++++++ src/coolify_setup/https_setup.test.ts | 333 +++++++++ src/coolify_setup/https_setup.ts | 304 ++++++++ src/coolify_setup/install.test.ts | 244 +++++++ src/coolify_setup/install.ts | 381 ++++++++++ src/coolify_setup/server_key.ts | 72 ++ .../setup_flow.integration.test.ts | 114 +++ src/coolify_setup/setup_flow.test.ts | 490 +++++++++++++ src/coolify_setup/setup_flow.ts | 277 ++++++++ src/coolify_setup/state.ts | 166 +++++ src/coolify_setup/tinker.test.ts | 152 ++++ src/coolify_setup/tinker.ts | 144 ++++ src/coolify_setup/transition.test.ts | 366 ++++++++++ src/coolify_setup/transition.ts | 151 ++++ .../boundary_inventory.test_support.ts | 1 + src/ipc/handlers/coolify_handlers.test.ts | 95 +++ src/ipc/handlers/coolify_handlers.ts | 78 +-- .../handlers/coolify_setup_handlers.test.ts | 545 +++++++++++++++ src/ipc/handlers/coolify_setup_handlers.ts | 290 ++++++++ src/ipc/ipc_host.ts | 2 + src/ipc/preload/channels.test.ts | 18 + src/ipc/preload/channels.ts | 6 + src/ipc/types/coolify_setup.ts | 271 +++++++ src/ipc/types/index.ts | 16 + src/ipc/utils/dns_resolve.ts | 60 ++ src/ipc/utils/ssh_client.test.ts | 559 +++++++++++++++ src/ipc/utils/ssh_client.ts | 444 ++++++++++++ src/ipc/utils/telemetry.test.ts | 21 + src/ipc/utils/telemetry.ts | 18 +- src/lib/queryKeys.ts | 6 + src/lib/schemas.ts | 26 + src/main/settings.test.ts | 32 + src/main/settings.ts | 51 ++ src/shared/coolify_admin_email.test.ts | 28 + src/shared/coolify_admin_email.ts | 28 + src/shared/coolify_domain.ts | 22 + .../domain_check.test.ts | 0 .../domain_check.ts | 8 + src/state_machines/boundaries.test.ts | 1 + .../renderer_query_invalidation.test.ts | 25 + testing/fake-llm-server/coolify.ts | 8 + vite.main.config.mts | 1 + 61 files changed, 9547 insertions(+), 195 deletions(-) create mode 100644 e2e-tests/coolify_setup.spec.ts create mode 100644 e2e-tests/helpers/fake_ssh_server.ts create mode 100644 src/components/CoolifyCredentials.test.tsx create mode 100644 src/components/CoolifyCredentials.tsx create mode 100644 src/components/CoolifyServerSetup.test.tsx create mode 100644 src/components/CoolifyServerSetup.tsx create mode 100644 src/coolify_setup/admin_credentials.test.ts create mode 100644 src/coolify_setup/admin_credentials.ts create mode 100644 src/coolify_setup/api_token.test.ts create mode 100644 src/coolify_setup/api_token.ts create mode 100644 src/coolify_setup/capabilities.test.ts create mode 100644 src/coolify_setup/capabilities.ts create mode 100644 src/coolify_setup/controller.test.ts create mode 100644 src/coolify_setup/controller.ts create mode 100644 src/coolify_setup/https_setup.test.ts create mode 100644 src/coolify_setup/https_setup.ts create mode 100644 src/coolify_setup/install.test.ts create mode 100644 src/coolify_setup/install.ts create mode 100644 src/coolify_setup/server_key.ts create mode 100644 src/coolify_setup/setup_flow.integration.test.ts create mode 100644 src/coolify_setup/setup_flow.test.ts create mode 100644 src/coolify_setup/setup_flow.ts create mode 100644 src/coolify_setup/state.ts create mode 100644 src/coolify_setup/tinker.test.ts create mode 100644 src/coolify_setup/tinker.ts create mode 100644 src/coolify_setup/transition.test.ts create mode 100644 src/coolify_setup/transition.ts create mode 100644 src/ipc/handlers/coolify_setup_handlers.test.ts create mode 100644 src/ipc/handlers/coolify_setup_handlers.ts create mode 100644 src/ipc/types/coolify_setup.ts create mode 100644 src/ipc/utils/dns_resolve.ts create mode 100644 src/ipc/utils/ssh_client.test.ts create mode 100644 src/ipc/utils/ssh_client.ts create mode 100644 src/shared/coolify_admin_email.test.ts create mode 100644 src/shared/coolify_admin_email.ts create mode 100644 src/shared/coolify_domain.ts rename src/{coolify_deploy => shared}/domain_check.test.ts (100%) rename src/{coolify_deploy => shared}/domain_check.ts (97%) diff --git a/e2e-tests/coolify_deploy.spec.ts b/e2e-tests/coolify_deploy.spec.ts index 0a112662d3..f249c9c7b5 100644 --- a/e2e-tests/coolify_deploy.spec.ts +++ b/e2e-tests/coolify_deploy.spec.ts @@ -78,6 +78,8 @@ async function connectGithubFromPublishPanel(po: any) { */ async function connectCoolify(po: any, fakeLlmPort: number) { await po.page.getByRole("tab", { name: "Your Own Server" }).click(); + // The tab opens on the installer, so switch to the paste-a-token form. + await po.page.getByTestId("coolify-setup-use-existing").click(); await po.page .getByTestId("coolify-instance-url") diff --git a/e2e-tests/coolify_setup.spec.ts b/e2e-tests/coolify_setup.spec.ts new file mode 100644 index 0000000000..ef27678190 --- /dev/null +++ b/e2e-tests/coolify_setup.spec.ts @@ -0,0 +1,165 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { expect } from "@playwright/test"; +import { testWithConfig, type ElectronConfig } from "./helpers/test_helper"; +import { Timeout } from "./helpers/constants"; +import { + startFakeSshServer, + type FakeSshServer, +} from "./helpers/fake_ssh_server"; + +/** + * Installing Coolify onto a server, through the packaged app. + * + * Two tests, both of which need real Chromium and the shipped build: the whole + * chain through to a stored token, and a server Dyad refuses. Everything that + * does not look at the screen belongs in + * src/coolify_setup/setup_flow.integration.test.ts, which drives the same SSH + * server in a fraction of the time. + * + * Not covered anywhere: whether Coolify's installer seeds an account, whether + * its seeder accepts an address, whether a certificate is issued. Those need a + * real machine. + */ + +/** + * One server per test, started before the app is. + * + * The app launches during fixture setup, so anything the app must see in its + * environment has to exist by then — which is why this runs in the pre-launch + * hook rather than in the test body. What each test wants the server to do is + * set afterwards by adjusting `state`, which the fake reads on every command. + */ +let sshServer: FakeSshServer | null = null; + +const electronConfig: ElectronConfig = { + preLaunchHook: async ({ userDataDir, fakeLlmPort }) => { + await fs.mkdir(userDataDir, { recursive: true }); + // The feature is behind an experiment, off unless the user turns it on. + await fs.writeFile( + path.join(userDataDir, "user-settings.json"), + JSON.stringify({ enableOwnServerDeployment: true }), + "utf8", + ); + + sshServer = await startFakeSshServer(); + // The form asks for an address, not a port, so both of these travel + // through the same e2e seam as every other test-only behaviour here. + process.env.DYAD_E2E_SSH_PORT = String(sshServer.port); + process.env.DYAD_E2E_DASHBOARD_PORT = String(fakeLlmPort); + // Whatever another spec left in the shared fake is not this test's setup. + await resetCoolify(fakeLlmPort); + }, +}; + +const test = testWithConfig(electronConfig); + +/** The server this test is talking to, adjustable before anything is done. */ +function server(): FakeSshServer { + if (!sshServer) throw new Error("The fake server was never started."); + return sshServer; +} + +test.afterEach(async () => { + delete process.env.DYAD_E2E_SSH_PORT; + delete process.env.DYAD_E2E_DASHBOARD_PORT; + await sshServer?.close(); + sshServer = null; +}); + +/** The fake Coolify is shared per worker, so each test says what it expects. */ +async function resetCoolify(port: number) { + await fetch(`http://localhost:${port}/coolify/test/reset`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); +} + +async function openInstaller(po: any) { + // The publish panel needs an app to be about, and the deployment section it + // carries needs a repository — Coolify deploys from one, so the tab that + // holds the installer only appears once GitHub is connected. + await po.sendPrompt("hi"); + await po.previewPanel.selectPreviewMode("publish"); + await po.githubConnector.connect(); + await po.githubConnector.createRepo(`coolify-setup-e2e-${Date.now()}`); + await po.page.getByRole("tab", { name: "Your Own Server" }).click(); + await expect(po.page.getByTestId("coolify-server-setup")).toBeVisible(); +} + +async function fillAndInstall(po: any) { + await po.page.getByTestId("coolify-setup-host").fill("127.0.0.1"); + await po.page.getByTestId("coolify-setup-email").fill("me@gmail.com"); + await po.page.getByTestId("coolify-setup-install").click(); +} + +test("installs Coolify onto a server and connects to it", async ({ po }) => { + await po.setUp({ autoApprove: true }); + await openInstaller(po); + + // The key is the one manual step: nothing can reach the server without it. + await expect(po.page.getByTestId("coolify-setup-public-key")).toContainText( + "ssh-ed25519", + ); + + await fillAndInstall(po); + + // A loopback address can never be given a certificate, so the install ends + // on plain HTTP — and the screen stays up to say so, because the token it + // stored travels over that address on every deploy. + await expect(po.page.getByTestId("coolify-setup-done")).toBeVisible({ + timeout: Timeout.LONG, + }); + await expect(po.page.getByTestId("coolify-setup-insecure")).toBeVisible(); + await expect(po.page.getByTestId("coolify-setup-done")).toContainText( + "Dyad created its own API token", + ); + + // Moving on hands the panel back with the token already stored. + await po.page.getByTestId("coolify-setup-continue").click(); + await expect(po.page.getByTestId("coolify-server-select")).toBeVisible({ + timeout: Timeout.MEDIUM, + }); + // The picker being present says only that a token was stored. This says the + // address stored with it is one Dyad can actually talk to: the servers came + // back from the instance the install pointed it at. + await po.page.getByTestId("coolify-server-select").click(); + // Named, not "the first option": while discovery is in flight the picker + // renders a "Loading servers..." row that is also an option, which would + // satisfy a looser assertion without a server ever arriving. + await expect(po.page.getByRole("option", { name: "production" })).toBeVisible( + { timeout: Timeout.MEDIUM }, + ); + + // Dyad offered a key rather than a password, and the installer really ran. + expect(server().state.keyOffered).toBe(true); + expect(server().state.commands.some((c) => c.includes("install.sh"))).toBe( + true, + ); + expect(server().state.commands.some((c) => c.includes("tinker"))).toBe(true); +}); + +test("refuses a server that already has Coolify on it", async ({ po }) => { + server().state.probe = "mem=1967\ncontainer=coolify\nbusy=no\n"; + await po.setUp({ autoApprove: true }); + await openInstaller(po); + await po.page.getByTestId("coolify-setup-host").fill("127.0.0.1"); + // Filled, so that a disabled Install button means the server was refused + // rather than that the form is incomplete. + await po.page.getByTestId("coolify-setup-email").fill("me@gmail.com"); + // Waits like every other assertion here rather than on the default five + // seconds: what it claims is that the form is complete, and how long the + // panel takes to re-render under load is not part of that claim. + await expect(po.page.getByTestId("coolify-setup-install")).toBeEnabled({ + timeout: Timeout.MEDIUM, + }); + await po.page.getByTestId("coolify-setup-inspect").click(); + + await expect(po.page.getByTestId("coolify-setup-inspection")).toContainText( + "already has Coolify", + { timeout: Timeout.MEDIUM }, + ); + // And it really refuses: the button that would install over it is disabled. + await expect(po.page.getByTestId("coolify-setup-install")).toBeDisabled(); +}); diff --git a/e2e-tests/helpers/fake_ssh_server.ts b/e2e-tests/helpers/fake_ssh_server.ts new file mode 100644 index 0000000000..39599fd3f7 --- /dev/null +++ b/e2e-tests/helpers/fake_ssh_server.ts @@ -0,0 +1,283 @@ +import ssh2 from "ssh2"; +import type { Connection } from "ssh2"; + +// Destructured from the default import: ssh2 is CommonJS, and named imports +// off it work under a bundler but not under plain Node ESM. +const { Server, utils } = ssh2; + +/** + * A server Dyad can install Coolify onto, enough of one to drive the flow. + * + * Started by the spec rather than inside the fake HTTP server, so a test can + * read what was asked of it directly instead of through a control endpoint. + * + * The install path speaks SSH rather than HTTP, so the fake Coolify beside + * this one cannot answer it. Dyad sends a small, fixed set of commands, and + * this answers them the way a real box would — down to details the parser + * depends on, like a tinker transcript echoing the script back with a "> " + * prompt before the output arrives. + * + * Nothing here validates the key it is offered. Whether Dyad's key reaches a + * server is the user's own step and there is nothing to check it against; what + * matters for a test is that a key is offered at all, which is asserted by + * refusing a connection that offers none. + */ + +/** + * An ed25519 key ssh2 will accept. + * + * Its own generator writes the public point without its leading byte when that + * byte happens to be zero — one key in 256 — and its own parser then rejects + * what it wrote. Unchecked, that is a suite which fails a few runs in a + * thousand with a parse error nothing in the diff explains. Retried rather + * than replaced with a fixed key, which would mean a private key in a public + * repository. + */ +export function generateSshKeyPair(): { private: string; public: string } { + for (let attempt = 0; attempt < 8; attempt++) { + const pair = utils.generateKeyPairSync("ed25519"); + if (!(utils.parseKey(pair.private) instanceof Error)) return pair; + } + throw new Error("ssh2 generated 8 unusable ed25519 keys in a row."); +} + +const START = "__DYAD_OUT_START__"; +const END = "__DYAD_OUT_END__"; + +interface FakeServerBehaviour { + /** What the batched probe reports. Defaults to a healthy empty machine. */ + probe?: string; + /** Exit code for install.sh. Non-zero makes the install fail. */ + installExit?: number; + /** Coolify's reported version, which decides the automatic token path. */ + version?: string; + /** + * Reports the exit status this long after closing the command's output. + * + * A real sshd sends EOF when the command's stdout closes and the exit + * status when the process is reaped, and those are not the same moment. + * Answering both in one batch — which is what this fake does otherwise — + * hides every bug that depends on the gap. + */ + exitAfterEofMs?: number; +} + +interface FakeServerState extends FakeServerBehaviour { + installed: boolean; + /** Every command the flow sent, so a test can say what was asked. */ + commands: string[]; + keyOffered: boolean; +} + +/** + * A tinker transcript, shaped the way a real one is. + * + * tinker echoes every line it is fed with a "> " prompt, and the output of a + * statement lands on the line after the prompt that produced it — so the + * opening marker arrives with a prompt attached, which is exactly what + * distinguishes it from the echo of the line that printed it. Getting this + * wrong is not cosmetic: the parser matches on that prefix. + */ +function transcript(script: string, output: string): string { + const echoed = script + .split(/\r?\n/) + .map((line) => `> ${line}`) + .join("\n"); + return [ + "Psy Shell v0.11 (PHP 8.2) by Justin Hileman", + echoed, + `> ${START}`, + output, + END, + "", + ].join("\n"); +} + +/** + * The environment a `docker exec -e NAME='value'` command carries. + * + * Modelled because the scripts read their inputs with getenv() rather than + * having them interpolated — that is the whole point of sending them that + * way — so a fake that ignores it cannot tell a script reading the right + * variable from one reading nothing at all. + */ +function environmentOf(command: string): Record { + const env: Record = {}; + const pattern = /-e ([A-Z_][A-Z0-9_]*)='([^']*)'/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(command)) !== null) { + env[match[1]] = match[2]; + } + return env; +} + +/** What the fake answers for one tinker script. */ +function answerTinker( + script: string, + env: Record, + state: FakeServerState, +): string { + if (script.includes("constants.coolify.version")) { + return state.version ?? "4.3.2"; + } + if (script.includes("is_api_enabled")) { + return "enabled"; + } + if (script.includes("->exists()")) { + // Answered about the address the script actually asked about. A script + // that read no variable, or the wrong one, gets told there is no account. + if (!env.DYAD_ADMIN_EMAIL) return "no"; + return state.installed ? "yes" : "no"; + } + if (script.includes("createToken")) { + if (!env.DYAD_ADMIN_EMAIL) return "no-user"; + // Sanctum's shape: an id, a pipe, then 40+ alphanumerics. Dyad checks + // that before storing it, so a token that merely looks token-ish is + // rejected — as a stray warning line should be. + return "1|EcaUxT43T5fgdLJmnYj0702tEUC6viy5jEhO3Ujk2298db95"; + } + if (script.includes("setupDynamicProxyConfiguration")) { + return "applied"; + } + return ""; +} + +function answer( + command: string, + stdin: string, + state: FakeServerState, +): { + stdout: string; + code: number; +} { + state.commands.push(command); + + if (command.includes("MemTotal")) { + return { + stdout: + state.probe ?? + `mem=1967\ncontainer=${state.installed ? "coolify" : ""}\nbusy=no\n`, + code: 0, + }; + } + if (command.includes("install.sh")) { + const code = state.installExit ?? 0; + if (code === 0) state.installed = true; + return { + stdout: "1/6 Installing Docker...\n6/6 Coolify is up\n", + code, + }; + } + if (command.includes("db:seed")) { + return { stdout: "Seeding: RootUserSeeder\n", code: 0 }; + } + if (command.includes("tinker")) { + // The script arrives on stdin, which is the whole reason it does: a + // script on a command line has to survive a shell as well as PHP. + return { + stdout: transcript( + stdin, + answerTinker(stdin, environmentOf(command), state), + ), + code: 0, + }; + } + return { stdout: "", code: 0 }; +} + +export interface FakeSshServer { + port: number; + state: FakeServerState; + close: () => Promise; +} + +export async function startFakeSshServer(): Promise { + const state: FakeServerState = { + installed: false, + commands: [], + keyOffered: false, + }; + + const open = new Set(); + const server = new Server( + // Generated per server: a fixed key checked into the repo would be a + // private key in a public repository, however inert. + { hostKeys: [generateSshKeyPair().private] }, + (client: Connection) => { + open.add(client); + client.on("close", () => open.delete(client)); + client.on("error", () => open.delete(client)); + client.on("authentication", (ctx) => { + if (ctx.method === "publickey") { + state.keyOffered = true; + ctx.accept(); + return; + } + // Anything else is refused, so a test can prove a key was offered. + ctx.reject(["publickey"]); + }); + + client.on("ready", () => { + client.on("session", (accept) => { + const session = accept(); + session.on("exec", (acceptExec, _reject, info) => { + const stream = acceptExec(); + let stdin = ""; + stream.on("data", (chunk: Buffer) => { + stdin += chunk.toString("utf8"); + }); + const finish = () => { + const { stdout, code } = answer(info.command, stdin, state); + stream.write(stdout); + if (!state.exitAfterEofMs) { + stream.exit(code); + stream.end(); + return; + } + // Output first, then the status, with a gap — a client that + // closes the channel on EOF loses the status, and against a real + // sshd would kill a command that was still running. Sent through + // the protocol because ssh2's own exit() refuses once the write + // side is done, which is not a restriction sshd has. + stream.eof(); + setTimeout(() => { + const inner = stream as unknown as { + _client: { + _protocol: { exitStatus(id: number, c: number): void }; + }; + outgoing: { id: number }; + }; + inner._client._protocol.exitStatus(inner.outgoing.id, code); + stream.close(); + }, state.exitAfterEofMs); + }; + // Answered when stdin closes, which the client always does — + // with the script for the commands that take one, and empty for + // the rest. Waiting on that rather than on a timer is why this + // cannot answer a question it has not finished reading. + stream.on("end", finish); + }); + }); + }); + }, + ); + + const port = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + resolve((server.address() as { port: number }).port); + }); + }); + + return { + port, + state, + close: () => + new Promise((resolve) => { + // Closed as well as stopped: a server with a connection still open + // never finishes closing, and the wait lands in afterEach. + for (const client of open) client.end(); + open.clear(); + server.close(() => resolve()); + }), + }; +} diff --git a/forge.config.ts b/forge.config.ts index fde761d71f..40e91dbeb4 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -44,8 +44,19 @@ const pgRuntimeDependencies = [ "xtend", ] as const; -function isPgRuntimeDependency(file: string): boolean { - return pgRuntimeDependencies.some((dependency) => { +const ssh2RuntimeDependencies = [ + "ssh2", + "asn1", + "safer-buffer", + "bcrypt-pbkdf", + "tweetnacl", +] as const; + +function isRuntimeDependency( + file: string, + packages: readonly string[], +): boolean { + return packages.some((dependency) => { const modulePath = `/node_modules/${dependency}`; return file === modulePath || file.startsWith(`${modulePath}/`); }); @@ -87,6 +98,9 @@ const ignore = (file: string) => { if (file.startsWith("/node_modules/node-pty")) { return false; } + if (isRuntimeDependency(file, ssh2RuntimeDependencies)) { + return false; + } if (file.startsWith("/node_modules/mustardscript")) { return false; } @@ -111,7 +125,7 @@ const ignore = (file: string) => { if (file.startsWith("/node_modules/@typescript/old")) { return false; } - if (isPgRuntimeDependency(file)) { + if (isRuntimeDependency(file, pgRuntimeDependencies)) { return false; } if (file === "/node_modules/ws" || file.startsWith("/node_modules/ws/")) { diff --git a/package-lock.json b/package-lock.json index bde5c7404e..73ab556fe1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -92,6 +92,7 @@ "shell-env": "^4.0.1", "shiki": "^3.2.1", "sonner": "^2.0.3", + "ssh2": "^1.17.0", "stacktrace-js": "^2.0.2", "tailwind-merge": "^3.1.0", "tailwindcss": "^4.1.3", @@ -132,6 +133,7 @@ "@types/pg": "^8.20.0", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", + "@types/ssh2": "^1.15.5", "@types/use-sync-external-store": "^0.0.6", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^5.62.0", @@ -8121,6 +8123,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -9389,6 +9418,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -9572,6 +9610,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", @@ -9843,6 +9890,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -10636,6 +10692,20 @@ "node": ">= 0.10" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -18289,6 +18359,13 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -21804,6 +21881,23 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/ssri": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", @@ -22870,6 +22964,12 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 8d7c14d947..b861682d4d 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "shell-env": "^4.0.1", "shiki": "^3.2.1", "sonner": "^2.0.3", + "ssh2": "^1.17.0", "stacktrace-js": "^2.0.2", "tailwind-merge": "^3.1.0", "tailwindcss": "^4.1.3", @@ -190,6 +191,7 @@ "@types/pg": "^8.20.0", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", + "@types/ssh2": "^1.15.5", "@types/use-sync-external-store": "^0.0.6", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^5.62.0", diff --git a/src/components/CoolifyConnector.test.tsx b/src/components/CoolifyConnector.test.tsx index 7276bd6f52..d5651df8bb 100644 --- a/src/components/CoolifyConnector.test.tsx +++ b/src/components/CoolifyConnector.test.tsx @@ -1,3 +1,6 @@ +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/queryKeys"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -9,6 +12,39 @@ const toastMock = vi.hoisted(() => ({ })); vi.mock("sonner", () => ({ toast: toastMock })); +// Stubbed so these tests stay about the connector. The real one fetches +// secrets through its own mutation, which would drag a query client into every +// case here for something none of them are checking. +vi.mock("@/components/CoolifyCredentials", () => ({ + CoolifyCredentials: ({ showTitle }: { showTitle?: boolean }) => ( +
+ {showTitle ? "Previous Coolify connection" : null} +
+ ), +})); + +// The real installer fetches a key and runs mutations of its own, none of +// which these cases are about. +vi.mock("@/components/CoolifyServerSetup", () => ({ + CoolifyServerSetup: ({ + children, + onUseExisting, + }: { + children?: React.ReactNode; + onUseExisting?: (url?: string) => void; + }) => ( +
+ + {children} +
+ ), +})); + /** * What the panel shows before it has an answer. * @@ -55,11 +91,37 @@ const loadedApp = vi.hoisted(() => ({ vi.mock("@/hooks/useLoadApp", () => ({ useLoadApp: () => ({ app: loadedApp.value, loading: false }), })); +const setup = vi.hoisted(() => ({ state: { type: "idle" } as unknown })); vi.mock("@/ipc/types", () => ({ - ipc: { system: { openExternalUrl: vi.fn() } }, + ipc: { + system: { openExternalUrl: vi.fn() }, + coolifySetup: { snapshot: () => Promise.resolve(setup.state) }, + }, })); -const { CoolifyConnector } = await import("./CoolifyConnector"); +const { CoolifyConnector: Panel } = await import("./CoolifyConnector"); + +// The installer's state is the main process's, shared by every window — so +// each case has to say what it is, or it inherits the last one's. +beforeEach(() => { + setup.state = { type: "idle" }; +}); + +function CoolifyConnector(props: { appId: number | null }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + // Seeded rather than fetched: what the main process is doing is present on + // the first render in the app too, once any window has asked. Waiting for + // it here would make an "it is not shown" assertion pass before the answer + // arrived, which is no assertion at all. + client.setQueryData(queryKeys.coolify.setup, setup.state); + return ( + + + + ); +} describe("before the status query has answered", () => { it("waits rather than claiming a failure when it is merely paused", () => { @@ -85,6 +147,248 @@ describe("before the status query has answered", () => { }); }); +/** + * Two things share this panel and they are not the same thing. + * + * The Coolify instance is the user's and outlives any app; where an app + * deploys is per app. Kept apart so the instance — and the way back into it — + * is readable without opening one app's settings. + */ +describe("the instance and the app are separate sections", () => { + function connected(connection: Record | null) { + deploy.value = { + status: { + hasToken: true, + instanceUrl: "https://coolify.test", + connection, + appUrl: null, + lastDeployedAt: null, + }, + discovery: { servers: [], projects: [] }, + }; + } + + it("shows the instance section while picking where an app deploys", async () => { + connected(null); + render(); + + expect(screen.getByTestId("coolify-instance-section")).toBeTruthy(); + expect(screen.getByTestId("coolify-credentials-stub")).toBeTruthy(); + expect(screen.getByText("Where this app deploys")).toBeTruthy(); + }); + + it("shows it once the app has somewhere to deploy too", async () => { + // Previously it appeared only while editing, so the instance details were + // reachable only by opening one app's settings. + connected({ + instanceUrl: "https://coolify.test", + serverUuid: "srv-1", + projectUuid: "prj-1", + environmentName: "production", + domain: null, + }); + render(); + + expect(screen.getByTestId("coolify-instance-section")).toBeTruthy(); + expect(screen.getByTestId("coolify-credentials-stub")).toBeTruthy(); + }); + + it("offers signing out from the instance section, not the app one", async () => { + connected(null); + render(); + + const section = screen.getByTestId("coolify-instance-section"); + expect(section.textContent).toContain("Sign out of Coolify"); + }); +}); + +/** With no token, installing is the landing screen; this is the other route. */ +async function openTokenForm(user: ReturnType) { + await user.click( + screen.getByRole("button", { name: "I already have Coolify installed" }), + ); +} + +const NO_TOKEN = { + status: { + hasToken: false, + tokenId: null, + instanceUrl: null, + connection: null, + appUrl: null, + lastDeployedAt: null, + }, +}; + +describe("where someone with no Coolify lands", () => { + it("offers to install one rather than asking for a token", async () => { + // The token form asks about a Coolify that already exists. Landing on it + // tells everyone else they are in the wrong place. + deploy.value = NO_TOKEN; + render(); + + expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(); + expect(screen.queryByTestId("coolify-instance-url")).toBeNull(); + }); + + it("reaches the token form from there, and back again", async () => { + deploy.value = NO_TOKEN; + const user = userEvent.setup(); + render(); + + await openTokenForm(user); + expect(screen.getByTestId("coolify-instance-url")).toBeTruthy(); + + await user.click(screen.getByTestId("coolify-no-instance")); + expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(); + }); + + it("puts the way out under what it knows, not against Install", async () => { + deploy.value = NO_TOKEN; + render(); + + const text = screen.getByTestId("coolify-server-setup-stub").textContent; + expect(text?.indexOf("Previous Coolify connection")).toBeLessThan( + text?.indexOf("I already have Coolify installed") ?? -1, + ); + }); + + it("still shows what it knows about a Coolify signed out of", async () => { + // Signing out is exactly when the password is needed, and it lands here. + deploy.value = NO_TOKEN; + const user = userEvent.setup(); + render(); + + expect(screen.getByTestId("coolify-credentials-stub").textContent).toBe( + "Previous Coolify connection", + ); + await openTokenForm(user); + expect(screen.getByTestId("coolify-credentials-stub")).toBeTruthy(); + }); +}); + +describe("what the installer leaves on screen", () => { + const DONE = { + type: "done", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + result: { + dashboardUrl: "http://203.0.113.5:8000", + secure: false, + insecureReason: "No certificate arrived.", + adminEmail: "me@gmail.com", + adminPassword: "Abc123@xyz", + tokenStored: true, + tokenUnavailableReason: null, + version: "4.3.2", + }, + }; + + const CONNECTED = { + status: { + hasToken: true, + instanceUrl: "http://203.0.113.5:8000", + connection: null, + appUrl: null, + lastDeployedAt: null, + }, + discovery: { servers: [], projects: [] }, + }; + + it("keeps a finished install up even once a token exists", async () => { + // The install stores a token, so the panel would otherwise be replaced by + // the connected view — taking with it the only notice that the server + // ended up unencrypted. + setup.state = DONE; + deploy.value = CONNECTED; + render(); + + await waitFor(() => + expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(), + ); + }); + + it("moves on once the user has read it", async () => { + // Dismissing is what ends it, and that is recorded where the install is: + // in the main process, not in a flag this panel could lose. + setup.state = { type: "idle" }; + deploy.value = CONNECTED; + render(); + + await waitFor(() => + expect(screen.getByText("Where this app deploys")).toBeTruthy(), + ); + expect(screen.queryByTestId("coolify-server-setup-stub")).toBeNull(); + }); + + it("shows a running install rather than the form", async () => { + setup.state = { + type: "running", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + step: "installing", + log: "", + stopping: false, + }; + deploy.value = CONNECTED; + render(); + + await waitFor(() => + expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(), + ); + }); + + it("does not hold the panel open after a failure", async () => { + // A failure leaves the form up with the installer's output under it, and + // holding the view there made the token form unreachable. + setup.state = { + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Installing Coolify failed (exit 1).", + log: "dpkg: error", + cancelled: false, + }; + deploy.value = CONNECTED; + render(); + + // Waited for something positive: an absence is true before the answer + // arrives, so asserting only that would pass without proving anything. + await waitFor(() => + expect(screen.getByText("Where this app deploys")).toBeTruthy(), + ); + expect(screen.queryByTestId("coolify-server-setup-stub")).toBeNull(); + }); + + it("is about the server, so it does not change with the app", async () => { + // Deliberate: the install is about a machine, not about one app, and it + // stays until the user has read it and moved on. + setup.state = DONE; + deploy.value = CONNECTED; + const { rerender } = render(); + await waitFor(() => + expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(), + ); + + rerender(); + await waitFor(() => + expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(), + ); + }); +}); + describe("consent to an unencrypted address", () => { it("does not carry over to an address it was not given for", async () => { // Ticked for one plain-HTTP host, then the host is edited. The token would @@ -101,6 +405,7 @@ describe("consent to an unencrypted address", () => { }; const user = userEvent.setup(); render(); + await openTokenForm(user); const url = screen.getByTestId("coolify-instance-url"); await user.type(url, "http://box.local:8000"); @@ -132,6 +437,7 @@ describe("consent to an unencrypted address", () => { }; const user = userEvent.setup(); const { rerender } = render(); + await openTokenForm(user); const url = screen.getByTestId("coolify-instance-url"); await user.clear(url); @@ -378,6 +684,19 @@ describe("editing while the saved connection changes", () => { ).toBe("typed.example.com"); }); + it("keeps Cancel with Save, since they answer the same form", async () => { + // Up by the refresh control it read as cancelling something in progress. + deploy.value = statusWith(CONNECTION); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Edit" })); + const save = screen.getByTestId("coolify-save-connection"); + const cancel = screen.getByRole("button", { name: "Cancel" }); + + expect(save.parentElement).toBe(cancel.parentElement); + }); + it("puts the saved values back when the edit is abandoned", async () => { // Cancel only drops the flag; refilling is the effect's job. deploy.value = statusWith(CONNECTION); diff --git a/src/components/CoolifyConnector.tsx b/src/components/CoolifyConnector.tsx index 7dac5bc12c..554efcfbd4 100644 --- a/src/components/CoolifyConnector.tsx +++ b/src/components/CoolifyConnector.tsx @@ -1,4 +1,6 @@ import { useEffect, useId, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/queryKeys"; import { ExternalLink, Loader2, RefreshCw } from "lucide-react"; import { toast } from "sonner"; import { Button, buttonVariants } from "@/components/ui/button"; @@ -24,6 +26,9 @@ import { SelectValue, } from "@/components/ui/select"; import { ipc } from "@/ipc/types"; +import type { SetupSnapshot } from "@/ipc/types"; +import { CoolifyServerSetup } from "@/components/CoolifyServerSetup"; +import { CoolifyCredentials } from "@/components/CoolifyCredentials"; import { useLoadApp } from "@/hooks/useLoadApp"; import { useCoolifyDeploy } from "@/hooks/useCoolifyDeploy"; import { selectCoolifyDeployCapabilities } from "@/coolify_deploy/capabilities"; @@ -94,6 +99,14 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { disconnect, } = useCoolifyDeploy(appId); + // What the main process is doing with a server, if anything. Asked for + // rather than remembered: an install outlives this panel, and the panel + // being replaced — by a spinner, by a status error — must not lose it. + const { data: setupSnapshot } = useQuery({ + queryKey: queryKeys.coolify.setup, + queryFn: () => ipc.coolifySetup.snapshot(), + }); + const serverSelectId = useId(); const projectSelectId = useId(); const instanceUrlId = useId(); @@ -104,6 +117,9 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { // A saved connection otherwise hides the only form that can change the // server, project or domain — and the only way to sign out. const [isEditingConnection, setIsEditingConnection] = useState(false); + // Installing is where this lands, so the flag is the other way round: it + // records having said "I already have Coolify". + const [isEnteringToken, setIsEnteringToken] = useState(false); const [token, setToken] = useState(""); const [serverUuid, setServerUuid] = useState(""); const [projectUuid, setProjectUuid] = useState(""); @@ -261,8 +277,68 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { ); - // --- Step 1: instance URL + API token --- + // On both screens below. Signing out is exactly when someone needs the + // password back, and it is the state that leaves them here. + const previousConnection = ; + + // One element, not one per branch: rendering a second copy elsewhere in the + // tree would remount the panel and lose the result it is being kept for. + const setupState: SetupSnapshot = setupSnapshot ?? { type: "idle" }; + + const serverSetup = ( + { + if (url) setInstanceUrl(url); + setIsEnteringToken(true); + }} + > + {previousConnection} + + {/* Last, under anything Dyad already knows about a Coolify: this is the + exit for the people the installer does not apply to, not another + control on it. */} +
+ +
+
+ ); + + // Before the token check: an install that is going on, or has something to + // say about how it went, outranks anything else this panel could show. Read + // from the main process rather than remembered here, so leaving the screen + // and coming back finds it again. + // A failure leaves the form on screen with the installer's output under it, + // which is the same panel the user would land on anyway — so it is only a + // reason to hold the view when something is running or finished. Being held + // there after a failure made the token form unreachable. + if (setupState.type === "running" || setupState.type === "done") { + return ( +
+ {serverSetup} +
+ ); + } + + // --- Step 1: get a Coolify, or connect to one --- if (!status.hasToken) { + // Installing comes first: the token form asks for an address and a token, + // which is a question about a Coolify that already exists. Landing on it + // told everyone else they were in the wrong place. + if (!isEnteringToken) { + return ( +
+ {serverSetup} +
+ ); + } + const trimmedUrl = instanceUrl.trim(); // A stock Coolify serves plain HTTP until it has a domain and certificate, // so this is the common case rather than an unusual one. @@ -369,10 +445,70 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { )} Connect + + {previousConnection} + + {/* Back to the installer, for someone who came here by mistake. */} +
+

+ No Coolify server yet?{" "} + {" "} + on a server you already have. +

+
); } + // The only control that removes the stored instance URL and token. It used + // to live solely inside the discovery-error card, so rotating a token or + // moving to another instance meant first breaking discovery on purpose. + const signOut = ( + + ); + // Two things are on this screen and they are not the same thing: the + // Coolify the user connected to, which is theirs and outlives any app, and + // where this one app deploys. Kept apart so the instance and its way back + // in are readable without opening the app's settings. + const coolifySection = ( +
+
+
Coolify
+ {signOut} +
+ +
+ ); + // --- Step 2: server, project, domain --- if (!status.connection || isEditingConnection) { const projects = discovery?.projects ?? []; @@ -380,39 +516,17 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { // is isFetching and so also covers background refetches over a list the // user is already reading. const awaitingDiscovery = isDiscoveryPending; - // The only control that removes the stored instance URL and token. It used - // to live solely inside the discovery-error card, so rotating a token or - // moving to another instance meant first breaking discovery on purpose. - const disconnectEverything = ( - - ); const duplicateProjectName = projects.some( (p) => p.name.toLowerCase() === newProjectName.trim().toLowerCase(), ); return (
+ {coolifySection} + +
+ Where this app deploys +
+ {discoveryError && (

Could not load servers and projects

@@ -452,23 +566,6 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
-
- {isEditingConnection && ( - - )} - {disconnectEverything} -
- {/* A project belongs to the Coolify instance, not to this app, so it is named and created on its own before anything is picked. */} {isEditingConnection && movingHost && ( @@ -636,100 +733,117 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { {insecureWarningBlock}
- + {isEditingConnection && ( + )} - Save - + ); } @@ -763,6 +877,12 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { return (
+ {coolifySection} + +
+ Where this app deploys +
+ {belongsElsewhere && (

diff --git a/src/components/CoolifyCredentials.test.tsx b/src/components/CoolifyCredentials.test.tsx new file mode 100644 index 0000000000..7553cbe4ff --- /dev/null +++ b/src/components/CoolifyCredentials.test.tsx @@ -0,0 +1,186 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/toast", () => ({ showError: vi.fn() })); + +const h = vi.hoisted(() => ({ revealCredentials: vi.fn() })); +vi.mock("@/ipc/types", () => ({ + ipc: { coolifySetup: { revealCredentials: h.revealCredentials } }, +})); + +const { CoolifyCredentials: Panel } = await import("./CoolifyCredentials"); + +function CoolifyCredentials(props: { showTitle?: boolean }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + + + + ); +} + +const FULL = { + dashboardUrl: "https://203.0.113.5.sslip.io", + adminEmail: "me@gmail.com", + adminPassword: "Abc123@xyzAbc123@xyz", + apiToken: "1|abcdefghijklmnop", + isPreviousConnection: true, +}; + +beforeEach(() => { + vi.clearAllMocks(); + h.revealCredentials.mockResolvedValue(FULL); +}); + +async function renderAndSettle() { + render(); + await waitFor(() => + expect(screen.getByTestId("coolify-credentials")).toBeTruthy(), + ); +} + +describe("what is on screen without asking", () => { + it("shows the details rather than hiding them behind a control", async () => { + // Made to click to discover Dyad even has these, most people never find + // out — and then signing out locks them out of their own server. + await renderAndSettle(); + + expect(screen.getByTestId("coolify-field-address").textContent).toBe( + FULL.dashboardUrl, + ); + expect(screen.getByTestId("coolify-field-email").textContent).toBe( + FULL.adminEmail, + ); + }); + + it("includes the API token, not only the sign-in details", async () => { + // Signing out of Coolify in Dyad clears it, so without this the token is + // gone for good and the instance has to be set up again. + await renderAndSettle(); + expect(screen.getByTestId("coolify-field-api-token")).toBeTruthy(); + }); + + it("keeps the secrets masked until they are asked for", async () => { + // Showing the details is not the same as showing the secrets: these sit + // in a panel someone may have open while screen sharing. + await renderAndSettle(); + + expect(screen.getByTestId("coolify-field-password").textContent).toMatch( + /^•+$/, + ); + expect(screen.getByTestId("coolify-field-api-token").textContent).toMatch( + /^•+$/, + ); + }); + + it("does not mask what is not secret", async () => { + await renderAndSettle(); + expect(screen.queryByRole("button", { name: "Show Address" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Show Email" })).toBeNull(); + }); +}); + +describe("revealing one value", () => { + it("shows the password when asked, and hides it again", async () => { + const user = userEvent.setup(); + await renderAndSettle(); + + await user.click(screen.getByRole("button", { name: "Show Password" })); + expect(screen.getByTestId("coolify-field-password").textContent).toBe( + FULL.adminPassword, + ); + + await user.click(screen.getByRole("button", { name: "Hide Password" })); + expect(screen.getByTestId("coolify-field-password").textContent).toMatch( + /^•+$/, + ); + }); + + it("reveals each value on its own", async () => { + // Showing the password should not put the token on screen too. + const user = userEvent.setup(); + await renderAndSettle(); + + await user.click(screen.getByRole("button", { name: "Show Password" })); + + expect(screen.getByTestId("coolify-field-api-token").textContent).toMatch( + /^•+$/, + ); + }); +}); + +describe("naming the section", () => { + it("calls a Coolify that was connected a previous one", async () => { + render(); + await waitFor(() => + expect(screen.getByText("Previous Coolify connection")).toBeTruthy(), + ); + }); + + it("does not call a server just installed a previous connection", async () => { + // Reached by installing a server whose API token could not be minted: + // it is new, and calling it previous reads as something being over. + h.revealCredentials.mockResolvedValue({ + ...FULL, + apiToken: null, + isPreviousConnection: false, + }); + render(); + + await waitFor(() => + expect(screen.getByText("Your new Coolify server")).toBeTruthy(), + ); + }); + + it("leaves no heading over nothing", async () => { + // The caller cannot know there is anything to show until this has asked. + h.revealCredentials.mockResolvedValue({ + dashboardUrl: null, + adminEmail: null, + adminPassword: null, + apiToken: null, + isPreviousConnection: false, + }); + render(); + + await waitFor(() => expect(h.revealCredentials).toHaveBeenCalled()); + expect(screen.queryByText("Previous Coolify connection")).toBeNull(); + }); +}); + +describe("an instance Dyad did not set up", () => { + it("renders nothing rather than an empty heading", async () => { + // Connected by pasting a token: no account Dyad created, no address it + // chose. A panel of blanks would read as something having failed. + h.revealCredentials.mockResolvedValue({ + dashboardUrl: null, + adminEmail: null, + adminPassword: null, + apiToken: null, + isPreviousConnection: false, + }); + const { container } = render(); + + await waitFor(() => expect(h.revealCredentials).toHaveBeenCalled()); + expect(screen.queryByTestId("coolify-credentials")).toBeNull(); + expect(container.textContent).toBe(""); + }); + + it("still shows a token the user pasted themselves", async () => { + h.revealCredentials.mockResolvedValue({ + dashboardUrl: "https://coolify.example.com", + adminEmail: null, + adminPassword: null, + apiToken: "1|theirs", + isPreviousConnection: true, + }); + await renderAndSettle(); + + expect(screen.getByTestId("coolify-field-api-token")).toBeTruthy(); + expect(screen.queryByTestId("coolify-field-password")).toBeNull(); + }); +}); diff --git a/src/components/CoolifyCredentials.tsx b/src/components/CoolifyCredentials.tsx new file mode 100644 index 0000000000..8e55f49caa --- /dev/null +++ b/src/components/CoolifyCredentials.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Eye, EyeOff, Copy, Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ipc } from "@/ipc/types"; +import { queryKeys } from "@/lib/queryKeys"; + +/** + * The way into a server Dyad set up. + * + * Dyad invents the admin password and mints the API token, so it is the only + * thing that knows either. Without somewhere to read them, signing out of + * Coolify in Dyad locks the user out of a machine they own. + * + * Shown rather than hidden behind a control: these belong to the user, and + * making them click to discover that Dyad even has them means most people + * never find out. The values themselves stay masked until asked for, which is + * the part worth a click. + */ + +function Field({ + label, + value, + secret, +}: { + label: string; + value: string; + secret?: boolean; +}) { + const [shown, setShown] = useState(false); + const [copied, setCopied] = useState(false); + const id = label.toLowerCase().replace(/\s+/g, "-"); + return ( +

+ {label} +
+ + {!secret || shown ? value : "•".repeat(Math.min(value.length, 16))} + + {secret && ( + + )} + +
+
+ ); +} + +export function CoolifyCredentials({ + showTitle, +}: { showTitle?: boolean } = {}) { + // Not held after this leaves the screen: these are the keys to the user's + // server, and there is no reason for them to sit in a cache once nothing is + // showing them. + const { data: credentials } = useQuery({ + queryKey: queryKeys.coolify.credentials, + queryFn: () => ipc.coolifySetup.revealCredentials(), + gcTime: 0, + }); + + if (!credentials) return null; + + const { dashboardUrl, adminEmail, adminPassword, apiToken } = credentials; + // An instance connected by pasting a token has no account Dyad created and + // no address it chose, so there is nothing here worth a heading. + if (!dashboardUrl && !adminEmail && !adminPassword && !apiToken) return null; + + return ( +
+ {/* Kept inside so a caller cannot leave a heading over nothing when + there is nothing to show — and so the wording follows which server + these turned out to describe, which only this knows. */} + {showTitle && ( +
+ {credentials.isPreviousConnection + ? "Previous Coolify connection" + : "Your new Coolify server"} +
+ )} + {dashboardUrl && } + {adminEmail && } + {adminPassword && } + {apiToken && } +
+ ); +} diff --git a/src/components/CoolifyServerSetup.test.tsx b/src/components/CoolifyServerSetup.test.tsx new file mode 100644 index 0000000000..8a80d7e16e --- /dev/null +++ b/src/components/CoolifyServerSetup.test.tsx @@ -0,0 +1,659 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/queryKeys"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; + +const toastMock = vi.hoisted(() => ({ + warning: vi.fn(), + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), +})); +vi.mock("sonner", () => ({ toast: toastMock })); +vi.mock("@/lib/toast", () => ({ showError: h.showError })); + +const h = vi.hoisted(() => ({ + showError: vi.fn(), + getServerKey: vi.fn(), + snapshot: vi.fn(), + dismiss: vi.fn(), + changedListeners: [] as Array<(state: unknown) => void>, + inspect: vi.fn(), + run: vi.fn(), + cancel: vi.fn(), +})); + +/** What the main process says is going on, pushed as it would be in the app. */ +const IDLE = { type: "idle" } as const; +function push(state: unknown) { + h.changedListeners.forEach((fn) => fn(state)); +} +const runningState = (over: Record = {}) => ({ + type: "running", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + step: "installing", + log: "", + stopping: false, + ...over, +}); + +vi.mock("@/ipc/types", () => ({ + ipc: { + coolifySetup: { + getServerKey: h.getServerKey, + inspect: h.inspect, + run: h.run, + cancel: h.cancel, + snapshot: h.snapshot, + dismiss: h.dismiss, + }, + events: { + coolifySetup: { + onChanged: (listener: (state: unknown) => void) => { + h.changedListeners.push(listener); + return () => {}; + }, + }, + }, + }, +})); + +const { CoolifyServerSetup } = await import("./CoolifyServerSetup"); + +function renderPanel(onUseExisting = vi.fn()) { + const client = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + }); + const invalidate = vi.spyOn(client, "invalidateQueries"); + return { + invalidate, + ...render( + + +
+ + , + ), + }; +} + +const PUBLIC_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA dyad-server-access"; + +beforeEach(() => { + vi.clearAllMocks(); + h.changedListeners.length = 0; + h.snapshot.mockResolvedValue(IDLE); + h.dismiss.mockResolvedValue(undefined); + h.getServerKey.mockResolvedValue({ publicKey: PUBLIC_KEY }); + h.cancel.mockResolvedValue(undefined); + h.inspect.mockResolvedValue({ + ready: true, + reason: null, + alreadyInstalled: false, + memoryMb: 1967, + hostFingerprint: "SHA256:3FQS9D0B0DVizoYtw1hNV09EClubwWqRUXoFnRTu6nA", + }); +}); + +describe("the key the user has to install", () => { + it("shows it first, because nothing else can happen until it is added", async () => { + renderPanel(); + await waitFor(() => + expect(screen.getByTestId("coolify-setup-public-key").textContent).toBe( + PUBLIC_KEY, + ), + ); + }); +}); + +describe("the admin address", () => { + it("warns about a domain Coolify will refuse, while it is being typed", async () => { + // Coolify resolves the domain when it seeds the account, so an address on + // a reserved domain fails after a multi-minute install rather than before. + const user = userEvent.setup(); + renderPanel(); + await user.type( + screen.getByTestId("coolify-setup-email"), + "admin@dyad.test", + ); + + expect(screen.getByText(/admin@example.test are rejected/)).toBeTruthy(); + }); + + it("says nothing about an ordinary address", async () => { + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + + expect(screen.queryByText(/are rejected/)).toBeNull(); + }); + + it("will not start an install it knows the address fails", async () => { + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type( + screen.getByTestId("coolify-setup-email"), + "admin@dyad.test", + ); + + expect( + screen.getByTestId("coolify-setup-install").hasAttribute("disabled"), + ).toBe(true); + }); +}); + +describe("checking the server first", () => { + it("shows the fingerprint, so it can be compared before anything is sent", async () => { + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.click(screen.getByTestId("coolify-setup-inspect")); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-inspection")).toBeTruthy(), + ); + expect(screen.getByText(/SHA256:3FQS9D0B/)).toBeTruthy(); + }); + + it("blocks the install when the server cannot take one", async () => { + h.inspect.mockResolvedValue({ + ready: false, + reason: "This server already has Coolify on it.", + alreadyInstalled: true, + memoryMb: 1967, + hostFingerprint: null, + }); + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await user.click(screen.getByTestId("coolify-setup-inspect")); + + await waitFor(() => + expect(screen.getByText(/already has Coolify/)).toBeTruthy(), + ); + expect( + screen.getByTestId("coolify-setup-install").hasAttribute("disabled"), + ).toBe(true); + }); +}); + +describe("while it runs", () => { + // The panel is a view of what the main process says is going on. It keeps + // none of this itself, so leaving the screen and coming back finds it again. + + it("says which step it is on rather than only spinning", async () => { + // The install takes minutes; a spinner with no label is indistinguishable + // from a hang, and this is the screen people stare at. + h.snapshot.mockResolvedValue(runningState({ step: "installing" })); + renderPanel(); + + await waitFor(() => + expect(screen.getByText("Installing Coolify")).toBeTruthy(), + ); + }); + + it("shows the installer's own output", async () => { + h.snapshot.mockResolvedValue( + runningState({ log: "3/6 Pulling Docker images..." }), + ); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-log").textContent).toContain( + "3/6 Pulling Docker images...", + ), + ); + }); + + it("offers a way to stop", async () => { + h.snapshot.mockResolvedValue(runningState()); + const user = userEvent.setup(); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-cancel")).toBeTruthy(), + ); + await user.click(screen.getByTestId("coolify-setup-cancel")); + expect(h.cancel).toHaveBeenCalled(); + }); + + it("says it is stopping once asked, rather than offering again", async () => { + h.snapshot.mockResolvedValue(runningState({ stopping: true })); + renderPanel(); + + await waitFor(() => + expect( + (screen.getByTestId("coolify-setup-cancel") as HTMLButtonElement) + .disabled, + ).toBe(true), + ); + }); + + it("shows a run this window did not start", async () => { + // The whole point of holding this in the main process: the install was + // started by another window, or by this one before it was replaced. + h.snapshot.mockResolvedValue(runningState({ step: "securing" })); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-running")).toBeTruthy(), + ); + expect(screen.getByText("Setting up HTTPS")).toBeTruthy(); + }); + + it("keeps up as the run moves on", async () => { + renderPanel(); + await waitFor(() => + expect(screen.getByTestId("coolify-server-setup")).toBeTruthy(), + ); + + push(runningState({ step: "waiting-for-dashboard" })); + + await waitFor(() => + expect(screen.getByText("Waiting for Coolify to start")).toBeTruthy(), + ); + }); +}); + +describe("what it refuses before starting", () => { + it("says so while a domain is being typed, not minutes later", async () => { + // The same shape the installer refuses. Left to the guard, it arrives + // after the install as the reason HTTPS did not happen. + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await user.type( + screen.getByTestId("coolify-setup-domain"), + "coolify.example.com:8000", + ); + + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(true); + }); + + it("accepts an ordinary domain", async () => { + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await user.type( + screen.getByTestId("coolify-setup-domain"), + "coolify.example.com", + ); + + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(false); + }); +}); + +describe("an answer about a server the user has moved on from", () => { + it("does not show one machine's check against another's address", async () => { + // The answer arrives after a round trip. By then the address in the field + // may be a different machine, whose Install button this verdict would + // otherwise disable. + let answer!: (checks: unknown) => void; + h.inspect.mockReturnValue( + new Promise((resolve) => { + answer = resolve; + }), + ); + const user = userEvent.setup(); + renderPanel(); + const host = screen.getByTestId("coolify-setup-host"); + await user.type(host, "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await user.click(screen.getByTestId("coolify-setup-inspect")); + + await user.clear(host); + await user.type(host, "198.51.100.9"); + answer({ + ready: false, + reason: "This server already has Coolify on it.", + alreadyInstalled: true, + memoryMb: 1967, + hostFingerprint: "SHA256:aaa", + }); + + await waitFor(() => expect(h.inspect).toHaveBeenCalled()); + expect(screen.queryByTestId("coolify-setup-inspection")).toBeNull(); + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(false); + }); +}); + +describe("a failure with nothing to show for it", () => { + it("can still be dismissed", async () => { + // Connection and preflight refusals never reach the installer, so they + // carry no output. Dismiss has to sit outside the log, or these failures + // would have no way to clear them. + h.snapshot.mockResolvedValue({ + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Could not reach the server (ECONNREFUSED).", + log: "", + cancelled: false, + }); + const user = userEvent.setup(); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-failed-message")).toBeTruthy(), + ); + expect(screen.queryByTestId("coolify-setup-failed-log")).toBeNull(); + + await user.click(screen.getByTestId("coolify-setup-dismiss-failure")); + expect(h.dismiss).toHaveBeenCalled(); + }); +}); + +describe("a failure that will not go away", () => { + it("can be dismissed, since it follows the user everywhere", async () => { + // The state is the main process's, so the last failure shows in every + // app's panel and every window until something clears it. + h.snapshot.mockResolvedValue({ + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Installing Coolify failed (exit 1).", + log: "dpkg: error", + cancelled: false, + }); + const user = userEvent.setup(); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-dismiss-failure")).toBeTruthy(), + ); + await user.click(screen.getByTestId("coolify-setup-dismiss-failure")); + expect(h.dismiss).toHaveBeenCalled(); + }); +}); + +describe("pressing Install", () => { + it("does not offer a second press while the first is in flight", async () => { + // The panel still shows the form until the broadcast lands, and a second + // request is refused as a second setup. + h.run.mockReturnValue(new Promise(() => {})); + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await user.click(screen.getByTestId("coolify-setup-install")); + + await waitFor(() => + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(true), + ); + }); + + it("does not start when the key could not be read", async () => { + // The key is what the server trusts; without it the install cannot work, + // and the panel already says so. + h.getServerKey.mockRejectedValue(new Error("key file is corrupt")); + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + + await waitFor(() => + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(true), + ); + }); +}); + +describe("the key the server needs", () => { + it("offers a way to try again when it cannot be read", async () => { + // Left as "Generating…" forever, there is nothing to read and nothing to + // press, and the whole screen depends on it. + h.getServerKey.mockRejectedValue(new Error("key file is corrupt")); + renderPanel(); + + await waitFor(() => + expect(screen.getByRole("button", { name: "Try again" })).toBeTruthy(), + ); + }); +}); + +describe("the way out of the installer", () => { + it("carries what the screen below adds, under the form", async () => { + // The way out for someone who already has Coolify lives there. + renderPanel(); + expect(screen.getByTestId("beneath")).toBeTruthy(); + }); + + it("drops it once the install is running", async () => { + // That screen has one thing left to say, and a link away from it is not + // it. Nothing here is a next step while the work is going on. + h.snapshot.mockResolvedValue(runningState()); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-running")).toBeTruthy(), + ); + expect(screen.queryByTestId("beneath")).toBeNull(); + }); + + it("asks for the admin email before the optional domain", async () => { + // A required field under an optional one reads as optional too. + renderPanel(); + const text = screen.getByTestId("coolify-server-setup").textContent ?? ""; + expect(text.indexOf("Email for the Coolify admin account")).toBeLessThan( + text.indexOf("Domain (optional)"), + ); + }); +}); + +describe("when the user stops it", () => { + it("does not report cancelling as a failure", async () => { + // Cancelling is the user asking for the work to stop. The installer's + // output under "What the server reported" reads as a fault. + h.snapshot.mockResolvedValue({ + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Cancelled.", + log: "3/6 Pulling...", + cancelled: true, + }); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-server-setup")).toBeTruthy(), + ); + expect(screen.queryByTestId("coolify-setup-failed-log")).toBeNull(); + }); + + it("does not raise a red error for a cancel", async () => { + // The flow rethrows the cancellation, so it arrives here as a rejection — + // and reporting it says something went wrong while the screen says + // nothing did. + h.run.mockRejectedValue( + new DyadError("Cancelled.", DyadErrorKind.UserCancelled), + ); + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await user.click(screen.getByTestId("coolify-setup-install")); + + await waitFor(() => expect(h.run).toHaveBeenCalled()); + expect(h.showError).not.toHaveBeenCalled(); + }); + + it("still reports a refusal to start", async () => { + h.run.mockRejectedValue(new Error("A server is already being set up.")); + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await user.click(screen.getByTestId("coolify-setup-install")); + + await waitFor(() => expect(h.showError).toHaveBeenCalled()); + }); + + it("still shows what the server said about a real failure", async () => { + h.snapshot.mockResolvedValue({ + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Installing Coolify failed (exit 1).", + log: "dpkg: error processing", + cancelled: false, + }); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-failed-log")).toBeTruthy(), + ); + expect( + screen.getByTestId("coolify-setup-failed-log").textContent, + ).toContain("dpkg: error processing"); + // And what went wrong, which the installer's output does not always say. + expect( + screen.getByTestId("coolify-setup-failed-message").textContent, + ).toContain("Installing Coolify failed"); + }); +}); + +describe("when it finishes", () => { + const DONE_RESULT = { + dashboardUrl: "https://203.0.113.5.sslip.io", + secure: true, + insecureReason: null, + adminEmail: "me@gmail.com", + adminPassword: "Abc123@xyz", + tokenStored: true, + tokenUnavailableReason: null, + version: "4.3.2", + }; + + const doneState = (over: Record = {}) => ({ + type: "done" as const, + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + result: { ...DONE_RESULT, ...over }, + }); + + it("shows the details, since this is the moment they are needed", async () => { + h.snapshot.mockResolvedValue(doneState({ tokenStored: false })); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-done")).toBeTruthy(), + ); + expect(screen.getByTestId("coolify-setup-password").textContent).toBe( + "Abc123@xyz", + ); + }); + + it("says nothing about encryption when the server got a certificate", async () => { + // Dyad asks for one and usually gets it, so a standing warning would be + // noise — and noise is what makes a real warning easy to miss. + h.snapshot.mockResolvedValue(doneState({ tokenStored: false })); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-done")).toBeTruthy(), + ); + expect(screen.queryByTestId("coolify-setup-insecure")).toBeNull(); + }); + + it("warns when it had to settle for plain HTTP", async () => { + h.snapshot.mockResolvedValue( + doneState({ secure: false, insecureReason: "No certificate arrived." }), + ); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-insecure")).toBeTruthy(), + ); + }); + + it("says what is left to do when only the token step failed", async () => { + h.snapshot.mockResolvedValue( + doneState({ tokenStored: false, tokenUnavailableReason: "too old" }), + ); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-manual-token")).toBeTruthy(), + ); + expect( + screen.getByTestId("coolify-setup-manual-token").textContent, + ).toContain("too old"); + }); + + it("puts the screen away and refreshes when the user moves on", async () => { + // The address and token are already stored, but every window is still + // holding the answer from before they were. + h.snapshot.mockResolvedValue(doneState()); + const onUseExisting = vi.fn(); + const user = userEvent.setup(); + const { invalidate } = renderPanel(onUseExisting); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-continue")).toBeTruthy(), + ); + await user.click(screen.getByTestId("coolify-setup-continue")); + + await waitFor(() => expect(h.dismiss).toHaveBeenCalled()); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: queryKeys.coolify.all, + }); + // Refreshed before the screen is put away: the other order hands the + // panel back to a connector that still believes there is no token, and + // the empty install form flashes up. + expect(invalidate.mock.invocationCallOrder[0]).toBeLessThan( + h.dismiss.mock.invocationCallOrder[0], + ); + expect(onUseExisting).toHaveBeenCalledWith("https://203.0.113.5.sslip.io"); + // And before the screen is cleared: dismissing first puts the machine + // back to idle while the panel above still believes there is nothing to + // enter, so the empty install form appears in between. + expect(onUseExisting.mock.invocationCallOrder[0]).toBeLessThan( + h.dismiss.mock.invocationCallOrder[0], + ); + }); +}); diff --git a/src/components/CoolifyServerSetup.tsx b/src/components/CoolifyServerSetup.tsx new file mode 100644 index 0000000000..64fdd0f4ea --- /dev/null +++ b/src/components/CoolifyServerSetup.tsx @@ -0,0 +1,517 @@ +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, Copy, Check, ServerCog } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ipc } from "@/ipc/types"; +import type { + SetupPreflight, + SetupResult, + SetupSnapshot, + SetupStep, +} from "@/ipc/types"; +import { showError } from "@/lib/toast"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { queryKeys } from "@/lib/queryKeys"; +import { isPlausibleAdminEmail } from "@/shared/coolify_admin_email"; +import { isPlausibleInstanceDomain } from "@/shared/coolify_domain"; +import { selectCoolifySetupCapabilities } from "@/coolify_setup/capabilities"; + +/** + * Setting up a server that has nothing on it yet. + * + * The order on screen is the order the work has to happen in, and the first + * step is the only manual one: nothing can reach the server until Dyad's key is + * on it. Everything after that is Dyad's job, and the panel's remaining work is + * to make a multi-minute install look like progress rather than a hang. + */ + +const STEP_LABELS: Record = { + connecting: "Connecting to the server", + "checking-server": "Checking the server", + installing: "Installing Coolify", + "waiting-for-dashboard": "Waiting for Coolify to start", + "verifying-account": "Checking the admin account", + securing: "Setting up HTTPS", + "creating-token": "Setting up API access", + done: "Done", +}; + +function CopyButton({ value, label }: { value: string; label: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +export function CoolifyServerSetup({ + onUseExisting, + children, +}: { + /** + * Leaves the installer for the screen that connects a Coolify that exists. + * + * Given the address when there is one, because after an install that could + * not mint a token the next screen asks for the address of the server just + * built — and leaving it blank means typing from memory. + */ + onUseExisting: (instanceUrl?: string) => void; + /** Sits under the form. Not under the run or the result, which have their + own next step and nothing to add to. */ + children?: ReactNode; +}) { + const queryClient = useQueryClient(); + const hostId = useId(); + const emailId = useId(); + const domainId = useId(); + const [host, setHost] = useState(""); + const [adminEmail, setAdminEmail] = useState(""); + const [customDomain, setCustomDomain] = useState(""); + + // Kept with the address it was asked about. The answer arrives after a + // round trip, and by then the address in the field may be a different + // machine — whose Install button this verdict would then disable. + const [inspection, setInspection] = useState<{ + host: string; + checks: SetupPreflight; + } | null>(null); + const inspectionForHost = + inspection && inspection.host === host.trim() ? inspection.checks : null; + const logRef = useRef(null); + + const serverKey = useQuery({ + queryKey: queryKeys.coolify.serverKey, + queryFn: () => ipc.coolifySetup.getServerKey(), + }); + const publicKey = serverKey.data?.publicKey ?? null; + + // What is going on is asked for, not remembered. An install outlives this + // screen — leaving it is invited, and a background refetch can replace it — + // so anything kept here would be lost exactly when it mattered. + const snapshot = useQuery({ + queryKey: queryKeys.coolify.setup, + queryFn: () => ipc.coolifySetup.snapshot(), + }); + const setup: SetupSnapshot = snapshot.data ?? { type: "idle" }; + // What the machine allows, asked once and answered the same way the + // transition would. What the form allows — a usable address, a key that + // could be read — stays below with the fields it is about. + const can = selectCoolifySetupCapabilities(setup); + + // Pushed rather than polled, so the step and the log keep up with a run + // this window did not start. + useEffect(() => { + return ipc.events.coolifySetup.onChanged((state) => { + queryClient.setQueryData(queryKeys.coolify.setup, state); + }); + }, [queryClient]); + + useEffect(() => { + logRef.current?.scrollTo({ top: logRef.current.scrollHeight }); + }, [setup]); + + const inspect = useMutation({ + mutationFn: async () => { + const asked = host.trim(); + const checks = await ipc.coolifySetup.inspect({ + host: asked, + username: "root", + }); + setInspection({ host: asked, checks }); + return checks; + }, + onError: (error) => showError(error), + }); + + const run = useMutation({ + mutationFn: () => + ipc.coolifySetup.run({ + host: host.trim(), + username: "root", + adminEmail, + customDomain: customDomain.trim() || undefined, + }), + // What became of the run is read from the snapshot, which every window + // gets. Nothing is done with the answer here: this window may not be the + // one still watching by the time it arrives. Only a refusal to start — + // one setup at a time — belongs to the caller. + onError: (error) => { + // A cancel comes back here too, because the flow rethrows it. Reported + // as a red panel it says something went wrong, while the screen behind + // it correctly says nothing did. + if ( + error instanceof DyadError && + error.kind === DyadErrorKind.UserCancelled + ) { + return; + } + showError(error); + }, + }); + + /** Puts the finished screen away and lets the panel behind catch up. */ + const leaveResult = async (instanceUrl?: string) => { + // Refreshed before the screen is put away. Dismissing first hands the + // panel back to a connector that still believes there is no token, so the + // empty install form flashes up before the right screen arrives. + await queryClient.invalidateQueries({ queryKey: queryKeys.coolify.all }); + // Told where to go before the screen is cleared. Dismissing first puts the + // machine back to idle while the panel above still believes there is + // nothing to enter, so the empty install form appears in between. + onUseExisting(instanceUrl); + await ipc.coolifySetup.dismiss().catch(showError); + }; + + const emailLooksUsable = !adminEmail || isPlausibleAdminEmail(adminEmail); + const domainLooksUsable = + !customDomain.trim() || isPlausibleInstanceDomain(customDomain); + // --- Finished --- + if (setup.type === "done") { + const result = setup.result; + return ( +
+
+ + Coolify is installed +
+ {/* Dyad keeps these, so this is a copy rather than the only sight of + them. Put here anyway: this is the moment they are needed. */} +
+

+ Save these now, or find them again under the server list. +

+
+ Address +
+ {result.dashboardUrl} + +
+
+
+ Email +
+ {result.adminEmail} + +
+
+
+ Password +
+ + {result.adminPassword} + + +
+
+
+ {/* Only when it is true. Dyad asks for a certificate and usually gets + one, so a standing warning would be noise — and a warning nobody + sees when it matters is worse than one that appears only then. The + token here carries root abilities and travels on every deploy, not + once at setup. */} + {!result.secure && ( +
+

This server is not encrypted

+

+ {result.insecureReason} Dyad will still work, but its access token + crosses your network unencrypted every time it deploys. Adding a + domain that points at this server fixes it. +

+
+ )} + {result.tokenStored ? ( +

+ Dyad created its own API token, so you can pick a server and project + next. +

+ ) : ( + // The install stands; only the last step did not. Saying so plainly + // beats implying the whole thing failed. +
+

One step left, in Coolify

+

+ {result.tokenUnavailableReason ?? + "Dyad could not create an API token automatically."}{" "} + Open {result.dashboardUrl}, sign in with the details above, enable + the API under Settings → Advanced, then create a token under + Security → API Tokens and paste it in on the next screen. +

+
+ )} + +
+ ); + } + + // --- Running --- + if (setup.type === "running") { + return ( +
+
+ + {STEP_LABELS[setup.step]} +
+

+ Installing takes a couple of minutes. Leaving this screen does not + stop it. +

+ {setup.log && ( +
+ {setup.log} +
+ )} + +
+ ); + } + + // --- Setting up --- + return ( +
+

+ Dyad allows you to self-host an instance of Coolify to deploy your apps. + To install it you need a Linux server with root access and about 2GB of + memory. Easiest if you have not created the server yet, since the key + below can go in at that point. +

+ + {/* First because nothing else can happen until it is done. */} +
+ + {/* The provider's web form comes first because it is the path that + needs no terminal: most hosts take an SSH key when the server is + created, so this whole step can happen in a browser. Leading with + authorized_keys made the easy route look like the footnote. */} +

+ Easiest when creating the server: most hosts — DigitalOcean, Hetzner + and others — have an SSH keys field on the create + page. Paste this in there and the server will trust Dyad from the + moment it starts. +

+

+ For a server that already exists, add it as a new line in{" "} + /root/.ssh/authorized_keys on the server. +

+
+ + {publicKey ?? + (serverKey.isError ? "Could not read the key" : "Generating…")} + + {publicKey ? ( + + ) : ( + serverKey.isError && ( + + ) + )} +
+
+ +
+ + { + setHost(e.target.value); + setInspection(null); + }} + /> +
+ +
+ + setAdminEmail(e.target.value)} + /> + {/* Checked while typing, because Coolify resolves the domain when it + creates the account — and finding out afterwards costs the whole + install. */} + {!emailLooksUsable && ( +

+ Coolify checks that the domain resolves, so addresses like + admin@example.test are rejected. Use one you can receive mail at. +

+ )} +

+ This is the account you would sign in to Coolify with. +

+
+ +
+ + {/* Dyad can get a certificate without this, using a free service that + turns an address into a name. Someone with their own domain is + better off using it: it is theirs, and that free service shares one + certificate allowance between everyone who uses it. */} + setCustomDomain(e.target.value)} + /> + {!domainLooksUsable && ( +

+ Use just the domain, with no port or path — for example + coolify.yourdomain.com. +

+ )} +

+ Point it at this server first. Leave blank and Dyad will set up HTTPS + using the server's address. +

+
+ + {/* Kept after a failure, not only during the run. The failure message + points at the installer's own output, and replacing the log with the + form the moment it fails leaves nothing to point at. */} + {/* One block, so Dismiss sits beside the message rather than inside the + log — a connection or preflight refusal carries no output, and would + otherwise have nothing to clear it. */} + {setup.type === "failed" && !setup.cancelled && ( +
+
+

+ {setup.message} +

+ +
+ {setup.log && ( +
+ {setup.log} +
+ )} +
+ )} + + {inspectionForHost && ( +
+ {inspectionForHost.hostFingerprint && ( + // Shown so someone who cares can compare it against their + // provider's console before Dyad sends anything to the machine. +
+ Server fingerprint + + {inspectionForHost.hostFingerprint} + +
+ )} + {inspectionForHost.memoryMb !== null && ( +
+ Memory + {inspectionForHost.memoryMb} MB +
+ )} + {!inspectionForHost.ready && ( +

+ {inspectionForHost.reason} +

+ )} +
+ )} + +
+ + +
+ + {children} +
+ ); +} diff --git a/src/coolify_setup/admin_credentials.test.ts b/src/coolify_setup/admin_credentials.test.ts new file mode 100644 index 0000000000..1332e8c390 --- /dev/null +++ b/src/coolify_setup/admin_credentials.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + buildAdminCredentials, + generateAdminPassword, + isShellSafe, +} from "./admin_credentials"; + +describe("generateAdminPassword", () => { + it("meets every rule Coolify checks", () => { + // Its validator wants length, mixed case, a digit and a symbol. Failing any + // of them leaves the install finished with no account on it. + for (let i = 0; i < 200; i++) { + const pw = generateAdminPassword(); + expect(pw.length).toBe(24); + expect(pw).toMatch(/[A-Z]/); + expect(pw).toMatch(/[a-z]/); + expect(pw).toMatch(/[0-9]/); + expect(pw).toMatch(/[@%^*_\-+=]/); + } + }); + + it("never produces a character that would break out of its quoting", () => { + // The value crosses a shell assignment and lands in a .env file. A quote or + // a backslash ends the quoting; # starts a comment and would truncate it. + for (let i = 0; i < 200; i++) { + expect(isShellSafe(generateAdminPassword())).toBe(true); + } + }); + + it("does not always put the required characters first", () => { + // They are appended before shuffling, so a missing shuffle would leave the + // first four positions perfectly predictable. + const firstFour = new Set( + Array.from({ length: 60 }, () => generateAdminPassword().slice(0, 4)), + ); + expect(firstFour.size).toBeGreaterThan(1); + }); +}); + +describe("buildAdminCredentials", () => { + it("keeps the address it was given rather than inventing one", () => { + const creds = buildAdminCredentials(" someone@gmail.com "); + expect(creds.email).toBe("someone@gmail.com"); + expect(creds.username).toBe("dyad-admin"); + expect(isShellSafe(creds.password)).toBe(true); + }); +}); diff --git a/src/coolify_setup/admin_credentials.ts b/src/coolify_setup/admin_credentials.ts new file mode 100644 index 0000000000..b33f6bf211 --- /dev/null +++ b/src/coolify_setup/admin_credentials.ts @@ -0,0 +1,77 @@ +import { randomInt } from "crypto"; + +/** + * The admin account Coolify seeds itself with during installation. + * + * Coolify creates its first user from environment variables the installer + * writes, which is what lets Dyad set a server up without the user ever opening + * the dashboard. The values only take effect while no admin exists, so this + * cannot take over an instance somebody is already using. + */ + +/** + * Excludes quote, backslash, backtick and $ so the value survives a shell, and + * # and ! because these land in a .env file, where # starts a comment and would + * silently truncate the password. + */ +const UPPER = "ABCDEFGHJKLMNPQRSTUVWXYZ"; +const LOWER = "abcdefghijkmnopqrstuvwxyz"; +const DIGITS = "23456789"; +const SYMBOLS = "@%^*_-+="; + +function pick(alphabet: string): string { + return alphabet[randomInt(alphabet.length)]; +} + +/** + * A password Coolify will accept. + * + * Its rule is at least eight characters with an upper case letter, a lower case + * letter, a digit and a symbol, and it additionally rejects passwords found in + * known breaches. A generated 24-character value clears both, though the breach + * check is a network call made by the server, so seeding needs the server to + * have outbound access. + */ +export function generateAdminPassword(length = 24): string { + const required = [pick(UPPER), pick(LOWER), pick(DIGITS), pick(SYMBOLS)]; + const all = UPPER + LOWER + DIGITS + SYMBOLS; + const rest = Array.from( + { length: Math.max(0, length - required.length) }, + () => pick(all), + ); + const chars = [...required, ...rest]; + // Fisher-Yates, so the four required characters are not always at the front. + for (let i = chars.length - 1; i > 0; i--) { + const j = randomInt(i + 1); + [chars[i], chars[j]] = [chars[j], chars[i]]; + } + return chars.join(""); +} + +export interface AdminCredentials { + username: string; + email: string; + password: string; +} + +/** + * Whether a value can be carried to the server without being escaped. + * + * These are written into a shell assignment and then into a .env file. Rather + * than escape them for each, values containing a character that would end the + * quoting are rejected outright — getting that subtly wrong runs arbitrary text + * as a command on somebody's server. + */ +export function isShellSafe(value: string): boolean { + return !/['"\\`$\n\r#!]/.test(value); +} + +export function buildAdminCredentials(email: string): AdminCredentials { + return { + username: "dyad-admin", + // Asked for rather than invented, because the domain has to resolve and + // because this is the address the user signs in with afterwards. + email: email.trim(), + password: generateAdminPassword(), + }; +} diff --git a/src/coolify_setup/api_token.test.ts b/src/coolify_setup/api_token.test.ts new file mode 100644 index 0000000000..9f3988297b --- /dev/null +++ b/src/coolify_setup/api_token.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from "vitest"; +import { + compareVersions, + enableApi, + mintApiToken, + readCoolifyVersion, + supportsAutomaticToken, + tryAutomaticAccess, +} from "./api_token"; +import type { SshSession } from "@/ipc/utils/ssh_client"; + +/** Wraps a value the way a real tinker transcript carries it. */ +function transcript(output: string): string { + return [ + '> echo "__DYAD_OUT_START__" . PHP_EOL;', + "> __DYAD_OUT_START__", + output, + "__DYAD_OUT_END__", + ].join("\n"); +} + +/** A token of the shape Sanctum actually returns. */ +const REAL_TOKEN = "1|EcaUxT43T5fgdLJmnYj0702tEUC6viy5jEhO3Ujk2298db95"; + +function fakeSession(replies: string[]): SshSession & { + scripts: string[]; + commands: string[]; +} { + const scripts: string[] = []; + const commands: string[] = []; + let call = 0; + return { + scripts, + commands, + run: vi.fn(async (command: string, options?: { input?: string }) => { + commands.push(command); + scripts.push(options?.input ?? ""); + const reply = replies[Math.min(call, replies.length - 1)]; + call += 1; + return { code: 0, stdout: transcript(reply), stderr: "" }; + }) as unknown as SshSession["run"], + end: vi.fn(), + }; +} + +describe("compareVersions", () => { + it("orders by number, not by text", () => { + // Compared as strings, "4.10.0" sorts below "4.9.0" and the automated path + // would quietly switch itself off on newer instances. + expect(compareVersions("4.10.0", "4.9.0")).toBe(1); + expect(compareVersions("4.3.2", "4.3.2")).toBe(0); + expect(compareVersions("4.3", "4.3.0")).toBe(0); + expect(compareVersions("3.9.9", "4.0.0")).toBe(-1); + }); +}); + +describe("supportsAutomaticToken", () => { + it.each([ + ["4.3.2", true], + ["4.0.0", true], + ["4.10.0", true], + ["3.9.0", false], + [null, false], + ])("reads %s as %s", (version, expected) => { + expect(supportsAutomaticToken(version)).toBe(expected); + }); +}); + +describe("readCoolifyVersion", () => { + it("asks the instance, because the installer picks the version", async () => { + const session = fakeSession(["4.3.2"]); + expect(await readCoolifyVersion(session)).toBe("4.3.2"); + }); + + it("answers null when the reply is not a version", async () => { + const session = fakeSession(["Command not found"]); + expect(await readCoolifyVersion(session)).toBeNull(); + }); +}); + +describe("enableApi", () => { + it("confirms the setting took rather than assuming the write worked", async () => { + const session = fakeSession(["enabled"]); + await expect(enableApi(session)).resolves.toBeUndefined(); + expect(session.scripts[0]).toContain("is_api_enabled = true"); + }); + + it("fails when the setting did not take", async () => { + const session = fakeSession(["still-disabled"]); + await expect(enableApi(session)).rejects.toMatchObject({ + kind: "external", + }); + }); +}); + +describe("mintApiToken", () => { + it("seeds a team into the session before creating the token", async () => { + // Not defensive: Coolify's createToken override stamps the row with + // session('currentTeam')->id, and tinker has no session, so the insert + // fails on a not-null team_id without this. + const session = fakeSession([REAL_TOKEN]); + await mintApiToken(session, "admin@gmail.com"); + expect(session.scripts[0]).toContain("session(['currentTeam' => $team])"); + }); + + it("asks for root abilities", async () => { + // Narrower tokens hide a server's private key id, which the deploy path + // reads to tell a stale key from one it simply cannot see. + const session = fakeSession([REAL_TOKEN]); + await mintApiToken(session, "admin@gmail.com"); + expect(session.scripts[0]).toContain("['root']"); + }); + + it("keeps the address out of the script", async () => { + const session = fakeSession([REAL_TOKEN]); + await mintApiToken(session, "admin@gmail.com"); + expect(session.scripts[0]).not.toContain("admin@gmail.com"); + expect(session.commands[0]).toContain( + "-e DYAD_ADMIN_EMAIL='admin@gmail.com'", + ); + }); + + it("uses no early return, which tinker cannot parse", async () => { + // A `return` at top level is a parse error, and a script that does not + // parse prints nothing at all — so the branch it guarded never reports. + const session = fakeSession([REAL_TOKEN]); + await mintApiToken(session, "admin@gmail.com"); + expect(session.scripts[0]).not.toMatch(/\breturn\s*;/); + }); + + it("says so when the account does not exist", async () => { + const session = fakeSession(["no-user"]); + await expect( + mintApiToken(session, "nobody@gmail.com"), + ).rejects.toMatchObject({ + kind: "precondition", + }); + }); + + it("says so when the account has no team", async () => { + const session = fakeSession(["no-team"]); + await expect( + mintApiToken(session, "admin@gmail.com"), + ).rejects.toMatchObject({ + kind: "precondition", + }); + }); + + it("refuses anything that is not a token", async () => { + // A warning line stored as a credential fails much later, somewhere that + // cannot explain what went wrong. + const session = fakeSession(["PHP Warning: something"]); + await expect( + mintApiToken(session, "admin@gmail.com"), + ).rejects.toMatchObject({ + kind: "external", + }); + }); + + it("refuses a token name that could break out of its quoting", async () => { + const session = fakeSession([REAL_TOKEN]); + await expect( + mintApiToken(session, "admin@gmail.com", { + tokenName: "a', ['root'], null); //", + }), + ).rejects.toMatchObject({ kind: "internal" }); + }); +}); + +describe("tryAutomaticAccess", () => { + it("returns a token when the instance can be driven", async () => { + const session = fakeSession(["4.3.2", "enabled", REAL_TOKEN]); + const access = await tryAutomaticAccess(session, "admin@gmail.com"); + expect(access?.token).toBe(REAL_TOKEN); + expect(access?.version).toBe("4.3.2"); + }); + + it("declines quietly on an instance it does not know", async () => { + // Not a failure: an instance Dyad did not install is the ordinary case, + // and the caller asks for a token by hand instead. + const session = fakeSession(["3.1.0"]); + expect(await tryAutomaticAccess(session, "admin@gmail.com")).toBeNull(); + }); + + it("does not try to enable the API on an instance it declined", async () => { + const session = fakeSession(["3.1.0"]); + await tryAutomaticAccess(session, "admin@gmail.com"); + expect(session.scripts.some((s) => s.includes("is_api_enabled"))).toBe( + false, + ); + }); +}); diff --git a/src/coolify_setup/api_token.ts b/src/coolify_setup/api_token.ts new file mode 100644 index 0000000000..fa1f7f9a03 --- /dev/null +++ b/src/coolify_setup/api_token.ts @@ -0,0 +1,234 @@ +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import type { SshSession } from "@/ipc/utils/ssh_client"; +import { runTinker } from "./tinker"; + +/** + * How long one of these questions may take. + * + * Each is a tinker one-liner against a Coolify that is already up, so the + * honest answer arrives in seconds. Unbounded, a wedged docker leaves + * "Setting up API access" on screen forever with nothing behind it. + */ +const TINKER_TIMEOUT_MS = 30_000; + +/** + * Turning Coolify's API on and creating a token for it, without the dashboard. + * + * Coolify ships with its API off and offers no way to change that or to create + * a token except by hand in the browser. So both are done by driving Laravel + * and Sanctum directly, which is not an interface anybody promised to keep. + * Every workaround below is marked, and each says what would replace it. + * + * All of it is best-effort. When any part of it does not work the caller falls + * back to asking the user for a token, which is the path Dyad has always had. + */ + +/** + * The last version this was checked against. + * + * Not a maximum. The path is attempted on anything at or above the version it + * was written for and simply reports failure otherwise — refusing outright + * would turn a working instance into a manual one for no reason, and the + * fallback already covers being wrong. + */ +export const VERIFIED_AGAINST = "4.3.2"; + +/** Coolify's API was off by default when this was written. */ +const MINIMUM_SUPPORTED = "4.0.0"; + +/** + * Compares dotted versions numerically. + * + * String comparison puts 4.10.0 before 4.9.0, which would silently disable the + * automated path on newer instances. + */ +export function compareVersions(a: string, b: string): number { + const parse = (v: string) => + v + .trim() + .split(".") + .map((part) => Number.parseInt(part, 10) || 0); + const left = parse(a); + const right = parse(b); + for (let i = 0; i < Math.max(left.length, right.length); i++) { + const diff = (left[i] ?? 0) - (right[i] ?? 0); + if (diff !== 0) return diff > 0 ? 1 : -1; + } + return 0; +} + +/** + * Reads the version of the Coolify that was just installed. + * + * Read rather than chosen: the installer resolves "latest" when it runs, so + * there is no version to pin at install time and the only way to know what + * landed is to ask it afterwards. + * + * WORKAROUND: the version endpoint needs the API, and whether the API can be + * turned on is what this answers, so it reads the config value directly. + * + * TODO: replace if a fresh Coolify gains a way to state its version before its + * API is reachable. + */ +export async function readCoolifyVersion( + session: SshSession, + { signal }: { signal?: AbortSignal } = {}, +): Promise { + try { + const output = await runTinker( + session, + `echo config('constants.coolify.version');`, + { signal, timeoutMs: TINKER_TIMEOUT_MS }, + ); + return /^\d+\.\d+/.test(output.trim()) ? output.trim() : null; + } catch (error) { + // A cancelled setup is the user stopping, not an instance that cannot be + // driven. Swallowing it here would report a version problem for something + // they did on purpose, and carry on setting the server up. + if ((error as { kind?: string }).kind === "user_cancelled") throw error; + // An instance too old or too different to answer is one to set up by hand. + return null; + } +} + +export function supportsAutomaticToken(version: string | null): boolean { + if (!version) return false; + return compareVersions(version, MINIMUM_SUPPORTED) >= 0; +} + +/** + * Turns the API on. + * + * WORKAROUND: Coolify has a migration named disable_api_by_default and no + * setting, command or endpoint to reverse it, so this writes the model field + * the dashboard's own toggle writes. + * + * TODO: replace with whatever Coolify offers if it ever gains a way to enable + * the API at install time — an installer environment variable would be the + * natural shape, and would make this function unnecessary rather than smaller. + */ +export async function enableApi( + session: SshSession, + { signal }: { signal?: AbortSignal } = {}, +): Promise { + const output = await runTinker( + session, + [ + `$s = \\App\\Models\\InstanceSettings::get();`, + `$s->is_api_enabled = true;`, + `$s->save();`, + `echo $s->is_api_enabled ? 'enabled' : 'still-disabled';`, + ].join("\n"), + { signal, timeoutMs: TINKER_TIMEOUT_MS }, + ); + if (output.trim() !== "enabled") { + throw new DyadError( + "Could not turn Coolify's API on automatically.", + DyadErrorKind.External, + ); + } +} + +/** + * Creates an API token for Dyad. + * + * WORKAROUND, in two parts. + * + * There is no artisan command that mints a token, so this calls Sanctum's + * createToken directly. And Coolify overrides createToken to stamp the row with + * `session('currentTeam')->id`; tinker has no session, so without seeding one + * the insert fails on a not-null constraint against team_id. The session line + * is doing real work, not defensive coding. + * + * `root` is the honest ability to ask for: reading a server's private key id — + * which the deploy path needs to tell a stale key from a hidden one — requires + * read:sensitive, and a narrower token silently returns nothing there instead + * of failing. + * + * TODO: replace both halves if Coolify gains a token-minting command or an + * endpoint that does not need a browser session. + */ +export async function mintApiToken( + session: SshSession, + adminEmail: string, + { + signal, + tokenName = "dyad", + }: { signal?: AbortSignal; tokenName?: string } = {}, +): Promise { + if (!/^[A-Za-z0-9 _-]{1,40}$/.test(tokenName)) { + throw new DyadError( + `Unsafe token name: ${tokenName}`, + DyadErrorKind.Internal, + ); + } + const output = await runTinker( + session, + // One statement per line and no early returns: tinker evaluates what it is + // fed as top-level code, where `return` is a parse error — and a script + // that does not parse produces no output at all rather than the branch it + // was meant to take. + [ + `$u = \\App\\Models\\User::where('email', getenv('DYAD_ADMIN_EMAIL'))->first();`, + `$team = $u ? $u->teams()->first() : null;`, + // The session line is doing real work: Coolify's createToken override + // reads the team from a session tinker does not otherwise have, and the + // insert fails on team_id without it. + `if ($team) { session(['currentTeam' => $team]); }`, + `echo !$u ? 'no-user' : (!$team ? 'no-team' : $u->createToken('${tokenName}', ['root'], null)->plainTextToken);`, + ].join("\n"), + { + env: { DYAD_ADMIN_EMAIL: adminEmail }, + signal, + timeoutMs: TINKER_TIMEOUT_MS, + }, + ); + + const token = output.trim(); + if (token === "no-user") { + throw new DyadError( + "Coolify has no account for this address, so no token could be created.", + DyadErrorKind.Precondition, + ); + } + if (token === "no-team") { + throw new DyadError( + "Coolify's admin account has no team yet, so no token could be created.", + DyadErrorKind.Precondition, + ); + } + // Sanctum's plain text token is `|<40+ characters>`. Checking the shape + // keeps a stray warning or a partial line from being stored as a credential + // and failing much later, somewhere that cannot explain itself. + if (!/^\d+\|[A-Za-z0-9]{40,}$/.test(token)) { + throw new DyadError( + "Coolify did not return a usable API token.", + DyadErrorKind.External, + ); + } + return token; +} + +export interface AutomaticAccess { + token: string; + version: string | null; +} + +/** + * Enables the API and mints a token, or reports that it could not. + * + * Returns null rather than throwing when the instance is one this cannot drive, + * because that is not a failure — it is the ordinary case of an instance Dyad + * did not install, and the caller asks for a token by hand instead. + */ +export async function tryAutomaticAccess( + session: SshSession, + adminEmail: string, + { signal }: { signal?: AbortSignal } = {}, +): Promise { + const version = await readCoolifyVersion(session, { signal }); + if (!supportsAutomaticToken(version)) return null; + await enableApi(session, { signal }); + const token = await mintApiToken(session, adminEmail, { signal }); + return { token, version }; +} diff --git a/src/coolify_setup/capabilities.test.ts b/src/coolify_setup/capabilities.test.ts new file mode 100644 index 0000000000..0efafd35ab --- /dev/null +++ b/src/coolify_setup/capabilities.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { selectCoolifySetupCapabilities } from "./capabilities"; +import { IDLE, type CoolifySetupState } from "./state"; + +const REF = { + kind: "coolify-setup" as const, + entityKey: "203.0.113.5", + operationId: "op-1", +}; + +const running = (stopping = false): CoolifySetupState => ({ + type: "running", + host: "203.0.113.5", + invocationRef: REF, + step: "installing", + log: "", + stopping, +}); + +describe("what the panel may offer", () => { + it("offers a start when nothing is going on", () => { + expect(selectCoolifySetupCapabilities(IDLE)).toMatchObject({ + canStart: true, + canCancel: false, + }); + }); + + it("refuses a second start, which is what the machine would say anyway", () => { + // Offering it means the answer to pressing it is an error rather than an + // install, and the transition already refuses with "already-running". + expect(selectCoolifySetupCapabilities(running()).canStart).toBe(false); + }); + + it("offers a cancel only while there is something to stop", () => { + expect(selectCoolifySetupCapabilities(running()).canCancel).toBe(true); + expect(selectCoolifySetupCapabilities(IDLE).canCancel).toBe(false); + }); + + it("stops offering a cancel once one has been asked for", () => { + expect(selectCoolifySetupCapabilities(running(true)).canCancel).toBe(false); + }); +}); diff --git a/src/coolify_setup/capabilities.ts b/src/coolify_setup/capabilities.ts new file mode 100644 index 0000000000..a7cc03f5cd --- /dev/null +++ b/src/coolify_setup/capabilities.ts @@ -0,0 +1,32 @@ +import type { CoolifySetupState } from "./state"; + +export interface CoolifySetupCapabilities { + /** + * Whether an install may be started. + * + * False while one is going, because the machine refuses a second one — + * offering the button anyway means the answer to pressing it is an error + * message rather than an install. + */ + readonly canStart: boolean; + /** Whether there is something to stop. */ + readonly canCancel: boolean; +} + +/** + * Pure domain policy for the controls on the setup panel. + * + * Only what the state decides. Whether the address looks like an address, or + * whether the key could be read, are questions about the form in front of the + * user and stay with it — this answers the questions the machine owns, and + * answers them the same way the transition does. + */ +export function selectCoolifySetupCapabilities( + state: CoolifySetupState, +): CoolifySetupCapabilities { + const isRunning = state.type === "running"; + return { + canStart: !isRunning, + canCancel: isRunning && !state.stopping, + }; +} diff --git a/src/coolify_setup/controller.test.ts b/src/coolify_setup/controller.test.ts new file mode 100644 index 0000000000..e0d62769be --- /dev/null +++ b/src/coolify_setup/controller.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it, vi } from "vitest"; +import { CoolifySetupController } from "./controller"; +import type { CoolifySetupState } from "./state"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import type { SetupResult, SetupStep, SetupTarget } from "@/ipc/types"; + +const TARGET: SetupTarget = { + host: "203.0.113.5", + username: "root", + adminEmail: "me@gmail.com", +}; + +const RESULT: SetupResult = { + dashboardUrl: "https://203.0.113.5.sslip.io", + secure: true, + insecureReason: null, + adminEmail: "me@gmail.com", + adminPassword: "Abc123@xyz", + tokenStored: true, + tokenUnavailableReason: null, + version: "4.3.2", +}; + +/** Deterministic, because a machine keyed on identity must be reproducible. */ +function ids() { + let n = 0; + return { next: () => `op-${++n}` }; +} + +function harness( + execute: CoolifySetupController extends never + ? never + : ConstructorParameters[0]["execute"], +) { + const states: CoolifySetupState[] = []; + const controller = new CoolifySetupController({ + execute, + ids: ids(), + onChanged: (state) => states.push(state), + }); + return { controller, states }; +} + +/** A run the test finishes by hand. */ +function deferred() { + let resolve!: (value: SetupResult) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("starting", () => { + it("reports what is happening, so a panel can be a view of it", async () => { + const gate = deferred(); + let report!: (step: SetupStep, output?: string) => void; + const { controller } = harness(async (_t, hooks) => { + report = hooks.onProgress; + return gate.promise; + }); + + controller.start(TARGET); + expect(controller.getState()).toMatchObject({ + type: "running", + host: "203.0.113.5", + step: "connecting", + }); + + report("installing", "3/6 Pulling..."); + expect(controller.getState()).toMatchObject({ + step: "installing", + log: "3/6 Pulling...", + }); + + gate.resolve(RESULT); + await controller.getState(); + }); + + it("refuses a second setup while one is running", async () => { + const gate = deferred(); + const { controller } = harness(async () => gate.promise); + controller.start(TARGET); + + expect(() => controller.start({ ...TARGET, host: "198.51.100.9" })).toThrow( + /already being set up/, + ); + gate.resolve(RESULT); + }); + + it("lets another start once the first has finished", async () => { + const first = deferred(); + let calls = 0; + const { controller } = harness(async () => { + calls += 1; + return calls === 1 ? first.promise : RESULT; + }); + + const run = controller.start(TARGET); + first.resolve(RESULT); + await run.result; + controller.dismiss(); + + expect(() => controller.start(TARGET)).not.toThrow(); + }); +}); + +describe("finishing", () => { + it("keeps the result where a panel can find it after the fact", async () => { + const { controller } = harness(async () => RESULT); + const run = controller.start(TARGET); + await run.result; + + expect(controller.getState()).toMatchObject({ type: "done" }); + }); + + it("hands the caller what the work produced", async () => { + const { controller } = harness(async () => RESULT); + expect(await controller.start(TARGET).result).toEqual(RESULT); + }); + + it("keeps a failure and the output that explains it", async () => { + const { controller } = harness(async (_t, hooks) => { + hooks.onProgress("installing", "3/6 Pulling..."); + throw new DyadError("exit 1", DyadErrorKind.External); + }); + + await expect(controller.start(TARGET).result).rejects.toThrow("exit 1"); + expect(controller.getState()).toMatchObject({ + type: "failed", + message: "exit 1", + log: "3/6 Pulling...", + cancelled: false, + }); + }); + + it("marks a cancellation as one rather than as a fault", async () => { + const { controller } = harness(async (_t, hooks) => { + await new Promise((resolve) => + hooks.signal.addEventListener("abort", () => resolve()), + ); + throw new DyadError("Cancelled.", DyadErrorKind.UserCancelled); + }); + + const run = controller.start(TARGET); + controller.cancel(); + await expect(run.result).rejects.toThrow("Cancelled."); + expect(controller.getState()).toMatchObject({ + type: "failed", + cancelled: true, + }); + }); +}); + +describe("cancelling", () => { + it("aborts the work rather than only saying so", async () => { + let aborted = false; + const gate = deferred(); + const { controller } = harness(async (_t, hooks) => { + hooks.signal.addEventListener("abort", () => { + aborted = true; + }); + return gate.promise; + }); + + controller.start(TARGET); + controller.cancel(); + + expect(aborted).toBe(true); + expect(controller.getState()).toMatchObject({ stopping: true }); + gate.resolve(RESULT); + }); + + it("is quiet when there is nothing to stop", () => { + const { controller } = harness(async () => RESULT); + expect(() => controller.cancel()).not.toThrow(); + expect(controller.getState()).toEqual({ type: "idle" }); + }); +}); + +describe("answers from a run nobody is watching any more", () => { + it("does not put a superseded run's result back on screen", async () => { + // The reason the state is here and not in the panel: this run finishes + // after the user has moved on, and its answer must not revive anything. + const first = deferred(); + let calls = 0; + const { controller } = harness(async () => { + calls += 1; + return calls === 1 ? first.promise : RESULT; + }); + + const stale = controller.start(TARGET); + controller.cancel(); + first.reject(new DyadError("Cancelled.", DyadErrorKind.UserCancelled)); + await expect(stale.result).rejects.toThrow(); + controller.dismiss(); + + // A second run of the same server, then the first one's answer arrives. + const second = controller.start(TARGET); + await second.result; + expect(controller.getState()).toMatchObject({ type: "done" }); + }); +}); + +describe("telling anyone who is listening", () => { + it("reports each change once, so windows can follow along", async () => { + const { controller, states } = harness(async (_t, hooks) => { + hooks.onProgress("installing"); + return RESULT; + }); + + await controller.start(TARGET).result; + + expect(states.map((s) => s.type)).toEqual(["running", "running", "done"]); + }); + + it("says nothing when the answer is the same as last time", async () => { + // Starting already puts it at "connecting". Reporting that again is not a + // change, and telling every window about it is a render for nothing. + const { controller, states } = harness(async (_t, hooks) => { + hooks.onProgress("connecting"); + return RESULT; + }); + + await controller.start(TARGET).result; + + expect(states.map((s) => s.type)).toEqual(["running", "done"]); + }); + + it("says nothing when it ignored the event", () => { + const { controller, states } = harness(async () => RESULT); + controller.cancel(); + controller.dismiss(); + expect(states).toHaveLength(0); + }); +}); + +describe("an executor that throws before it starts", () => { + it("does not leave the machine running with nothing running", async () => { + // Reading the server key throws where it stands, not as a rejection, and + // handlers attached to a promise that was never made never run — so the + // machine would stay running and refuse every later setup. + const { controller } = harness((() => { + throw new Error("key file is corrupt"); + }) as never); + + const run = controller.start(TARGET); + await expect(run.result).rejects.toThrow("key file is corrupt"); + + expect(controller.getState()).toMatchObject({ type: "failed" }); + controller.dismiss(); + expect(() => controller.start(TARGET)).not.toThrow(); + }); +}); + +describe("after it is disposed", () => { + it("starts nothing new, rather than working where nobody can see it", async () => { + // A disposed store keeps nothing and tells nobody. Launching anyway would + // put an install on somebody's server that no window could show, cancel, + // or hear the end of. + let launched = 0; + const { controller } = harness(async () => { + launched += 1; + return RESULT; + }); + + controller.dispose(); + expect(() => controller.start(TARGET)).toThrow(); + expect(launched).toBe(0); + }); + + it("stops telling anyone, so a late answer cannot reach a closed window", async () => { + const gate = deferred(); + const { controller, states } = harness(async () => gate.promise); + controller.start(TARGET); + const before = states.length; + + controller.dispose(); + gate.resolve(RESULT); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(states).toHaveLength(before); + }); +}); + +describe("not taking the process down", () => { + it("survives a failure nobody awaited", async () => { + const rejection = vi.fn(); + process.once("unhandledRejection", rejection); + const { controller } = harness(async () => { + throw new Error("nobody is listening"); + }); + + controller.start(TARGET); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(rejection).not.toHaveBeenCalled(); + process.off("unhandledRejection", rejection); + }); +}); diff --git a/src/coolify_setup/controller.ts b/src/coolify_setup/controller.ts new file mode 100644 index 0000000000..89d2745c4d --- /dev/null +++ b/src/coolify_setup/controller.ts @@ -0,0 +1,205 @@ +import log from "electron-log"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { createInvocationRef } from "@/state_machines/invocation_ref"; +import type { IdSource } from "@/state_machines/clock"; +import type { + SetupResult, + SetupStep, + SetupTarget, +} from "@/ipc/types/coolify_setup"; +import { + COOLIFY_SETUP_INVOCATION_KIND, + IDLE, + type CoolifySetupCommand, + type CoolifySetupEvent, + type CoolifySetupInvocationRef, + type CoolifySetupState, +} from "./state"; +import { coolifySetupTransition } from "./transition"; +import { SnapshotStore } from "@/state_machines/snapshot_store"; + +const logger = log.scope("coolify_setup_controller"); + +/** + * Runs the setup machine and performs what it asks for. + * + * The state lives here, in the main process, because the work does. The panel + * asks for a snapshot and renders it; it keeps nothing of its own, so leaving + * the screen — which the running screen invites — cannot lose the install. + * + * Every launch mints an invocation ref, and every event echoes it back. An + * answer from a run that was cancelled or superseded is ignored by the + * transition rather than guarded against here. + */ + +export interface SetupRun { + /** Resolves with what the flow produced, or rejects as the flow did. */ + readonly result: Promise; + readonly invocationRef: CoolifySetupInvocationRef; +} + +export interface CoolifySetupControllerOptions { + /** Does the work. Injected so the controller can be tested without SSH. */ + execute: ( + target: SetupTarget, + hooks: { + signal: AbortSignal; + onProgress: (step: SetupStep, output?: string) => void; + }, + ) => Promise; + ids: IdSource; + /** Told after every applied transition, so windows can be updated. */ + onChanged?: (state: CoolifySetupState) => void; +} + +export class CoolifySetupController { + /** + * The snapshot everything else reads. + * + * The shared store rather than a field and a callback: it already refuses + * to notify when the reference has not moved, which is the difference + * between `stay` and `change`, and it can be disposed so a late answer + * cannot reach windows after teardown. + */ + private readonly store = new SnapshotStore(IDLE); + private aborters = new Map(); + private runs = new Map>(); + + constructor(private readonly options: CoolifySetupControllerOptions) { + if (options.onChanged) { + this.store.subscribe(() => options.onChanged?.(this.store.getSnapshot())); + } + } + + getState(): CoolifySetupState { + return this.store.getSnapshot(); + } + + /** Stops telling anyone anything. Nothing in flight is cancelled by it. */ + dispose(): void { + this.store.dispose(); + } + + /** + * Starts a setup, or refuses because one is already going. + * + * The refusal is the machine's decision rather than a check here, so the + * rule is tested where it is made. + */ + start(target: SetupTarget): SetupRun { + const invocationRef = createInvocationRef( + COOLIFY_SETUP_INVOCATION_KIND, + target.host.trim(), + this.options.ids, + ); + const before = this.store.getSnapshot(); + this.dispatch({ type: "start-requested", invocationRef, target }); + if (this.store.getSnapshot() === before) { + // Nothing launched: the machine refused. Named for the user rather than + // for the log, because this is what the panel shows. + throw new DyadError( + "A server is already being set up. Wait for it to finish, or cancel it.", + DyadErrorKind.Precondition, + ); + } + const result = this.runs.get(invocationRef.operationId); + if (!result) { + // Unreachable: a launch command is emitted with the state change. + throw new DyadError( + "The setup did not start.", + DyadErrorKind.Precondition, + ); + } + return { result, invocationRef }; + } + + /** Asks a running setup to stop. Quiet when there is nothing to stop. */ + cancel(): void { + this.dispatch({ type: "cancel-requested" }); + } + + /** The user has read the terminal screen; put the panel back to the form. */ + dismiss(): void { + this.dispatch({ type: "dismissed" }); + } + + private dispatch(event: CoolifySetupEvent): void { + const result = coolifySetupTransition(this.store.getSnapshot(), event); + if (result.kind === "ignored") { + logger.info(`Ignored ${event.type}: ${result.reason}`); + return; + } + // Told first, then the work is started. A command that dispatches again — + // the flow reports its first step as soon as it is launched — then sends + // its own notification, so each one says something that changed rather + // than two saying the same thing. + // + // A disposed store keeps nothing and tells nobody, so running the + // commands anyway would start an install over SSH that no window could + // see, cancel, or hear the end of — while `start` reported it as running. + if (!this.store.setState(result.state)) return; + for (const command of result.commands) this.run(command); + } + + private run(command: CoolifySetupCommand): void { + switch (command.type) { + case "launch": + this.launch(command.invocationRef, command.target); + return; + case "abort": + this.aborters.get(command.invocationRef.operationId)?.abort(); + return; + } + } + + private launch( + invocationRef: CoolifySetupInvocationRef, + target: SetupTarget, + ): void { + const controller = new AbortController(); + this.aborters.set(invocationRef.operationId, controller); + + // Started inside a try, because an executor can throw before it returns a + // promise — reading the server key does, on a key file it cannot parse. + // Handlers attached to a promise that was never made never run, which + // would leave the machine running with nothing running. + let started: Promise; + try { + started = this.options.execute(target, { + signal: controller.signal, + onProgress: (step, output) => + this.dispatch({ type: "progress", invocationRef, step, output }), + }); + } catch (error) { + started = Promise.reject(error); + } + + const work = started + .then((result) => { + this.dispatch({ type: "succeeded", invocationRef, result }); + return result; + }) + .catch((error: unknown) => { + const cancelled = + (error as { kind?: string }).kind === DyadErrorKind.UserCancelled; + this.dispatch({ + type: "failed", + invocationRef, + message: error instanceof Error ? error.message : String(error), + cancelled, + }); + throw error; + }) + .finally(() => { + this.aborters.delete(invocationRef.operationId); + this.runs.delete(invocationRef.operationId); + }); + + // Kept so a second caller can await the same work rather than starting + // its own, and so the handler that asked can answer its own invoke. + this.runs.set(invocationRef.operationId, work); + // Nothing else attaches to this promise, and an install that fails with + // no window listening must not take the process down. + work.catch(() => {}); + } +} diff --git a/src/coolify_setup/https_setup.test.ts b/src/coolify_setup/https_setup.test.ts new file mode 100644 index 0000000000..5dfe6b4407 --- /dev/null +++ b/src/coolify_setup/https_setup.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, it, vi } from "vitest"; +import { + applyInstanceDomain, + plainUrlFor, + certificateDomainFor, + domainPointsAtServer, + hasTrustedCertificate, + tryEnableHttps, +} from "./https_setup"; +import type { SshSession } from "@/ipc/utils/ssh_client"; + +function transcript(output: string): string { + return [ + '> echo "__DYAD_OUT_START__" . PHP_EOL;', + "> __DYAD_OUT_START__", + output, + "__DYAD_OUT_END__", + ].join("\n"); +} + +function fakeSession() { + const commands: string[] = []; + const scripts: string[] = []; + const session: SshSession = { + run: vi.fn(async (command: string, options?: { input?: string }) => { + commands.push(command); + scripts.push(options?.input ?? ""); + return { code: 0, stdout: transcript("applied"), stderr: "" }; + }) as unknown as SshSession["run"], + end: vi.fn(), + }; + return { session, commands, scripts }; +} + +describe("certificateDomainFor", () => { + it("spells a bare address as a name, since a certificate needs one", () => { + // Let's Encrypt will not issue for an IP address. sslip.io exists so an + // address can be written as a domain that resolves back to it. + expect(certificateDomainFor("203.0.113.5")).toBe("203.0.113.5.sslip.io"); + }); + + it("prefers a name the user already has", () => { + // Theirs, and it does not spend a shared certificate allowance. + expect(certificateDomainFor("203.0.113.5", "coolify.example.com")).toBe( + "coolify.example.com", + ); + }); + + it("takes a domain however the user pasted it", () => { + expect( + certificateDomainFor("203.0.113.5", "https://coolify.example.com/"), + ).toBe("coolify.example.com"); + }); + + it("uses a hostname as given rather than wrapping it", () => { + expect(certificateDomainFor("coolify.example.com")).toBe( + "coolify.example.com", + ); + }); + + it("declines an address no certificate authority could reach", () => { + // Validation happens over the public internet. A name pointing at a LAN + // or loopback address can never be given a certificate, and asking for + // one costs the whole poll — two minutes — before the same answer. + expect(certificateDomainFor("192.168.1.50")).toBeNull(); + expect(certificateDomainFor("127.0.0.1")).toBeNull(); + expect(certificateDomainFor("10.0.0.7")).toBeNull(); + }); + + it("declines a name only this machine or this network answers to", () => { + // Same position as a private address: nothing public can validate it. + expect(certificateDomainFor("localhost")).toBeNull(); + // mDNS, which is how a homelab box is usually reached on a LAN. + expect(certificateDomainFor("coolify.local")).toBeNull(); + }); + + it("still takes an ordinary hostname", () => { + expect(certificateDomainFor("coolify.example.com")).toBe( + "coolify.example.com", + ); + }); + + it("still takes a domain the user gave for such a server", () => { + // Their own domain may point at a router that forwards to it, which is a + // different question from what the address itself can be reached at. + expect(certificateDomainFor("192.168.1.50", "coolify.example.com")).toBe( + "coolify.example.com", + ); + }); + + it("declines an IPv6 address rather than guessing at a spelling", () => { + expect(certificateDomainFor("2606:4700::1")).toBeNull(); + }); +}); + +describe("plainUrlFor", () => { + it("brackets an IPv6 address, or the URL will not parse", async () => { + const url = plainUrlFor("2606:4700::1"); + expect(url).toBe("http://[2606:4700::1]:8000"); + expect(() => new URL(url)).not.toThrow(); + }); + + it("leaves an IPv4 address and a hostname alone", () => { + expect(plainUrlFor("203.0.113.5")).toBe("http://203.0.113.5:8000"); + expect(plainUrlFor("box.example.com")).toBe("http://box.example.com:8000"); + }); +}); + +describe("applyInstanceDomain", () => { + it("sets the domain and rebuilds the proxy, which is what asks", async () => { + // The setting alone changes nothing; the proxy rebuild is what requests + // the certificate. + const { session, scripts } = fakeSession(); + await applyInstanceDomain(session, "203.0.113.5.sslip.io"); + + expect(scripts[0]).toContain("$s->fqdn ="); + expect(scripts[0]).toContain("setupDynamicProxyConfiguration"); + }); + + it("keeps the domain out of the script", async () => { + const { session, scripts, commands } = fakeSession(); + await applyInstanceDomain(session, "coolify.example.com"); + + expect(scripts[0]).not.toContain("coolify.example.com"); + expect(commands[0]).toContain( + "-e DYAD_INSTANCE_DOMAIN='coolify.example.com'", + ); + }); + + it("clears the domain when given null", async () => { + const { session, scripts } = fakeSession(); + await applyInstanceDomain(session, null); + expect(scripts[0]).toContain("$s->fqdn = null;"); + }); + + it("refuses a domain that could break out of the script", async () => { + const { session } = fakeSession(); + await expect( + applyInstanceDomain(session, "example.com'; system('rm -rf /'); '"), + ).rejects.toMatchObject({ kind: "validation" }); + }); +}); + +describe("hasTrustedCertificate", () => { + it("is false when the certificate is not trusted", async () => { + // Node rejects an untrusted certificate rather than reporting it, so the + // request failing IS the answer. A self-signed one fails here, which is + // the point: it would fail in the user's browser too. + const fetchImpl = vi.fn(async () => { + throw new Error("unable to verify the first certificate"); + }) as unknown as typeof fetch; + expect( + await hasTrustedCertificate("https://example.com", { fetchImpl }), + ).toBe(false); + }); + + it("is true when the request completes", async () => { + const fetchImpl = vi.fn(async () => ({ + status: 302, + })) as unknown as typeof fetch; + expect( + await hasTrustedCertificate("https://example.com", { fetchImpl }), + ).toBe(true); + }); +}); + +describe("domainPointsAtServer", () => { + const answers = + (addresses: string[], failed = false) => + async () => ({ addresses, failed }); + + it("is true when the domain resolves to the server", async () => { + expect( + await domainPointsAtServer("coolify.example.com", "203.0.113.5", { + resolve: answers(["203.0.113.5"]), + }), + ).toBe(true); + }); + + it("is false when it still points at something else", async () => { + // The user's old website answers HTTPS with a valid certificate of its + // own, so the certificate check alone would call this a success. + expect( + await domainPointsAtServer("example.com", "203.0.113.5", { + resolve: answers(["198.51.100.9"]), + }), + ).toBe(false); + }); + + it("does not object when the resolver could not be reached", async () => { + // Not knowing is not the same as knowing it is wrong, and this only + // decides whether to attempt something that is checked afterwards anyway. + expect( + await domainPointsAtServer("example.com", "203.0.113.5", { + resolve: answers([], true), + }), + ).toBe(true); + }); + + it("does not object when the domain has no records yet", async () => { + // It may be minutes old. The certificate wait is the real answer. + expect( + await domainPointsAtServer("example.com", "203.0.113.5", { + resolve: answers([]), + }), + ).toBe(true); + }); + + it("says nothing about a server known by a name", async () => { + const resolve = vi.fn(answers(["203.0.113.5"])); + expect( + await domainPointsAtServer("coolify.example.com", "box.example.com", { + resolve, + }), + ).toBe(true); + expect(resolve).not.toHaveBeenCalled(); + }); +}); + +describe("tryEnableHttps", () => { + const FAST = { timeoutMs: 40, intervalMs: 5 }; + + it("reports the encrypted address once a certificate arrives", async () => { + const { session } = fakeSession(); + const outcome = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + check: async () => true, + }); + + expect(outcome).toEqual({ + instanceUrl: "https://203.0.113.5.sslip.io", + secure: true, + }); + }); + + it("takes the domain back off when no certificate arrives", async () => { + // Left pointed at a domain with no certificate, Coolify would answer its + // own address with an error — a server nobody can open, which is worse + // than one that is merely unencrypted. + const { session, scripts } = fakeSession(); + const outcome = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + check: async () => false, + }); + + expect(outcome.secure).toBe(false); + expect(outcome.instanceUrl).toBe("http://203.0.113.5:8000"); + expect(outcome.reason).toBeTruthy(); + // The last thing it did was clear the domain again. + expect(scripts.at(-1)).toContain("$s->fqdn = null;"); + }); + + it("does not point Coolify at a domain that is somewhere else", async () => { + // Applying it would take the dashboard off its own address, and the + // certificate check would pass against the host that answers there. + const { session, scripts } = fakeSession(); + const outcome = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "example.com", + resolve: async () => ({ addresses: ["198.51.100.9"], failed: false }), + check: async () => true, + }); + + expect(outcome.secure).toBe(false); + expect(outcome.instanceUrl).toBe("http://203.0.113.5:8000"); + expect(outcome.reason).toContain("does not point at this server"); + expect(scripts).toHaveLength(0); + }); + + it("still uses the derived name without asking DNS about it", async () => { + // It is built from the address, so it resolves there by construction. + const resolve = vi.fn(); + const { session } = fakeSession(); + const outcome = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + resolve: resolve as never, + check: async () => true, + }); + + expect(outcome.secure).toBe(true); + expect(resolve).not.toHaveBeenCalled(); + }); + + it("explains a failed custom domain differently from a shared one", async () => { + // A domain of the user's own fails for a reason they can act on; the free + // shared one fails for a reason they cannot. + const { session } = fakeSession(); + const mine = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "coolify.example.com", + resolve: async () => ({ addresses: ["203.0.113.5"], failed: false }), + check: async () => false, + }); + expect(mine.reason).toContain("points at this server"); + + const shared = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + check: async () => false, + }); + expect(shared.reason).toContain("allowance"); + + // A server given by name uses that name directly, so the free service is + // not involved and blaming its allowance describes nothing. + const named = await tryEnableHttps(session, "coolify.example.com", { + ...FAST, + check: async () => false, + }); + expect(named.reason).not.toContain("allowance"); + expect(named.reason).toContain("points at this server"); + }); + + it("does not touch the instance when there is no domain to ask for", async () => { + const { session, scripts } = fakeSession(); + const outcome = await tryEnableHttps(session, "2606:4700::1", FAST); + + expect(outcome.secure).toBe(false); + expect(scripts).toHaveLength(0); + }); + + it("stops when cancelled", async () => { + const { session } = fakeSession(); + const controller = new AbortController(); + controller.abort(); + + await expect( + tryEnableHttps(session, "203.0.113.5", { + ...FAST, + signal: controller.signal, + check: async () => false, + }), + ).rejects.toMatchObject({ kind: "user_cancelled" }); + }); +}); diff --git a/src/coolify_setup/https_setup.ts b/src/coolify_setup/https_setup.ts new file mode 100644 index 0000000000..03cdaa108b --- /dev/null +++ b/src/coolify_setup/https_setup.ts @@ -0,0 +1,304 @@ +import { isIP } from "node:net"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import type { SshSession } from "@/ipc/utils/ssh_client"; +import { runTinker } from "./tinker"; +import { IS_TEST_BUILD } from "@/ipc/utils/test_utils"; +import { isPlausibleInstanceDomain } from "@/shared/coolify_domain"; +import { resolveBoth } from "@/ipc/utils/dns_resolve"; +import { + domainCheckVerdict, + isLoopbackAddress, + isNonRoutableAddress, +} from "@/shared/domain_check"; + +/** + * Getting the new instance onto HTTPS, if it can be had. + * + * A stock Coolify serves plain HTTP, so Dyad's API token — which carries root + * abilities and can read database connection strings — would cross the network + * in the clear on every deploy, not just once at setup. + * + * Coolify already knows how to fix this: give it a domain and its proxy asks + * Let's Encrypt for a certificate. The missing piece is the domain, and a + * server's own address is one: `1.2.3.4.sslip.io` resolves to 1.2.3.4 with + * nothing to configure. + * + * Attempted rather than required. Certificates depend on a third party that can + * refuse, so this checks whether it worked and puts the instance back on plain + * HTTP if it did not. Verified against Coolify 4.3.2: a domain that cannot be + * validated leaves port 8000 serving normally, so the fallback is a real one. + */ + +/** Long enough for the proxy to be rebuilt, short enough to give up on. */ +const APPLY_DOMAIN_TIMEOUT_MS = 60_000; + +/** It answered in about fifteen seconds on a new server; this is slack. */ +const CERTIFICATE_TIMEOUT_MS = 120_000; +const POLL_INTERVAL_MS = 6_000; + +/** + * The name to ask for a certificate for. + * + * A hostname the user gave us is already a domain and is the better answer: + * it is theirs, and it does not spend anyone else's certificate allowance. Only + * a bare address needs sslip.io, which exists precisely so an address can be + * spelled as a name. + */ +export function certificateDomainFor( + host: string, + customDomain?: string | null, +): string | null { + const custom = customDomain?.trim(); + if (custom) return custom.replace(/^https?:\/\//, "").replace(/\/+$/, ""); + + const bare = host.trim(); + if (isIP(bare) === 4) { + // A certificate authority validates over the public internet, so a name + // that resolves to a private or loopback address can never be given one. + // Deriving it anyway meant waiting out the whole certificate poll — two + // minutes — for an answer that could not arrive, which is what a homelab + // server on a LAN address would do every time. + if (isLoopbackAddress(bare) || isNonRoutableAddress(bare)) return null; + return `${bare}.sslip.io`; + } + // A name only this machine or this network answers to is in the same + // position as a private address: nothing public can validate it, and asking + // anyway costs the whole certificate wait. mDNS names are spelled out here + // rather than folded into isLoopbackAddress, which means something narrower + // — a .local is a real machine, just not one the internet can reach. + if (isLoopbackAddress(bare) || /\.local$/i.test(bare)) return null; + // IPv6 has an sslip.io spelling with dashes, but Coolify's proxy and the + // certificate authority both have more to say about IPv6 than is worth + // guessing at here. A name is used as given. + if (isIP(bare) === 6) return null; + return bare || null; +} + +export function httpsUrlFor(domain: string): string { + return `https://${domain}`; +} + +/** Bracketed when it needs to be, or the colons read as a port. */ +export function urlHost(host: string): string { + return isIP(host) === 6 ? `[${host}]` : host; +} + +/** + * Coolify's own port, or the one an e2e build was told to use. + * + * Fixed in production because Coolify's installer fixes it. Named by the + * environment under a test build so parallel workers do not have to share it. + */ +function dashboardPort(): number { + const override = IS_TEST_BUILD ? process.env.DYAD_E2E_DASHBOARD_PORT : null; + return override ? Number(override) : 8000; +} + +export function plainUrlFor(host: string): string { + return `http://${urlHost(host)}:${dashboardPort()}`; +} + +/** + * Points Coolify at a domain and reconfigures its proxy. + * + * Both halves are needed: the setting alone changes nothing until the proxy is + * rebuilt, which is what asks for the certificate. + * + * WORKAROUND: only the dashboard can set this, so it writes the model field + * and calls the proxy rebuild the dashboard calls. + * + * TODO: replace if Coolify gains an endpoint or command that sets the instance + * domain and reconfigures the proxy. + */ +export async function applyInstanceDomain( + session: SshSession, + domain: string | null, + { signal }: { signal?: AbortSignal } = {}, +): Promise { + if (domain !== null && !isPlausibleInstanceDomain(domain)) { + throw new DyadError( + `Refusing to set an unsafe instance domain: ${domain}`, + DyadErrorKind.Validation, + ); + } + const answer = await runTinker( + session, + [ + `$s = \\App\\Models\\InstanceSettings::get();`, + domain === null + ? `$s->fqdn = null;` + : `$s->fqdn = 'https://' . getenv('DYAD_INSTANCE_DOMAIN');`, + `$s->save();`, + // Server 0 is the machine Coolify runs on, which is the one serving the + // dashboard this domain points at. + `\\App\\Models\\Server::find(0)->setupDynamicProxyConfiguration();`, + `echo 'applied';`, + ].join("\n"), + { + env: domain === null ? {} : { DYAD_INSTANCE_DOMAIN: domain }, + signal, + // Bounded: the proxy rebuild is the slow part, and a server that never + // answers leaves "Setting up HTTPS" on screen with nothing behind it. + timeoutMs: APPLY_DOMAIN_TIMEOUT_MS, + }, + ); + // Checked, because a Coolify that refused still prints and still ends. Left + // unread, its error became a two-minute wait blamed on the certificate + // authority. Matched loosely: the transcript carries its own noise. + if (!answer.includes("applied")) { + throw new DyadError( + `Coolify would not take the domain: ${answer.trim()}`, + DyadErrorKind.External, + ); + } +} + +/** + * Whether the address serves HTTPS with a certificate this machine trusts. + * + * Node rejects an untrusted certificate rather than reporting it, so a request + * that resolves at all is the check. A self-signed certificate would fail here, + * which is the point: it would fail in the user's browser too. + */ +export async function hasTrustedCertificate( + url: string, + { fetchImpl = fetch }: { fetchImpl?: typeof fetch } = {}, +): Promise { + try { + const res = await fetchImpl(url, { + method: "GET", + redirect: "manual", + signal: AbortSignal.timeout(10_000), + }); + return res.status > 0; + } catch { + return false; + } +} + +/** + * Whether a domain the user gave us points at the server being set up. + * + * Asked before the domain is applied, because a certificate is not the same + * question as "is this our server". A name still pointing at the user's old + * site answers HTTPS on the first poll with a valid certificate of its own, + * and taking that as success would store that host as Coolify's address — and + * send it an API token that carries root abilities. + * + * Advisory in one direction only: a resolver we could not reach says nothing, + * so it is not treated as a wrong answer. + */ +export async function domainPointsAtServer( + domain: string, + host: string, + { resolve = resolveBoth }: { resolve?: typeof resolveBoth } = {}, +): Promise { + // Nothing to compare against: the server is known by a name rather than an + // address, and resolving it would only be asking DNS about DNS. + if (isIP(host) === 0) return true; + const resolved = await resolve(domain); + // Only a domain that resolves somewhere else is an objection. A resolver we + // could not reach and a name with no records yet both come back with nothing + // to compare, which is not knowing rather than knowing it is wrong. + return ( + domainCheckVerdict({ + expectedIps: [host], + actualIps: resolved.addresses, + }) !== "points-elsewhere" + ); +} + +export interface HttpsOutcome { + /** What Dyad should store and talk to. */ + instanceUrl: string; + secure: boolean; + /** Present when HTTPS was attempted and did not arrive. */ + reason?: string; +} + +/** + * Tries to put the instance on HTTPS, and settles for HTTP if it cannot. + * + * The instance is left on plain HTTP rather than pointed at a domain with no + * certificate: a half-configured proxy would answer the dashboard's own address + * with an error, and a working server nobody can open is worse than one that is + * merely unencrypted. + */ +export async function tryEnableHttps( + session: SshSession, + host: string, + { + customDomain, + signal, + timeoutMs = CERTIFICATE_TIMEOUT_MS, + intervalMs = POLL_INTERVAL_MS, + now = () => Date.now(), + check = hasTrustedCertificate, + resolve = resolveBoth, + onProgress, + }: { + customDomain?: string | null; + signal?: AbortSignal; + timeoutMs?: number; + intervalMs?: number; + now?: () => number; + check?: typeof hasTrustedCertificate; + resolve?: typeof resolveBoth; + onProgress?: (message: string) => void; + } = {}, +): Promise { + const domain = certificateDomainFor(host, customDomain); + if (!domain) { + return { + instanceUrl: plainUrlFor(host), + secure: false, + reason: "This address cannot be given a certificate.", + }; + } + + // Only a domain of the user's own can point somewhere else. The derived + // sslip.io name resolves to the address it was built from, by construction. + if ( + customDomain && + !(await domainPointsAtServer(domain, host, { resolve })) + ) { + return { + instanceUrl: plainUrlFor(host), + secure: false, + reason: + `${domain} does not point at this server, so a certificate for it ` + + `would not describe this machine. Point it at ${host} and set the ` + + `domain in Coolify.`, + }; + } + + const url = httpsUrlFor(domain); + onProgress?.(`Requesting a certificate for ${domain}…\n`); + await applyInstanceDomain(session, domain, { signal }); + + const deadline = now() + timeoutMs; + while (now() < deadline) { + if (signal?.aborted) { + throw new DyadError("Cancelled.", DyadErrorKind.UserCancelled); + } + if (await check(url)) { + onProgress?.(`Coolify is available over HTTPS at ${url}\n`); + return { instanceUrl: url, secure: true }; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + // Put it back, so the dashboard is reachable at the address the user will be + // given rather than at a domain whose certificate never arrived. + onProgress?.("No certificate arrived; leaving Coolify on plain HTTP.\n"); + await applyInstanceDomain(session, null, { signal }); + return { + instanceUrl: plainUrlFor(host), + secure: false, + reason: !domain.endsWith(".sslip.io") + ? `No certificate was issued for ${domain}. Check that it points at this server.` + : `No certificate was issued for ${domain}. The free service that ` + + `provides these names shares one certificate allowance between ` + + `everyone using it, and it can run out.`, + }; +} diff --git a/src/coolify_setup/install.test.ts b/src/coolify_setup/install.test.ts new file mode 100644 index 0000000000..9b38c4e985 --- /dev/null +++ b/src/coolify_setup/install.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it, vi } from "vitest"; +import { installCoolify, preflight, waitForAdminSeeded } from "./install"; +import { SshError } from "@/ipc/utils/ssh_client"; +import type { SshSession } from "@/ipc/utils/ssh_client"; +import { DyadErrorKind } from "@/errors/dyad_error"; + +/** + * What Dyad concludes when a server does not answer properly. + * + * The interesting cases here are not the ones where a server says something + * unexpected — they are the ones where it says nothing at all, because every + * answer is read out of one transcript and an empty transcript still parses. + */ + +function sessionAnswering(run: SshSession["run"]): SshSession { + return { run, end: vi.fn() } as unknown as SshSession; +} + +const HEALTHY = "mem=1967\ncontainer=\nbusy=no"; + +/** What a tinker script's output looks like coming back off the wire. */ +function transcript(output: string): string { + return [ + '> echo "__DYAD_OUT_START__" . PHP_EOL;', + "> __DYAD_OUT_START__", + output, + "__DYAD_OUT_END__", + ].join("\n"); +} + +describe("preflight", () => { + it("reads a healthy server as ready", async () => { + const session = sessionAnswering( + vi.fn(async () => ({ code: 0, stdout: HEALTHY, stderr: "" })) as never, + ); + await expect(preflight(session)).resolves.toMatchObject({ + ready: true, + alreadyInstalled: false, + memoryMb: 1967, + }); + }); + + it("refuses a probe that came back with nothing", async () => { + // An empty transcript parses as "no memory, no container, not busy" — + // which reads as a healthy empty server, and that is the one wrong answer + // that matters: it stands between the user and installing over a Coolify + // that is already there. + const session = sessionAnswering( + vi.fn(async () => ({ code: 1, stdout: "", stderr: "" })) as never, + ); + const checks = await preflight(session); + + expect(checks.ready).toBe(false); + expect(checks.alreadyInstalled).toBe(false); + expect(checks.reason).toContain("could not read"); + }); + + it("still reports a server that is busy", async () => { + const session = sessionAnswering( + vi.fn(async () => ({ + code: 0, + stdout: "mem=1967\ncontainer=\nbusy=yes", + stderr: "", + })) as never, + ); + await expect(preflight(session)).resolves.toMatchObject({ ready: false }); + }); +}); + +describe("a server that answers the connection but not the question", () => { + it("gives up on the probe rather than leaving the step running", async () => { + // A wedged docker answers nothing. Without a bound the panel sits on + // "Checking the server" until the user works out that nothing is + // happening and stops it themselves. + const asked: Array = []; + const session = sessionAnswering( + vi.fn(async (_c: string, options?: { timeoutMs?: number }) => { + asked.push(options?.timeoutMs); + return { code: 0, stdout: HEALTHY, stderr: "" }; + }) as never, + ); + + await preflight(session); + expect(asked[0]).toBeGreaterThan(0); + }); + + it("leaves the installer alone, which legitimately takes minutes", async () => { + const asked: Array = []; + const session = sessionAnswering( + vi.fn(async (command: string, options?: { timeoutMs?: number }) => { + if (command.includes("install.sh")) asked.push(options?.timeoutMs); + return { code: 0, stdout: "", stderr: "" }; + }) as never, + ); + + await installCoolify(session, { + username: "dyad-admin", + email: "me@gmail.com", + password: "Abc123@xyz", + }); + expect(asked).toEqual([undefined]); + }); +}); + +describe("waiting for the admin account", () => { + it("asks with a bound, so one hung attempt cannot outlast the loop", async () => { + // The deadline is only looked at between attempts, so an unbounded + // question outlasts every bound there is. The bound belongs in the + // command — giving up on the answer should also stop the asking — so + // what is checked here is that the question carries one. + const asked: Array<{ timeoutMs?: number }> = []; + const session = sessionAnswering( + vi.fn(async (_command: string, options?: { timeoutMs?: number }) => { + asked.push({ timeoutMs: options?.timeoutMs }); + return { code: 0, stdout: transcript("yes"), stderr: "" }; + }) as never, + ); + + await expect( + waitForAdminSeeded(session, "me@gmail.com", { + timeoutMs: 2_000, + intervalMs: 1, + attemptTimeoutMs: 20, + }), + ).resolves.toEqual({ seeded: true }); + + expect(asked[0]?.timeoutMs).toBe(20); + }); + + it("bounds the repair and the confirmation after it, not only the poll", async () => { + // The loop expiring is where the seeder runs, and the question after it + // is the same question — asked on the same server that just failed to + // answer four times. + const asked: Array<{ command: string; timeoutMs?: number }> = []; + const session = sessionAnswering( + vi.fn(async (command: string, options?: { timeoutMs?: number }) => { + asked.push({ command, timeoutMs: options?.timeoutMs }); + return { code: 0, stdout: transcript("no"), stderr: "" }; + }) as never, + ); + + await waitForAdminSeeded(session, "me@gmail.com", { + timeoutMs: 20, + intervalMs: 1, + attemptTimeoutMs: 50, + }); + + // Every question, including the seeder and the confirmation after it. + expect(asked.length).toBeGreaterThan(1); + for (const question of asked) { + expect(question.timeoutMs).toBeGreaterThan(0); + } + }); + + it("asks again when one attempt was merely slow", async () => { + // The bound is ours, not the server's: the link is fine and the poll has + // minutes left. Treating it as a dead link ended the wait on the first + // slow answer, on a server where the account had in fact been seeded. + let asked = 0; + const session = sessionAnswering( + vi.fn(async () => { + asked += 1; + if (asked === 1) { + throw new SshError( + "command-timeout", + "The server did not answer in time.", + DyadErrorKind.External, + ); + } + return { code: 0, stdout: transcript("yes"), stderr: "" }; + }) as never, + ); + + await expect( + waitForAdminSeeded(session, "me@gmail.com", { + timeoutMs: 2_000, + intervalMs: 1, + }), + ).resolves.toEqual({ seeded: true }); + expect(asked).toBeGreaterThan(1); + }); + + it("reports the seeder's words when the last question times out", async () => { + // A bound being hit is not an answer, and it must not become the answer: + // the seeder has already said why it refused, and that is what the user + // needs. Pinned because the bound and the rethrow rule are set in two + // different places and either could stop agreeing with the other. + let asked = 0; + const session = sessionAnswering( + vi.fn(async (command: string) => { + asked += 1; + if (command.includes("db:seed")) { + return { + code: 0, + stdout: "ERROR Invalid Root User Environment Variables\n", + stderr: "", + }; + } + throw new SshError( + "command-timeout", + "The server did not answer in time.", + DyadErrorKind.External, + ); + }) as never, + ); + + const outcome = await waitForAdminSeeded(session, "me@gmail.com", { + timeoutMs: 20, + intervalMs: 1, + attemptTimeoutMs: 5, + }); + + expect(outcome.seeded).toBe(false); + expect(outcome.reason).toContain("Invalid Root User"); + expect(asked).toBeGreaterThan(1); + }); + + it("does not report a dead link as Coolify refusing the address", async () => { + // Waiting longer cannot revive a connection. Swallowed, it becomes a + // complaint about the email address — after polling a dead link for a + // minute and a half and then running the seeder down it as well. + let asked = 0; + const session = sessionAnswering( + vi.fn(async () => { + asked += 1; + throw new SshError( + "timeout", + "The server stopped answering.", + DyadErrorKind.External, + ); + }) as never, + ); + + await expect( + waitForAdminSeeded(session, "me@gmail.com", { + timeoutMs: 2_000, + intervalMs: 1, + }), + ).rejects.toMatchObject({ failure: "timeout" }); + // Once: it gave up on the first answer rather than polling a dead link + // and then asking the seeder down the same one. + expect(asked).toBe(1); + }); +}); diff --git a/src/coolify_setup/install.ts b/src/coolify_setup/install.ts new file mode 100644 index 0000000000..b3e4f990d4 --- /dev/null +++ b/src/coolify_setup/install.ts @@ -0,0 +1,381 @@ +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { plainUrlFor } from "./https_setup"; +import { SshError } from "@/ipc/utils/ssh_client"; +import type { SshSession } from "@/ipc/utils/ssh_client"; +import { runTinker } from "./tinker"; +import type { AdminCredentials } from "./admin_credentials"; +import { isShellSafe } from "./admin_credentials"; + +/** + * Installing Coolify on a bare server. + * + * The installer is fetched on the server and fed to a shell there, with the + * admin account's details in the environment. Coolify seeds that account itself + * on first start, which is what saves the user a trip to the dashboard. + */ + +/** Coolify's own published installer. */ +const INSTALLER_URL = "https://cdn.coollabs.io/coolify/install.sh"; + +/** Coolify asks for 2GB; below it the install completes and then falls over. */ +const MINIMUM_MEMORY_MB = 1900; + +export interface Preflight { + ready: boolean; + /** Present when ready is false, phrased for the user. */ + reason?: string; + alreadyInstalled: boolean; + memoryMb: number | null; +} + +/** + * How long a question may take before it is not worth waiting for. + * + * Each of these is short and has an honest answer in seconds. A server whose + * docker has wedged answers none of them at all, and unbounded the step simply + * never ends — the user has to work out that nothing is happening and stop it + * themselves. The installer stays unbounded: it genuinely runs for minutes + * with nothing to say. + */ +const PROBE_TIMEOUT_MS = 30_000; +/** Longer, because the seeder writes to Coolify's own database. */ +const SEEDER_TIMEOUT_MS = 60_000; + +/** + * Looks at the server before touching it. + * + * Checked first because the install takes minutes and every one of these is + * something the user can act on immediately — and because installing over an + * existing Coolify would be far worse than declining to. + */ + +export async function preflight( + session: SshSession, + { signal }: { signal?: AbortSignal } = {}, +): Promise { + const probe = await session.run( + // One round trip rather than several: each answer is a labelled line, so a + // missing one is distinguishable from an empty one. + [ + "echo \"mem=$(awk '/MemTotal/{print int($2/1024)}' /proc/meminfo 2>/dev/null)\"", + // Whether Coolify is actually there, rather than whether a directory + // with its name is. A failed install leaves the directory behind, and + // treating that as an install refuses the retry that would fix it. + `echo "container=$(docker ps -a --filter name=^coolify$ --format '{{.Names}}' 2>/dev/null | head -1)"`, + // A cloud server runs its own updates on first boot and holds the + // package lock while it does. Coolify's installer needs that lock to + // install Docker, fails when it cannot get it, and leaves the server + // half-set-up — so this is worth a second of checking beforehand. + // Asked of the lock rather than of process names: the updater runs as + // python3 with its own name only in the command line, so no name match + // finds it. fuser is not everywhere, so a name check still backs it up. + `echo "busy=$(fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock >/dev/null 2>&1 || pgrep -x 'apt|apt-get|dpkg' >/dev/null 2>&1 && echo yes || echo no)"`, + ].join("; "), + { signal, timeoutMs: PROBE_TIMEOUT_MS }, + ); + + const read = (key: string): string | null => { + const match = new RegExp(`^${key}=(.*)$`, "m").exec(probe.stdout); + return match ? match[1].trim() : null; + }; + + // Every answer below is read out of one round trip, so an empty transcript + // is not a server with nothing on it — it is a question that never got + // asked. Answering it as "ready, no Coolify here" is the one wrong answer + // that matters: it is what stands between the user and installing over an + // instance that is already there. + if (read("mem") === null && read("busy") === null) { + return { + ready: false, + alreadyInstalled: false, + memoryMb: null, + reason: + "Dyad could not read anything back from this server. It answered the " + + "connection but not the question — check it and try again.", + }; + } + + const memoryRaw = read("mem"); + const memoryMb = + memoryRaw && /^\d+$/.test(memoryRaw) ? Number(memoryRaw) : null; + const alreadyInstalled = Boolean(read("container")); + + if (read("busy") === "yes") { + return { + ready: false, + alreadyInstalled, + memoryMb, + reason: + "This server is still finishing its own first-boot setup, which holds " + + "the package manager Coolify's installer needs. Wait a minute and " + + "check again.", + }; + } + if (alreadyInstalled) { + return { + ready: false, + alreadyInstalled, + memoryMb, + reason: + "This server already has Coolify on it. Connect to it with an API token " + + "instead of installing again.", + }; + } + if (memoryMb !== null && memoryMb < MINIMUM_MEMORY_MB) { + return { + ready: false, + alreadyInstalled, + memoryMb, + reason: + `This server has ${memoryMb}MB of memory and Coolify needs about 2GB. ` + + `Installing would finish and then fail to run.`, + }; + } + return { ready: true, alreadyInstalled, memoryMb }; +} + +/** + * The command that installs Coolify with its admin account seeded. + * + * Values are single-quoted and anything that could end that quoting is refused + * rather than escaped — these run as root on somebody else's machine, and a + * near-miss there is a command injection. + * + * The installer itself is piped to a shell, which is Coolify's documented way + * of running it. + */ +export function buildInstallCommand(credentials: AdminCredentials): string { + for (const [label, value] of Object.entries(credentials)) { + if (!isShellSafe(value)) { + throw new DyadError( + `The ${label} contains a character that cannot be sent safely.`, + DyadErrorKind.Validation, + ); + } + } + return ( + `env ROOT_USERNAME='${credentials.username}' ` + + `ROOT_USER_EMAIL='${credentials.email}' ` + + `ROOT_USER_PASSWORD='${credentials.password}' ` + + `bash -c "curl -fsSL ${INSTALLER_URL} | bash"` + ); +} + +export async function installCoolify( + session: SshSession, + credentials: AdminCredentials, + { + onOutput, + signal, + }: { onOutput?: (chunk: string) => void; signal?: AbortSignal } = {}, +): Promise { + const result = await session.run(buildInstallCommand(credentials), { + onOutput, + signal, + }); + if (result.code !== 0) { + // The installer's own last words, rather than only its exit code. Its + // most common failure on a new cloud server is losing a race for the + // package lock against the server's own first-boot updates, and that is + // only visible in what it printed. + const tail = `${result.stdout}\n${result.stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .slice(-3) + .join(" "); + throw new DyadError( + `Installing Coolify failed (exit ${result.code}).` + + (tail ? ` The server said: ${tail}` : ""), + DyadErrorKind.External, + ); + } +} + +/** + * Where the dashboard answers. + * + * The same address the setup stores and shows the user, from one definition: + * two copies meant a change to the port could move only half of them. + */ + +/** + * Waits for the dashboard to answer. + * + * The installer returns before Coolify is listening, so this is what stands + * between finishing the install and using it. Any answer counts — a redirect to + * the login page is the dashboard working, not a failure. + */ +export async function waitForDashboard( + host: string, + { + timeoutMs = 5 * 60 * 1000, + intervalMs = 5_000, + signal, + now = () => Date.now(), + fetchImpl = fetch, + }: { + timeoutMs?: number; + intervalMs?: number; + signal?: AbortSignal; + now?: () => number; + fetchImpl?: typeof fetch; + } = {}, +): Promise { + const deadline = now() + timeoutMs; + while (now() < deadline) { + if (signal?.aborted) { + throw new DyadError("Cancelled.", DyadErrorKind.UserCancelled); + } + try { + const res = await fetchImpl(plainUrlFor(host), { + method: "GET", + redirect: "manual", + signal: AbortSignal.timeout(5_000), + }); + if (res.status > 0) return true; + } catch { + // Not listening yet, which is the expected state for the first minute. + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + return false; +} + +/** + * Whether Coolify has the account yet. + * + * A question about right now, not a verdict: the account is created by a + * startup service, and the dashboard starts answering before that service has + * run. Asked once, immediately, this says "no" about a server that is merely + * still starting. + * + * WORKAROUND: nothing exposes whether the first account exists, so this asks + * the model. + * + * TODO: replace if Coolify gains a way to ask about, or create, the first user. + */ +export async function isAdminSeeded( + session: SshSession, + email: string, + { signal, timeoutMs }: { signal?: AbortSignal; timeoutMs?: number } = {}, +): Promise { + try { + const output = await runTinker( + session, + `echo \\App\\Models\\User::where('email', getenv('DYAD_ADMIN_EMAIL'))->exists() ? 'yes' : 'no';`, + { env: { DYAD_ADMIN_EMAIL: email }, signal, timeoutMs }, + ); + return output.trim() === "yes"; + } catch (error) { + // A container still starting cannot answer at all. That is not the same as + // answering no, and treating it as one is how a healthy server gets + // reported as a rejected email address. + const kind = (error as { kind?: string }).kind; + if (kind === "user_cancelled") throw error; + // A connection that has died is not a container still starting: waiting + // longer cannot help, and swallowing it reports a lost link as Coolify + // refusing the address. A bound we imposed ourselves is the opposite — + // the link is fine and the question is worth asking again, which is the + // whole reason the caller is polling. + if (error instanceof SshError && error.failure !== "command-timeout") { + throw error; + } + return false; + } +} + +/** + * Runs Coolify's own seeder and returns what it said. + * + * Used only after waiting has not produced an account. It is both the repair — + * if the startup service somehow did not run, this runs it — and the + * diagnosis, because the seeder names the reason it refuses rather than + * leaving us to guess at one. + * + * WORKAROUND: an internal seeder is the only thing that creates the first + * user, so it is invoked by name. + * + * TODO: replace if Coolify gains a supported way to create the first account. + */ +export async function runAdminSeeder( + session: SshSession, + { signal }: { signal?: AbortSignal } = {}, +): Promise { + const result = await session.run( + "docker exec -i coolify php artisan db:seed --class=RootUserSeeder --no-ansi --force", + { signal, timeoutMs: SEEDER_TIMEOUT_MS }, + ); + return `${result.stdout}\n${result.stderr}`.trim(); +} + +export interface AdminSeedOutcome { + seeded: boolean; + /** The seeder's own words, when it had something to say about refusing. */ + reason?: string; +} + +/** + * Waits for the admin account, repairing and diagnosing if it never appears. + * + * The account arrives a little after the dashboard does, so this polls rather + * than deciding on the first answer. If waiting is not enough it runs the + * seeder directly: that fixes the case where the startup service did not run, + * and where the address is genuinely refused it produces the reason instead of + * an accusation invented here. + */ +export async function waitForAdminSeeded( + session: SshSession, + email: string, + { + timeoutMs = 90_000, + intervalMs = 5_000, + attemptTimeoutMs = 20_000, + signal, + now = () => Date.now(), + }: { + timeoutMs?: number; + intervalMs?: number; + /** One question, bounded — the loop's deadline cannot bound it. */ + attemptTimeoutMs?: number; + signal?: AbortSignal; + now?: () => number; + } = {}, +): Promise { + const deadline = now() + timeoutMs; + while (now() < deadline) { + // Each attempt is bounded, not only the loop: asking goes over SSH, and a + // wedged docker daemon answers nothing at all, so an unbounded attempt + // outlasts the deadline it is supposed to be inside. Bounded in the + // command rather than raced beside it, so giving up on the answer also + // stops the question. + const answered = await isAdminSeeded(session, email, { + signal, + timeoutMs: attemptTimeoutMs, + }); + if (answered) return { seeded: true }; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + const output = await runAdminSeeder(session, { signal }); + // Asked once more, bounded like every other question. A bound that is hit + // reads as "no account yet" rather than as a failure — isAdminSeeded already + // answers that way — so the seeder's own words, which say why it refused, + // are what gets reported below. + if ( + await isAdminSeeded(session, email, { signal, timeoutMs: attemptTimeoutMs }) + ) { + return { seeded: true }; + } + + // Coolify prints its complaint as "ERROR Invalid Root User Environment + // Variables" followed by the field it objected to. Handing that back beats + // naming a cause we only assumed. + const complaint = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith("→") || /invalid|error/i.test(line)) + .join(" ") + .trim(); + return { seeded: false, reason: complaint || undefined }; +} diff --git a/src/coolify_setup/server_key.ts b/src/coolify_setup/server_key.ts new file mode 100644 index 0000000000..b62f06a42b --- /dev/null +++ b/src/coolify_setup/server_key.ts @@ -0,0 +1,72 @@ +import * as fs from "fs"; +import * as path from "path"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { getUserDataPath } from "@/paths/paths"; +import { + generateDeployKeyPair, + publicKeyFromPrivate, +} from "@/ipc/utils/coolify_deploy_key"; + +/** + * The key Dyad uses to reach a server it is setting up. + * + * Separate from the deploy keys, which are per repository and handed to GitHub + * and Coolify. This one is Dyad's own identity for logging into a machine, so + * there is one of it, it is never uploaded anywhere, and it outlives any single + * server: the user adds its public half once and can reuse it for the next + * server they set up. + * + * Kept out of ~/.ssh deliberately, for the reason the deploy keys are: that + * directory holds identities the user maintains by hand, and Dyad treats it as + * off-limits everywhere else. + */ + +const KEY_NAME = "server_access"; +const KEY_COMMENT = "dyad-server-access"; + +export function serverKeyDirPath(): string { + return path.join(getUserDataPath(), "coolify_server_key"); +} + +export function serverKeyPath(): string { + return path.join(serverKeyDirPath(), KEY_NAME); +} + +export interface ServerKey { + /** What the user adds to the server's authorized_keys. */ + publicKey: string; + /** OpenSSH format, which is what the wire library accepts. */ + privateKey: string; +} + +/** + * Returns Dyad's server key, creating it the first time. + * + * Reused rather than regenerated per server: the public half is something the + * user pastes into a console by hand, and making them do that again for every + * server would be the most tedious part of the whole flow. + */ +export function ensureServerKey(): ServerKey { + const keyPath = serverKeyPath(); + if (fs.existsSync(keyPath)) { + const privateKey = fs.readFileSync(keyPath, "utf8"); + const publicKey = publicKeyFromPrivate(privateKey); + if (publicKey) return { privateKey, publicKey }; + // A file that cannot be read as a key is worse than none: it would fail at + // connect time with something about the wire format. Say so here instead. + throw new DyadError( + `The server key at ${keyPath} could not be read. Delete it and Dyad will ` + + `generate a new one — you will need to add the new public key to your ` + + `server.`, + DyadErrorKind.Precondition, + ); + } + + const pair = generateDeployKeyPair(KEY_COMMENT); + fs.mkdirSync(serverKeyDirPath(), { recursive: true, mode: 0o700 }); + // 0600 is a no-op on Windows, where the directory's own permissions are what + // protects this. Set anyway, because it is what protects it everywhere else. + fs.writeFileSync(keyPath, pair.privateKey, { mode: 0o600 }); + fs.writeFileSync(`${keyPath}.pub`, pair.publicKey, { mode: 0o644 }); + return { privateKey: pair.privateKey, publicKey: pair.publicKey }; +} diff --git a/src/coolify_setup/setup_flow.integration.test.ts b/src/coolify_setup/setup_flow.integration.test.ts new file mode 100644 index 0000000000..a9646d8c6c --- /dev/null +++ b/src/coolify_setup/setup_flow.integration.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { runServerSetup } from "./setup_flow"; +import { connectSsh, trustOnFirstUse } from "@/ipc/utils/ssh_client"; +import type { SshSession } from "@/ipc/utils/ssh_client"; +import { + generateSshKeyPair, + startFakeSshServer, + type FakeSshServer, +} from "../../e2e-tests/helpers/fake_ssh_server"; + +/** + * The setup flow over a real SSH connection, without the app around it. + * + * Dyad's own SSH client talks to a real ssh2 server here, so preflight, the + * installer and the tinker transcripts these tests reach are parsed from the + * shapes the library actually produces rather than ones a hand-written fake + * finds convenient. + * + * Only the two HTTP steps are stubbed: whether a dashboard answers and + * whether a certificate is issued are not questions a server on loopback can + * be asked, and neither goes over SSH. + * + * These live here rather than in e2e because none of them looks at the + * screen. The packaged app proves the wiring — that the handler is registered + * and the panel renders what it returns — and that is what the two remaining + * Playwright specs are for. + */ + +const KEY = generateSshKeyPair().private; + +let server: FakeSshServer | undefined; + +beforeEach(async () => { + server = await startFakeSshServer(); +}); + +afterEach(async () => { + // Optional: a server that failed to start leaves nothing to close, and + // throwing here would bury the reason it failed under a second error. + await server?.close(); + server = undefined; +}); + +function runSetup() { + const output: string[] = []; + const done = runServerSetup({ + onProgress: ({ output: chunk }) => { + if (chunk) output.push(chunk); + }, + target: { + host: "127.0.0.1", + port: server!.port, + username: "root", + privateKey: KEY, + }, + adminEmail: "me@gmail.com", + verifyHostKey: trustOnFirstUse(() => {}), + connect: (target, verify, signal): Promise => + connectSsh(target, verify, { signal }), + // The dashboard is a URL, not a server: there is nothing on loopback to + // answer it, and answering it is not what these tests are about. + waitForDashboardImpl: async () => true, + tryEnableHttpsImpl: async (_session, host) => ({ + instanceUrl: `http://${host}:8000`, + secure: false, + reason: "A loopback address cannot be given a certificate.", + }), + }); + return { done, output }; +} + +describe("a server that refuses the install", () => { + it("reports what the installer said, not only that it failed", async () => { + // The installer's most common failure is losing a race for the package + // lock, and an exit code alone says nothing about that. Its last words do. + server!.state.installExit = 1; + const { done, output } = runSetup(); + + await expect(done).rejects.toThrow(/Coolify is up/); + // And through the stream, which is a different path: the panel's log is + // built from what arrives here, not from the message thrown at the end. + expect(output.join("")).toContain("Coolify is up"); + }); +}); + +describe("a Coolify too old to make its own token", () => { + it("finishes anyway and hands back the sign-in details", async () => { + // The server is installed and usable; only the token has to be made by + // hand. Throwing here would discard a working install. + server!.state.version = "3.1.0"; + + const result = await runSetup().done; + + expect(result.token).toBeNull(); + expect(result.tokenUnavailableReason).toBeTruthy(); + expect(result.credentials.email).toBe("me@gmail.com"); + expect(result.credentials.password).not.toBe(""); + }); +}); + +describe("a server that reports its exit status late", () => { + it("does not read the end of the output as the end of the command", async () => { + // A real sshd closes a command's output when its stdout closes and reports + // the status when the process is reaped. Closing the channel on the first + // of those loses the status — a finished install read as a failure, and + // against a real server a command killed while it was still running. + server!.state.exitAfterEofMs = 50; + + const result = await runSetup().done; + + expect(result.token).toBeTruthy(); + expect(result.version).toBeTruthy(); + }); +}); diff --git a/src/coolify_setup/setup_flow.test.ts b/src/coolify_setup/setup_flow.test.ts new file mode 100644 index 0000000000..82c63c5d97 --- /dev/null +++ b/src/coolify_setup/setup_flow.test.ts @@ -0,0 +1,490 @@ +import { describe, expect, it, vi } from "vitest"; +import { runServerSetup, type SetupStep } from "./setup_flow"; +import { waitForAdminSeeded } from "./install"; +import { tryEnableHttps } from "./https_setup"; +import type { SshSession } from "@/ipc/utils/ssh_client"; + +const REAL_TOKEN = "1|EcaUxT43T5fgdLJmnYj0702tEUC6viy5jEhO3Ujk2298db95"; + +function transcript(output: string): string { + return [ + '> echo "__DYAD_OUT_START__" . PHP_EOL;', + "> __DYAD_OUT_START__", + output, + "__DYAD_OUT_END__", + ].join("\n"); +} + +/** + * A server that answers each command by what the command is for. + * + * Matching on the command rather than on call order means a test that changes + * the number of steps does not silently start answering the wrong question. + */ +function fakeServer( + overrides: { + probe?: string; + probeAfterInstall?: string; + installExit?: number; + seeded?: string; + seederOutput?: string; + httpsWorks?: boolean; + version?: string; + apiEnabled?: string; + token?: string; + } = {}, +) { + const commands: string[] = []; + let probes = 0; + const session: SshSession = { + run: vi.fn(async (command: string, options?: { input?: string }) => { + commands.push(command); + // Tinker scripts travel on stdin, so what is being asked is in the + // input rather than in the command — which is the whole point of + // piping them. + const script = options?.input ?? ""; + if (command.includes("MemTotal")) { + probes += 1; + const after = overrides.probeAfterInstall; + return { + code: 0, + stdout: + (probes > 1 && after ? after : overrides.probe) ?? + "os=ubuntu\nmem=1967\ndir=no\ncontainer=\nbusy=no\narch=x86_64", + stderr: "", + }; + } + if (command.includes("install.sh")) { + return { + code: overrides.installExit ?? 0, + stdout: "installed", + stderr: "", + }; + } + if (script.includes("constants.coolify.version")) { + return { + code: 0, + stdout: transcript(overrides.version ?? "4.3.2"), + stderr: "", + }; + } + if (script.includes("->exists()")) { + return { + code: 0, + stdout: transcript(overrides.seeded ?? "yes"), + stderr: "", + }; + } + if (command.includes("RootUserSeeder")) { + return { + code: 0, + stdout: + overrides.seederOutput ?? + " ERROR Invalid Root User Environment Variables\n \u2192 The email field must be a valid email address.", + stderr: "", + }; + } + if (script.includes("createToken")) { + return { + code: 0, + stdout: transcript(overrides.token ?? REAL_TOKEN), + stderr: "", + }; + } + if (script.includes("setupDynamicProxyConfiguration")) { + return { code: 0, stdout: transcript("applied"), stderr: "" }; + } + if (script.includes("is_api_enabled")) { + return { + code: 0, + stdout: transcript(overrides.apiEnabled ?? "enabled"), + stderr: "", + }; + } + return { code: 0, stdout: "", stderr: "" }; + }) as unknown as SshSession["run"], + end: vi.fn(), + }; + return { session, commands, httpsWorks: overrides.httpsWorks }; +} + +function run( + server: ReturnType, + extra: Partial[0]> = {}, +) { + const steps: SetupStep[] = []; + return { + steps, + promise: runServerSetup({ + target: { host: "203.0.113.5", username: "root", privateKey: "KEY" }, + adminEmail: "admin@gmail.com", + verifyHostKey: () => true, + connect: async () => server.session, + waitForDashboardImpl: async () => true, + // The real waiter, wound right down: what matters is that it polls, and + // five-second sleeps would only make the suite slow. + // The real HTTPS logic, wound down. What matters is that it asks, checks, + // and reverts — five-second polls would only make the suite slow. + tryEnableHttpsImpl: (session, host, options) => + tryEnableHttps(session, host, { + ...options, + timeoutMs: 40, + intervalMs: 5, + check: async () => server.httpsWorks !== false, + }), + waitForAdminSeededImpl: (session, email, options) => + waitForAdminSeeded(session, email, { + ...options, + timeoutMs: 40, + intervalMs: 5, + }), + onProgress: ({ step }) => { + if (steps[steps.length - 1] !== step) steps.push(step); + }, + ...extra, + }), + }; +} + +describe("runServerSetup", () => { + it("takes a bare server all the way to a token", async () => { + const server = fakeServer(); + const { promise, steps } = run(server); + const result = await promise; + + expect(result.token).toBe(REAL_TOKEN); + expect(result.version).toBe("4.3.2"); + // HTTPS by default: the token carries root abilities and travels on every + // deploy, so the address it travels to is worth a certificate. + expect(result.dashboardUrl).toBe("https://203.0.113.5.sslip.io"); + expect(result.secure).toBe(true); + expect(result.credentials.email).toBe("admin@gmail.com"); + expect(steps).toEqual([ + "connecting", + "checking-server", + "installing", + "waiting-for-dashboard", + "verifying-account", + "securing", + "creating-token", + "done", + ]); + }); + + it("looks at the server before installing anything", async () => { + // The two things a user can fix immediately cost a second to find, and the + // install costs minutes. Finding them afterwards wastes both. + const server = fakeServer({ + probe: "os=ubuntu\nmem=1967\ndir=yes\ncontainer=coolify\nbusy=no", + }); + await expect(run(server).promise).rejects.toMatchObject({ + kind: "precondition", + }); + expect(server.commands.some((c) => c.includes("install.sh"))).toBe(false); + }); + + it("waits rather than racing the server's own first-boot updates", async () => { + // A new cloud server holds the package lock while it updates itself, and + // Coolify's installer needs that lock for Docker. Losing that race leaves + // the server half set up, which is worse than a second of checking. + const server = fakeServer({ + probe: "os=ubuntu\nmem=1967\ndir=no\ncontainer=\nbusy=yes", + }); + await expect(run(server).promise).rejects.toThrow(/first-boot setup/); + expect(server.commands.some((c) => c.includes("install.sh"))).toBe(false); + }); + + it("allows a retry after an install that did not finish", async () => { + // A failed install leaves /data/coolify behind with nothing running. + // Reading that as "already installed" refuses the retry that would fix it. + const server = fakeServer({ + probe: "os=ubuntu\nmem=1967\ndir=yes\ncontainer=\nbusy=no", + }); + await expect(run(server).promise).resolves.toBeTruthy(); + }); + + it("refuses a server too small to run what it would install", async () => { + const server = fakeServer({ + probe: "os=ubuntu\nmem=980\ndir=no\ncontainer=\nbusy=no", + }); + await expect(run(server).promise).rejects.toMatchObject({ + kind: "precondition", + }); + expect(server.commands.some((c) => c.includes("install.sh"))).toBe(false); + }); + + it("keeps the install when only the token could not be created", async () => { + // The server is set up and usable; throwing here would discard that over + // the one step the user can complete by hand. + const server = fakeServer({ version: "3.1.0" }); + const result = await run(server).promise; + + expect(result.token).toBeNull(); + expect(result.tokenUnavailableReason).toBeTruthy(); + expect(result.dashboardUrl).toBe("https://203.0.113.5.sslip.io"); + expect(result.credentials.password).toBeTruthy(); + }); + + it("keeps the install when HTTPS cannot even be attempted", async () => { + // The server is installed and running by this point. HTTPS improves it; + // failing to get it must not be able to throw it away. + const server = fakeServer(); + const result = await run(server, { + tryEnableHttpsImpl: async () => { + throw new Error("proxy would not restart"); + }, + }).promise; + + expect(result.secure).toBe(false); + expect(result.dashboardUrl).toBe("http://203.0.113.5:8000"); + expect(result.insecureReason).toContain("proxy would not restart"); + expect(result.credentials.password).toBeTruthy(); + }); + + it("still stops when the user cancels during HTTPS", async () => { + // Cancelling is the user asking for the work to stop, which is not the + // same as a step that could not be done. + const server = fakeServer(); + await expect( + run(server, { + tryEnableHttpsImpl: async () => { + throw Object.assign(new Error("Cancelled."), { + kind: "user_cancelled", + }); + }, + }).promise, + ).rejects.toMatchObject({ kind: "user_cancelled" }); + }); + + it("gives up on a server that stops answering after a failed install", async () => { + // A frozen machine leaves the connection half-open: the question never + // comes back and nothing else times it out. Unbounded, the run never + // settles and the one-at-a-time slot is never freed, so no later setup + // can start at all. + const server = fakeServer({ installExit: 1 }); + const original = server.session.run as unknown as ( + command: string, + options?: unknown, + ) => Promise; + let installed = false; + server.session.run = (async (command: string, options?: unknown) => { + if (command.includes("install.sh")) { + installed = true; + return original(command, options); + } + // Every probe after the install goes unanswered. + if (installed) return new Promise(() => {}); + return original(command, options); + }) as unknown as SshSession["run"]; + + await expect( + run(server, { recoveryProbeTimeoutMs: 20 }).promise, + ).rejects.toThrow(/Installing Coolify failed/); + }); + + it("hands the account over when a failed install still left Coolify there", async () => { + // install.sh writes the password into Coolify's own .env and brings the + // stack up partway through. Failing after that leaves an account nobody + // else knows the password for, and preflight refuses to install again. + const server = fakeServer({ + installExit: 1, + probeAfterInstall: "os=ubuntu\nmem=1967\ncontainer=coolify\nbusy=no", + }); + const seen: string[] = []; + await expect( + run(server, { + onAccountKnown: ({ credentials }) => seen.push(credentials.password), + }).promise, + ).rejects.toThrow(); + + expect(seen).toHaveLength(1); + }); + + it("keeps a failed install from overwriting another server's password", async () => { + // The common install failure is losing the package lock, which happens + // before install.sh writes anything. Handing the account over at that + // point would replace the credentials for a server that does exist with + // ones for a server that does not. + const server = fakeServer({ installExit: 1 }); + const seen: string[] = []; + await expect( + run(server, { + onAccountKnown: ({ credentials }) => seen.push(credentials.password), + }).promise, + ).rejects.toThrow(); + + expect(seen).toHaveLength(0); + }); + + it("hands over the account even when the dashboard never answers", async () => { + // The poll runs on the user's side of their firewall; the account is + // created by the server from the .env the installer already wrote. A + // closed port 8000 says nothing about whether the account exists. + const server = fakeServer(); + const seen: string[] = []; + await expect( + run(server, { + onAccountKnown: ({ credentials }) => seen.push(credentials.password), + waitForDashboardImpl: async () => false, + }).promise, + ).rejects.toThrow(/dashboard did not start/); + + expect(seen).toHaveLength(1); + expect(seen[0]).toBeTruthy(); + }); + + it("hands over the account as soon as it exists, not at the end", async () => { + // Everything after this point can fail on a server that is installed and + // running. Dyad invented this password and never showed it, so a caller + // that only learns it on success cannot store what it never received. + const server = fakeServer(); + const seen: Array<{ password: string; dashboardUrl: string }> = []; + await expect( + run(server, { + onAccountKnown: ({ credentials, dashboardUrl }) => + seen.push({ password: credentials.password, dashboardUrl }), + tryEnableHttpsImpl: async () => { + throw Object.assign(new Error("Cancelled."), { + kind: "user_cancelled", + }); + }, + }).promise, + ).rejects.toMatchObject({ kind: "user_cancelled" }); + + expect(seen).toHaveLength(1); + expect(seen[0].password).toBeTruthy(); + expect(seen[0].dashboardUrl).toBe("http://203.0.113.5:8000"); + }); + + it("says so again once the address settles on HTTPS", async () => { + // The address is part of signing in, and it is not known until the + // certificate is. Left at the first answer, a later save would read as a + // different Coolify and drop the account. + const server = fakeServer(); + const seen: string[] = []; + await run(server, { + onAccountKnown: ({ dashboardUrl }) => seen.push(dashboardUrl), + }).promise; + + expect(seen).toEqual([ + "http://203.0.113.5:8000", + "https://203.0.113.5.sslip.io", + ]); + }); + + it("keeps the install when enabling the API fails outright", async () => { + const server = fakeServer({ apiEnabled: "still-disabled" }); + const result = await run(server).promise; + + expect(result.token).toBeNull(); + expect(result.tokenUnavailableReason).toContain("API"); + }); + + it("reports the seeder's own reason rather than inventing one", async () => { + // The account never appears, so the seeder is run directly and what it + // says is handed back. Guessing here is how a working gmail address got + // reported as a domain that does not resolve. + const server = fakeServer({ seeded: "no" }); + await expect(run(server).promise).rejects.toThrow( + /must be a valid email address/, + ); + }); + + it("waits for an account that has not been created yet", async () => { + // The dashboard answers before the startup service that seeds the account + // has run, so the first answer is no on a perfectly healthy server. + let asked = 0; + const server = fakeServer(); + const inner = server.session.run as unknown as ( + c: string, + o?: { input?: string }, + ) => Promise; + server.session.run = (async (c: string, o?: { input?: string }) => { + if ((o?.input ?? "").includes("->exists()")) { + asked += 1; + return { + code: 0, + stdout: transcript(asked === 1 ? "no" : "yes"), + stderr: "", + }; + } + return inner(c, o); + }) as unknown as SshSession["run"]; + + await expect(run(server).promise).resolves.toBeTruthy(); + expect(asked).toBeGreaterThan(1); + }); + + it("falls back to plain HTTP when no certificate arrives", async () => { + // Certificates depend on a third party that can refuse. A server nobody + // can open would be worse than one that is merely unencrypted, so the + // domain is taken back off and the address returns to port 8000. + const server = fakeServer({ httpsWorks: false }); + const result = await run(server).promise; + + expect(result.secure).toBe(false); + expect(result.dashboardUrl).toBe("http://203.0.113.5:8000"); + expect(result.insecureReason).toBeTruthy(); + // Taken back off, so the dashboard answers at the address handed over. + const reverts = server.commands.filter((c) => c.includes("tinker")).length; + expect(reverts).toBeGreaterThan(0); + }); + + it("stops when the dashboard never answers", async () => { + const server = fakeServer(); + await expect( + run(server, { waitForDashboardImpl: async () => false }).promise, + ).rejects.toMatchObject({ kind: "external" }); + }); + + it("fails when the install itself fails", async () => { + const server = fakeServer({ installExit: 1 }); + await expect(run(server).promise).rejects.toMatchObject({ + kind: "external", + }); + }); + + it("closes the connection whichever way it ends", async () => { + const ok = fakeServer(); + await run(ok).promise; + expect(ok.session.end).toHaveBeenCalled(); + + const bad = fakeServer({ installExit: 1 }); + await run(bad).promise.catch(() => {}); + expect(bad.session.end).toHaveBeenCalled(); + }); + + it("passes cancellation through rather than reporting it as a token problem", async () => { + // A cancelled setup is the user's decision, not an instance that could not + // be driven — reporting it as the latter would claim a server exists. + const server = fakeServer(); + server.session.run = vi.fn( + async (command: string, options?: { input?: string }) => { + const script = options?.input ?? ""; + if (script.includes("constants.coolify.version")) { + throw Object.assign(new Error("Cancelled."), { + kind: "user_cancelled", + }); + } + if (command.includes("MemTotal")) { + return { + code: 0, + stdout: "os=ubuntu\nmem=1967\ndir=no\ncontainer=\nbusy=no", + stderr: "", + }; + } + if (script.includes("->exists()")) { + return { code: 0, stdout: transcript("yes"), stderr: "" }; + } + if (script.includes("setupDynamicProxyConfiguration")) { + return { code: 0, stdout: transcript("applied"), stderr: "" }; + } + return { code: 0, stdout: "", stderr: "" }; + }, + ) as unknown as SshSession["run"]; + + await expect(run(server).promise).rejects.toMatchObject({ + kind: "user_cancelled", + }); + }); +}); diff --git a/src/coolify_setup/setup_flow.ts b/src/coolify_setup/setup_flow.ts new file mode 100644 index 0000000000..d889d24f29 --- /dev/null +++ b/src/coolify_setup/setup_flow.ts @@ -0,0 +1,277 @@ +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import type { + HostKeyVerifier, + SshSession, + SshTarget, +} from "@/ipc/utils/ssh_client"; +import { buildAdminCredentials } from "./admin_credentials"; +import type { AdminCredentials } from "./admin_credentials"; +import { + installCoolify, + preflight, + waitForAdminSeeded, + waitForDashboard, +} from "./install"; +import { tryAutomaticAccess } from "./api_token"; +import { plainUrlFor, tryEnableHttps } from "./https_setup"; +import type { HttpsOutcome } from "./https_setup"; + +/** + * Taking a bare server to a Coolify Dyad can deploy to. + * + * The order is not arbitrary: each step is the cheapest way to fail from where + * it sits. Looking at the server costs a second and rules out the two problems + * a user can fix immediately; installing costs minutes; and asking for a token + * only makes sense once there is an instance to ask about. + */ + +export type SetupStep = + | "connecting" + | "checking-server" + | "installing" + | "waiting-for-dashboard" + | "verifying-account" + | "securing" + | "creating-token" + | "done"; + +export interface SetupProgress { + step: SetupStep; + /** Installer output, forwarded so a long step does not look like a hang. */ + output?: string; +} + +export interface SetupResult { + dashboardUrl: string; + /** Whether the address above is encrypted. */ + secure: boolean; + /** Present when HTTPS was attempted and could not be had. */ + insecureReason?: string; + credentials: AdminCredentials; + /** + * Absent when Coolify was installed but its API could not be opened. + * + * Not an error: the server is set up and usable either way, and the caller + * asks for a token by hand rather than throwing away a working install. + */ + token: string | null; + version: string | null; + /** Present when token is null, phrased for the user. */ + tokenUnavailableReason?: string; +} + +/** + * How long the after-a-failure question may take. + * + * It goes to a server that has just failed and may be frozen. The connection + * now notices a peer that stops answering, but only after its own keepalive + * has run out — minutes, against a question worth seconds. Bounded here so + * the answer arrives while the failure it is about is still on screen. + */ +const RECOVERY_PROBE_TIMEOUT_MS = 15_000; + +export interface SetupOptions { + target: SshTarget; + adminEmail: string; + verifyHostKey: HostKeyVerifier; + onProgress?: (progress: SetupProgress) => void; + signal?: AbortSignal; + /** Injected so the flow can be exercised without a server. */ + connect: ( + target: SshTarget, + verify: HostKeyVerifier, + signal?: AbortSignal, + ) => Promise; + waitForDashboardImpl?: typeof waitForDashboard; + waitForAdminSeededImpl?: typeof waitForAdminSeeded; + tryEnableHttpsImpl?: typeof tryEnableHttps; + /** Bounded so a frozen server cannot hold the setup open. */ + recoveryProbeTimeoutMs?: number; + /** A domain the user owns, used instead of one derived from the address. */ + customDomain?: string | null; + /** + * The account exists on the user's server from here on. + * + * Dyad invented this password and never showed it, so anything that fails + * after this point and takes the password with it leaves the user locked out + * of a Coolify that is installed and running. Called again once the address + * settles, since HTTPS can change it. + */ + onAccountKnown?: (account: { + credentials: AdminCredentials; + dashboardUrl: string; + }) => void; +} + +export async function runServerSetup({ + target, + adminEmail, + verifyHostKey, + onProgress, + signal, + connect, + onAccountKnown, + recoveryProbeTimeoutMs = RECOVERY_PROBE_TIMEOUT_MS, + waitForDashboardImpl = waitForDashboard, + waitForAdminSeededImpl = waitForAdminSeeded, + tryEnableHttpsImpl = tryEnableHttps, + customDomain, +}: SetupOptions): Promise { + const report = (step: SetupStep, output?: string) => + onProgress?.({ step, output }); + + report("connecting"); + const session = await connect(target, verifyHostKey, signal); + + try { + report("checking-server"); + const checks = await preflight(session, { signal }); + if (!checks.ready) { + throw new DyadError( + checks.reason ?? "This server cannot be set up automatically.", + DyadErrorKind.Precondition, + ); + } + + const credentials = buildAdminCredentials(adminEmail); + + report("installing"); + try { + await installCoolify(session, credentials, { + signal, + onOutput: (chunk) => report("installing", chunk), + }); + } catch (error) { + // The installer writes this password into Coolify's own .env and brings + // the stack up partway through its run, so a failure after that point + // leaves an account nobody else knows the password for — and preflight + // refuses to install again once the container exists. Asked without the + // signal, because a cancel is one of the ways to arrive here. + let timer: ReturnType | undefined; + try { + const after = await Promise.race([ + preflight(session, {}), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("The server did not answer.")), + recoveryProbeTimeoutMs, + ); + }), + ]); + if (after.alreadyInstalled) { + onAccountKnown?.({ + credentials, + dashboardUrl: plainUrlFor(target.host), + }); + } + } catch { + // Nothing to add: the installer's own error is the one that matters. + } finally { + clearTimeout(timer); + } + throw error; + } + + // Once the installer has finished, because only then is this password + // certainly on the machine: install.sh writes it into Coolify's own .env + // and the account is seeded from there. Before the checks below, because + // every one of them can fail on a server that is running fine — the + // dashboard poll runs on the user's side of their firewall — and Dyad is + // the only thing that knows what it invented. + onAccountKnown?.({ + credentials, + dashboardUrl: plainUrlFor(target.host), + }); + + report("waiting-for-dashboard"); + const answered = await waitForDashboardImpl(target.host, { signal }); + if (!answered) { + throw new DyadError( + "Coolify was installed but its dashboard did not start. Check the " + + "server and try again.", + DyadErrorKind.External, + ); + } + + // Waited for rather than checked once: the account is created by a startup + // service that runs after the dashboard starts answering, so asking + // immediately says no about a server that is only still starting. + report("verifying-account"); + const seeded = await waitForAdminSeededImpl(session, credentials.email, { + signal, + }); + if (!seeded.seeded) { + throw new DyadError( + seeded.reason + ? `Coolify would not create its admin account: ${seeded.reason}` + : `Coolify has not created an admin account for ${credentials.email}. ` + + `The server is installed — sign in at the address below to finish ` + + `setting it up.`, + DyadErrorKind.External, + ); + } + + // Before the token, so what gets stored is the address the token will + // travel to. It carries root abilities and goes over the wire on every + // deploy, not once at setup, which is what makes plain HTTP worth this. + report("securing"); + let https: HttpsOutcome; + try { + https = await tryEnableHttpsImpl(session, target.host, { + customDomain, + signal, + onProgress: (message) => report("securing", message), + }); + } catch (error) { + // This improves a server that already works, so it must not be able to + // throw one away. A domain left set with no certificate still leaves + // port 8000 serving. + if ((error as { kind?: string }).kind === "user_cancelled") throw error; + https = { + instanceUrl: plainUrlFor(target.host), + secure: false, + reason: + error instanceof Error + ? error.message + : "HTTPS could not be set up on this server.", + }; + } + onAccountKnown?.({ credentials, dashboardUrl: https.instanceUrl }); + + report("creating-token"); + const result: SetupResult = { + dashboardUrl: https.instanceUrl, + secure: https.secure, + insecureReason: https.reason, + credentials, + token: null, + version: null, + }; + try { + const access = await tryAutomaticAccess(session, credentials.email, { + signal, + }); + if (access) { + result.token = access.token; + result.version = access.version; + } else { + result.tokenUnavailableReason = + "This version of Coolify could not be set up automatically."; + } + } catch (error) { + // The install stands whatever happened here, so this reports rather than + // throws: losing a working server because the last step failed would be + // the worse outcome by far. + if ((error as { kind?: string }).kind === "user_cancelled") throw error; + result.tokenUnavailableReason = + error instanceof Error + ? error.message + : "Coolify's API could not be opened automatically."; + } + + report("done"); + return result; + } finally { + session.end(); + } +} diff --git a/src/coolify_setup/state.ts b/src/coolify_setup/state.ts new file mode 100644 index 0000000000..d367063c57 --- /dev/null +++ b/src/coolify_setup/state.ts @@ -0,0 +1,166 @@ +import type { InvocationRef } from "@/state_machines/invocation_ref"; +import type { + StaleOperationIgnoreReason, + TransitionResult as GenericTransitionResult, +} from "@/state_machines/types"; + +/** + * Written out here rather than imported from the wire schema. + * + * A pure machine module may not reach into `src/ipc` at all — the boundary + * check does not care that an import is type-only, and it is right not to: + * the rule is about which side owns the definition. The schemas beside + * `SetupSnapshotSchema` assert against these, so neither can drift. + */ +export type SetupStep = + | "connecting" + | "checking-server" + | "installing" + | "waiting-for-dashboard" + | "verifying-account" + | "securing" + | "creating-token" + | "done"; + +export interface SetupTarget { + host: string; + username: string; + port?: number; + adminEmail: string; + customDomain?: string; +} + +export interface SetupResult { + dashboardUrl: string; + secure: boolean; + insecureReason: string | null; + adminEmail: string; + adminPassword: string; + tokenStored: boolean; + tokenUnavailableReason: string | null; + version: string | null; +} + +/** + * Types for the machine that sets up one Coolify server. + * + * Types only: the pure transition lives in `transition.ts` and the side + * effects in `controller.ts`. The state belongs to the main process because + * that is where the work happens — an install outlives the screen that started + * it, since leaving that screen is invited and a background refetch can + * replace it. A copy in the renderer goes stale the moment either happens. + * + * Every event from a running setup echoes its invocation ref, so an answer + * from a run that was cancelled or superseded cannot revive or overwrite + * whatever the user is looking at now. Correlation is by operation rather than + * by address: the same server can be set up twice, and the second run must not + * inherit the first one's answers. + * + * Nothing here reaches into `src/ipc`: a pure machine module owns its own + * types, and the wire schema asserts against them rather than the other way + * round. + */ + +export const COOLIFY_SETUP_INVOCATION_KIND = "coolify-setup" as const; + +/** Keyed by host: one machine at a time, and which one matters. */ +export type CoolifySetupInvocationRef = InvocationRef< + typeof COOLIFY_SETUP_INVOCATION_KIND, + string +>; + +/** How much installer output is kept. The end is what explains a failure. */ +export const MAX_LOG_CHARS = 20_000; + +export interface CoolifySetupRunning { + type: "running"; + host: string; + invocationRef: CoolifySetupInvocationRef; + step: SetupStep; + log: string; + /** True once the user has asked it to stop and before it has. */ + stopping: boolean; +} + +export interface CoolifySetupDone { + type: "done"; + host: string; + invocationRef: CoolifySetupInvocationRef; + result: SetupResult; +} + +export interface CoolifySetupFailed { + type: "failed"; + host: string; + invocationRef: CoolifySetupInvocationRef; + message: string; + /** Kept: the installer's own last words are what explain the failure. */ + log: string; + /** Cancelling is the user's decision, not a fault of the install. */ + cancelled: boolean; +} + +export type CoolifySetupState = + | { type: "idle" } + | CoolifySetupRunning + | CoolifySetupDone + | CoolifySetupFailed; + +export const IDLE: CoolifySetupState = { type: "idle" }; + +/** + * What the machine asks the controller to do. + * + * Kept out of the handler so the rules that decide them are testable without + * a server: one setup at a time, and cancelling only something that is + * running. + */ +export type CoolifySetupCommand = + | { + type: "launch"; + invocationRef: CoolifySetupInvocationRef; + target: SetupTarget; + } + | { type: "abort"; invocationRef: CoolifySetupInvocationRef }; + +export type CoolifySetupEvent = + | { + type: "start-requested"; + invocationRef: CoolifySetupInvocationRef; + target: SetupTarget; + } + | { type: "cancel-requested" } + | { + type: "progress"; + invocationRef: CoolifySetupInvocationRef; + step: SetupStep; + output?: string; + } + | { + type: "succeeded"; + invocationRef: CoolifySetupInvocationRef; + result: SetupResult; + } + | { + type: "failed"; + invocationRef: CoolifySetupInvocationRef; + message: string; + cancelled: boolean; + } + /** The user has read the terminal screen and moved on. */ + | { type: "dismissed" }; + +export type CoolifySetupIgnoreReason = + | StaleOperationIgnoreReason + /** One server at a time: two installs would fight over the same docker. */ + | "already-running" + /** Nothing is running, so there is nothing this could belong to. */ + | "not-running" + /** Dismissing while it runs would hide work that is still going on. */ + | "still-running"; + +export type TransitionResult = GenericTransitionResult< + CoolifySetupState, + CoolifySetupCommand, + CoolifySetupIgnoreReason +>; diff --git a/src/coolify_setup/tinker.test.ts b/src/coolify_setup/tinker.test.ts new file mode 100644 index 0000000000..e3261d097b --- /dev/null +++ b/src/coolify_setup/tinker.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from "vitest"; +import { extractOutput, runTinker, tinkerCommand, wrapScript } from "./tinker"; +import type { SshSession } from "@/ipc/utils/ssh_client"; + +/** + * A transcript captured from Coolify 4.3.2 rather than written by hand. + * + * The shape is the whole reason this module exists: tinker echoes every line it + * is fed with a `> ` prefix, so the markers appear twice — and the first line of + * real output shares a line with the last prompt. + */ +const REAL_TRANSCRIPT = [ + '> echo "__DYAD_OUT_START__" . PHP_EOL;', + "", + '> echo "line-one" . PHP_EOL; echo "line-two" . PHP_EOL;', + '> echo "__DYAD_OUT_END__" . PHP_EOL;', + "> __DYAD_OUT_START__", + "line-one", + "line-two", + "__DYAD_OUT_END__", +].join("\n"); + +function fakeSession( + onRun: (command: string, options?: { input?: string }) => { stdout: string }, +): SshSession & { calls: Array<{ command: string; input?: string }> } { + const calls: Array<{ command: string; input?: string }> = []; + return { + calls, + run: vi.fn(async (command: string, options?: { input?: string }) => { + calls.push({ command, input: options?.input }); + return { code: 0, stderr: "", ...onRun(command, options) }; + }) as unknown as SshSession["run"], + end: vi.fn(), + }; +} + +describe("extractOutput", () => { + it("takes the printed output, not the echo of the line printing it", () => { + // Both appear in the transcript. Matching the marker without its prompt + // would find the echoed input first and return the script back. + expect(extractOutput(REAL_TRANSCRIPT)).toBe("line-one\nline-two"); + }); + + it("says nothing was found when the script never ran", () => { + // A container still starting answers with an error and no markers at all. + // Reading that as empty output would let a caller treat "no token" as a + // fact rather than as a failure. + expect( + extractOutput("Error response from daemon: container not running"), + ).toBeNull(); + }); + + it("says nothing was found when the output stops halfway", () => { + const truncated = REAL_TRANSCRIPT.split("\n").slice(0, 6).join("\n"); + expect(extractOutput(truncated)).toBeNull(); + }); + + it("finds output from a script that printed no trailing newline", () => { + // The real shape before wrapScript was fixed: value and marker on one line. + const glued = [ + '> echo "__DYAD_OUT_START__" . PHP_EOL;', + "> __DYAD_OUT_START__", + "yes", + "__DYAD_OUT_END__", + ].join("\n"); + expect(extractOutput(glued)).toBe("yes"); + }); + + it("survives carriage returns", () => { + expect(extractOutput(REAL_TRANSCRIPT.replace(/\n/g, "\r\n"))).toBe( + "line-one\nline-two", + ); + }); +}); + +describe("tinkerCommand", () => { + it("attaches stdin", () => { + // Without -i docker does not attach stdin, so the script is never read and + // the command succeeds having done nothing at all. + expect(tinkerCommand()).toContain("docker exec -i "); + }); +}); + +describe("runTinker", () => { + it("feeds the script on stdin rather than the command line", async () => { + const session = fakeSession(() => ({ stdout: REAL_TRANSCRIPT })); + await runTinker(session, 'echo "line-one" . PHP_EOL;'); + + expect(session.calls[0].input).toContain('echo "line-one" . PHP_EOL;'); + expect(session.calls[0].command).not.toContain("line-one"); + }); + + it("passes a secret by name and value, quoted", async () => { + const session = fakeSession(() => ({ stdout: REAL_TRANSCRIPT })); + await runTinker(session, "echo getenv('DYAD_SECRET');", { + env: { DYAD_SECRET: "p@ssw0rd-+=" }, + }); + + expect(session.calls[0].command).toContain("-e DYAD_SECRET='p@ssw0rd-+='"); + // The value stays out of the script, so it never meets PHP's parser too. + expect(session.calls[0].input).not.toContain("p@ssw0rd"); + }); + + it("refuses a value that would escape its quoting", async () => { + const session = fakeSession(() => ({ stdout: REAL_TRANSCRIPT })); + // Rejected rather than escaped: getting this subtly wrong runs arbitrary + // text as a command on the user's server. + await expect( + runTinker(session, "echo 1;", { + env: { DYAD_SECRET: "a'; rm -rf /; '" }, + }), + ).rejects.toMatchObject({ kind: "internal" }); + }); + + it("refuses a variable name that is not a plain identifier", async () => { + const session = fakeSession(() => ({ stdout: REAL_TRANSCRIPT })); + await expect( + runTinker(session, "echo 1;", { env: { "X; rm -rf /": "v" } }), + ).rejects.toMatchObject({ kind: "internal" }); + }); + + it("fails loudly when the script did not run", async () => { + const session = fakeSession(() => ({ stdout: "container not running" })); + await expect(runTinker(session, "echo 1;")).rejects.toMatchObject({ + kind: "external", + }); + }); + + it("targets a named container when asked", async () => { + const session = fakeSession(() => ({ stdout: REAL_TRANSCRIPT })); + await runTinker(session, "echo 1;", { container: "coolify-staging" }); + expect(session.calls[0].command).toContain(" coolify-staging "); + }); +}); + +describe("wrapScript", () => { + it("breaks the line before the closing marker", () => { + // Captured from a real run: a script ending without PHP_EOL produced + // `yes__DYAD_OUT_END__`, and the marker was never found. + expect(wrapScript("echo 'yes';")).toContain( + 'echo PHP_EOL . "__DYAD_OUT_END__"', + ); + }); + + it("puts the body between the markers", () => { + const wrapped = wrapScript("echo 42;"); + const lines = wrapped.split("\n"); + expect(lines[0]).toContain("__DYAD_OUT_START__"); + expect(lines[1]).toBe("echo 42;"); + expect(lines[2]).toContain("__DYAD_OUT_END__"); + }); +}); diff --git a/src/coolify_setup/tinker.ts b/src/coolify_setup/tinker.ts new file mode 100644 index 0000000000..3bd7a6f07c --- /dev/null +++ b/src/coolify_setup/tinker.ts @@ -0,0 +1,144 @@ +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import type { SshSession } from "@/ipc/utils/ssh_client"; + +/** + * Running PHP inside the Coolify container. + * + * Coolify has no supported interface for what setup needs — turning the API + * on, minting a token, creating and finding the first user, setting the + * instance domain, reading the version before the API is reachable — so each + * is done by driving Laravel directly. That is a workaround, not an interface: + * every caller carries a TODO naming what an official API would replace. + */ + +/** + * Marks where our output starts and stops. + * + * tinker echoes every line it is fed, prefixed `> `, and the first line of real + * output lands on the same line as the last prompt. So the transcript contains + * the marker twice: once in the echo of the line that prints it, once as the + * output itself. Anchoring on `> MARKER` alone would match the echo. + */ +const START = "__DYAD_OUT_START__"; +const END = "__DYAD_OUT_END__"; + +/** + * The command that feeds a script to tinker. + * + * `-i` matters: without it docker does not attach stdin, the script is never + * read, and the whole thing succeeds having done nothing. + */ +export function tinkerCommand(container = "coolify"): string { + return `docker exec -i ${container} php artisan tinker --no-ansi`; +} + +/** + * Wraps a script so its output can be found in the transcript. + * + * The closing marker starts with a newline of its own because a script whose + * last echo omits PHP_EOL would otherwise leave the marker stuck to the end of + * the value — `yes__DYAD_OUT_END__` — and nothing would find it. Putting the + * break here means callers do not have to remember. + */ +export function wrapScript(body: string): string { + return [ + `echo "${START}" . PHP_EOL;`, + body.trim(), + `echo PHP_EOL . "${END}" . PHP_EOL;`, + ].join("\n"); +} + +/** + * Pulls our output back out of a tinker transcript. + * + * The opening marker is matched with its prompt attached, because that is what + * distinguishes the real output from the echo of the line that produced it. + */ +export function extractOutput(transcript: string): string | null { + const lines = transcript.split(/\r?\n/); + const startAt = lines.findIndex((line) => line.trimEnd() === `> ${START}`); + if (startAt === -1) return null; + const rest = lines.slice(startAt + 1); + const endAt = rest.findIndex((line) => line.trimEnd() === END); + if (endAt === -1) return null; + return rest.slice(0, endAt).join("\n").trim(); +} + +export interface TinkerOptions { + /** + * Values the script reads with getenv(). + * + * Secrets travel this way rather than being written into the script, so they + * never have to survive PHP's parser as well as the shell's. They are still + * single-quoted into the docker command — sshd forwards almost no environment + * by default, so there is no way to hand them over out of band. + */ + env?: Record; + container?: string; + signal?: AbortSignal; + /** Passed through: a tinker one-liner has a short honest answer. */ + timeoutMs?: number; +} + +/** + * Renders values as docker -e arguments. + * + * Rejects rather than escapes: a value carrying a quote or a backslash would + * end the quoting, and getting that subtly wrong runs arbitrary text as a + * command on the user's server. Everything passed here is generated by Dyad, + * so a rejection is a bug on our side rather than something a user hits. + */ +function envArgs(env: Record): string { + return Object.entries(env) + .map(([name, value]) => { + if (!/^[A-Z][A-Z0-9_]*$/.test(name)) { + throw new DyadError( + `Unsafe environment variable name: ${name}`, + DyadErrorKind.Internal, + ); + } + if (/['\\`$\n\r]/.test(value)) { + throw new DyadError( + `Value for ${name} contains a character that cannot be passed safely`, + DyadErrorKind.Internal, + ); + } + return `-e ${name}='${value}'`; + }) + .join(" "); +} + +/** + * Runs a PHP script in the Coolify container and returns what it printed. + * + * Throws rather than returning an empty string when the markers are missing, + * because that means the script did not run — a container that is not up yet, + * or a tinker that died — and a caller reading "" as "no token" would go on to + * do something worse than stopping. + */ +export async function runTinker( + session: SshSession, + script: string, + { env = {}, container = "coolify", signal, timeoutMs }: TinkerOptions = {}, +): Promise { + const names = envArgs(env); + const command = names + ? `docker exec -i ${names} ${container} php artisan tinker --no-ansi` + : tinkerCommand(container); + + const result = await session.run(command, { + input: wrapScript(script) + "\n", + signal, + timeoutMs, + }); + + const output = extractOutput(result.stdout); + if (output === null) { + throw new DyadError( + "Coolify did not answer as expected while being set up. It may still be " + + "starting — wait a moment and try again.", + DyadErrorKind.External, + ); + } + return output; +} diff --git a/src/coolify_setup/transition.test.ts b/src/coolify_setup/transition.test.ts new file mode 100644 index 0000000000..75f9856ced --- /dev/null +++ b/src/coolify_setup/transition.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, it } from "vitest"; +import { coolifySetupTransition } from "./transition"; +import { + IDLE, + MAX_LOG_CHARS, + COOLIFY_SETUP_INVOCATION_KIND, + type CoolifySetupCommand, + type CoolifySetupEvent, + type CoolifySetupInvocationRef, + type CoolifySetupState, +} from "./state"; +import type { SetupResult } from "@/ipc/types/coolify_setup"; + +const ref = (host: string, operationId: string): CoolifySetupInvocationRef => ({ + kind: COOLIFY_SETUP_INVOCATION_KIND, + entityKey: host, + operationId, +}); + +const HOST = "203.0.113.5"; +const REF = ref(HOST, "op-1"); +// A second run of the SAME server: the host cannot tell these apart, which is +// why correlation is by operation and not by address. +const AGAIN = ref(HOST, "op-2"); +const OTHER = ref("198.51.100.9", "op-3"); + +const TARGET = { host: HOST, username: "root", adminEmail: "me@gmail.com" }; + +const RESULT: SetupResult = { + dashboardUrl: "https://203.0.113.5.sslip.io", + secure: true, + insecureReason: null, + adminEmail: "me@gmail.com", + adminPassword: "Abc123@xyz", + tokenStored: true, + tokenUnavailableReason: null, + version: "4.3.2", +}; + +const running = (over: Record = {}): CoolifySetupState => + ({ + type: "running", + host: HOST, + invocationRef: REF, + step: "installing", + log: "", + stopping: false, + ...over, + }) as CoolifySetupState; + +const done = (): CoolifySetupState => ({ + type: "done", + host: HOST, + invocationRef: REF, + result: RESULT, +}); +const failed = (): CoolifySetupState => ({ + type: "failed", + host: HOST, + invocationRef: REF, + message: "boom", + log: "output", + cancelled: false, +}); + +const ALL_STATES: CoolifySetupState[] = [IDLE, running(), done(), failed()]; +const ALL_EVENTS: CoolifySetupEvent[] = [ + { type: "start-requested", invocationRef: REF, target: TARGET }, + { type: "cancel-requested" }, + { type: "progress", invocationRef: REF, step: "installing", output: "x" }, + { type: "succeeded", invocationRef: REF, result: RESULT }, + { type: "failed", invocationRef: REF, message: "boom", cancelled: false }, + { type: "dismissed" }, +]; + +/** The state a transition settles on, whether it applied or was ignored. */ +const next = (state: CoolifySetupState, event: CoolifySetupEvent) => + coolifySetupTransition(state, event).state; + +const commandsOf = ( + state: CoolifySetupState, + event: CoolifySetupEvent, +): readonly CoolifySetupCommand[] => { + const result = coolifySetupTransition(state, event); + return result.kind === "applied" ? result.commands : []; +}; + +const reasonOf = (state: CoolifySetupState, event: CoolifySetupEvent) => { + const result = coolifySetupTransition(state, event); + return result.kind === "ignored" ? result.reason : null; +}; + +describe("totality", () => { + it("answers every event from every state", () => { + // The events come from a process that does not know what the user has + // done since, so there is no combination that cannot arrive. + for (const state of ALL_STATES) { + for (const event of ALL_EVENTS) { + expect(() => coolifySetupTransition(state, event)).not.toThrow(); + expect(coolifySetupTransition(state, event)).toBeTruthy(); + } + } + }); + + it("never mutates the state it was given", () => { + for (const state of ALL_STATES) { + for (const event of ALL_EVENTS) { + const before = JSON.stringify(state); + coolifySetupTransition(state, event); + expect(JSON.stringify(state)).toBe(before); + } + } + }); +}); + +describe("starting", () => { + it("begins at connecting with nothing said yet", () => { + expect( + next(IDLE, { + type: "start-requested", + invocationRef: REF, + target: TARGET, + }), + ).toEqual({ + type: "running", + host: HOST, + invocationRef: REF, + step: "connecting", + log: "", + stopping: false, + }); + }); + + it("replaces a terminal screen rather than keeping it beside the new run", () => { + expect( + next(done(), { + type: "start-requested", + invocationRef: OTHER, + target: { ...TARGET, host: "198.51.100.9" }, + }), + ).toMatchObject({ type: "running", host: "198.51.100.9" }); + }); +}); + +describe("while it runs", () => { + it("follows the step", () => { + const stepped = next(running(), { + type: "progress", + invocationRef: REF, + step: "securing", + }); + expect(stepped).toMatchObject({ type: "running", step: "securing" }); + }); + + it("accumulates output", () => { + let state = running(); + state = next(state, { + type: "progress", + invocationRef: REF, + step: "installing", + output: "one ", + }); + state = next(state, { + type: "progress", + invocationRef: REF, + step: "installing", + output: "two", + }); + expect(state).toMatchObject({ log: "one two" }); + }); + + it("keeps the end of a long installer's output", () => { + // The end is what explains a failure, and an installer can print a lot. + const state = next(running({ log: "a".repeat(MAX_LOG_CHARS) }), { + type: "progress", + invocationRef: REF, + step: "installing", + output: "TAIL", + }); + const log = (state as { log: string }).log; + expect(log).toHaveLength(MAX_LOG_CHARS); + expect(log.endsWith("TAIL")).toBe(true); + }); + + it("records that the user has asked it to stop", () => { + expect(next(running(), { type: "cancel-requested" })).toMatchObject({ + stopping: true, + }); + }); +}); + +describe("what it asks the controller to do", () => { + // The rules live here rather than beside the effect, so they can be tested + // without a server. + + it("asks for the work to be launched when nothing is running", () => { + expect( + commandsOf(IDLE, { + type: "start-requested", + invocationRef: REF, + target: TARGET, + }), + ).toEqual([{ type: "launch", invocationRef: REF, target: TARGET }]); + }); + + it("refuses a second setup while one is going, and launches nothing", () => { + const state = running(); + const event: CoolifySetupEvent = { + type: "start-requested", + invocationRef: AGAIN, + target: TARGET, + }; + expect(reasonOf(state, event)).toBe("already-running"); + expect(commandsOf(state, event)).toEqual([]); + expect(next(state, event)).toBe(state); + }); + + it("asks for the running invocation to be aborted, not the requested one", () => { + // The abort has to name what is actually going on, or a cancel arriving + // just after a supersede would stop the wrong run. + expect(commandsOf(running(), { type: "cancel-requested" })).toEqual([ + { type: "abort", invocationRef: REF }, + ]); + }); + + it("does not remake the state when it is already stopping", () => { + // A second Cancel — a double click inside the round trip, or another + // window — must not hand back a new object that says the same thing. + const state = running({ stopping: true }); + const result = coolifySetupTransition(state, { type: "cancel-requested" }); + + expect(result.state).toBe(state); + expect(commandsOf(state, { type: "cancel-requested" })).toEqual([ + { type: "abort", invocationRef: REF }, + ]); + }); + + it("aborts nothing when nothing is running", () => { + expect(reasonOf(IDLE, { type: "cancel-requested" })).toBe("not-running"); + expect(commandsOf(done(), { type: "cancel-requested" })).toEqual([]); + }); + + it("says why it ignored an answer from a superseded run", () => { + expect( + reasonOf(running(), { + type: "succeeded", + invocationRef: AGAIN, + result: RESULT, + }), + ).toBe("stale-operation"); + }); +}); + +describe("answers from a run that is no longer the one in hand", () => { + // The reason this is a machine. A run keeps going after the panel showing + // it is gone, so its answers arrive against whatever state came next. + + it("ignores progress for another server", () => { + const state = running(); + expect( + next(state, { + type: "progress", + invocationRef: OTHER, + step: "securing", + }), + ).toBe(state); + }); + + it("ignores a result for another server", () => { + const state = running(); + expect( + next(state, { type: "succeeded", invocationRef: OTHER, result: RESULT }), + ).toBe(state); + }); + + it("ignores a result that arrives after the screen was dismissed", () => { + // Exactly the shape that put a finished install over a panel the user had + // already moved on from. + expect( + next(IDLE, { type: "succeeded", invocationRef: REF, result: RESULT }), + ).toBe(IDLE); + }); + + it("ignores a failure from an earlier run of the same server", () => { + // Start, cancel, start again: the host is identical, so only the + // operation identity can tell the first run's answer from the second's. + const state = running({ invocationRef: AGAIN }); + expect( + next(state, { + type: "failed", + invocationRef: REF, + message: "late", + cancelled: false, + }), + ).toBe(state); + }); + + it("ignores a second result for a run already finished", () => { + const state = done(); + expect( + next(state, { type: "succeeded", invocationRef: REF, result: RESULT }), + ).toBe(state); + }); +}); + +describe("finishing", () => { + it("carries the result through", () => { + expect( + next(running(), { + type: "succeeded", + invocationRef: REF, + result: RESULT, + }), + ).toEqual({ + type: "done", + host: HOST, + invocationRef: REF, + result: RESULT, + }); + }); + + it("keeps the output when it fails, since that is what explains it", () => { + expect( + next(running({ log: "3/6 Pulling..." }), { + type: "failed", + invocationRef: REF, + message: "exit 1", + cancelled: false, + }), + ).toEqual({ + type: "failed", + host: HOST, + invocationRef: REF, + message: "exit 1", + log: "3/6 Pulling...", + cancelled: false, + }); + }); + + it("marks a cancellation as one, so it is not reported as a fault", () => { + expect( + next(running({ stopping: true }), { + type: "failed", + invocationRef: REF, + message: "Cancelled.", + cancelled: true, + }), + ).toMatchObject({ type: "failed", cancelled: true }); + }); +}); + +describe("dismissing", () => { + it("clears a finished screen", () => { + expect(next(done(), { type: "dismissed" })).toEqual(IDLE); + }); + + it("clears a failed screen", () => { + expect(next(failed(), { type: "dismissed" })).toEqual(IDLE); + }); + + it("does not clear a running one", () => { + // There would still be an install going on, with nothing showing it. + const state = running(); + expect(next(state, { type: "dismissed" })).toBe(state); + }); +}); diff --git a/src/coolify_setup/transition.ts b/src/coolify_setup/transition.ts new file mode 100644 index 0000000000..3b67b57721 --- /dev/null +++ b/src/coolify_setup/transition.ts @@ -0,0 +1,151 @@ +import { + IDLE, + MAX_LOG_CHARS, + type CoolifySetupCommand, + type CoolifySetupEvent, + type CoolifySetupRunning, + type CoolifySetupState, + type TransitionResult, +} from "./state"; +import { sameInvocationRef } from "@/state_machines/invocation_ref"; +import { + STALE_OPERATION_IGNORE_REASON, + change, + ignore, + stay, +} from "@/state_machines/types"; + +function appendLog(existing: string, chunk: string): string { + const combined = existing + chunk; + return combined.length > MAX_LOG_CHARS + ? combined.slice(combined.length - MAX_LOG_CHARS) + : combined; +} + +/** + * Returns the running state only when the event claims the live invocation. + * + * This is what stops a setup the user has left behind from writing progress, + * a finished screen, or an error over whatever replaced it. + */ +function runningFor( + state: CoolifySetupState, + ref: CoolifySetupRunning["invocationRef"], +): CoolifySetupRunning | null { + if (state.type !== "running") return null; + return sameInvocationRef(state.invocationRef, ref) ? state : null; +} + +/** Progress and completions all answer the same question about identity. */ +function notForTheRunInHand(state: CoolifySetupState): TransitionResult { + return ignore( + state, + state.type === "running" ? STALE_OPERATION_IGNORE_REASON : "not-running", + ); +} + +/** + * Pure transition function for the Coolify server setup machine. + * + * Total: every event is answered from every state, and one that does not apply + * is ignored with a reason rather than throwing. That is the point rather than + * a convenience — these events come from a process that does not know what the + * user has done since, so an answer for a run that is no longer the one in + * hand is ordinary traffic, not an error. + */ +export function coolifySetupTransition( + state: CoolifySetupState, + event: CoolifySetupEvent, +): TransitionResult { + switch (event.type) { + case "start-requested": { + // One at a time, decided here rather than by a check beside the effect. + // Two installs on one machine would interleave their output and fight + // over the same docker state. + if (state.type === "running") return ignore(state, "already-running"); + const command: CoolifySetupCommand = { + type: "launch", + invocationRef: event.invocationRef, + target: event.target, + }; + return change( + { + type: "running", + host: event.target.host, + invocationRef: event.invocationRef, + step: "connecting", + log: "", + stopping: false, + }, + [command], + ); + } + + case "cancel-requested": { + // Nothing to stop is not a failure; it is the ordinary answer to a + // cancel that raced the run finishing. + if (state.type !== "running") return ignore(state, "not-running"); + const abort: CoolifySetupCommand = { + type: "abort", + invocationRef: state.invocationRef, + }; + // Already stopping: the abort is worth sending again, since aborting + // twice is the same as aborting once — but the state is not worth + // remaking. A value-equal snapshot with a new reference is a change + // that changes nothing, and every window would be told about it. + if (state.stopping) return stay(state, [abort]); + return change({ ...state, stopping: true }, [abort]); + } + + case "progress": { + const running = runningFor(state, event.invocationRef); + if (!running) return notForTheRunInHand(state); + const log = event.output + ? appendLog(running.log, event.output) + : running.log; + // Same answer as last time. Returning a new object anyway would send + // every window a change that changes nothing. + if (running.step === event.step && running.log === log) { + return stay(running, []); + } + return change({ ...running, step: event.step, log }); + } + + case "succeeded": { + const running = runningFor(state, event.invocationRef); + if (!running) return notForTheRunInHand(state); + return change({ + type: "done", + host: running.host, + invocationRef: running.invocationRef, + result: event.result, + }); + } + + case "failed": { + const running = runningFor(state, event.invocationRef); + if (!running) return notForTheRunInHand(state); + return change({ + type: "failed", + host: running.host, + invocationRef: running.invocationRef, + message: event.message, + log: running.log, + cancelled: event.cancelled, + }); + } + + case "dismissed": + if (state.type === "running") return ignore(state, "still-running"); + if (state.type === "idle") return ignore(state, "not-running"); + return change(IDLE); + + default: { + // Total by construction: adding an event without answering it above + // stops this assignment compiling. + const unanswered: never = event; + void unanswered; + return ignore(state, "not-running"); + } + } +} diff --git a/src/distributed_machines/boundary_inventory.test_support.ts b/src/distributed_machines/boundary_inventory.test_support.ts index 41db7045df..d02e262bf2 100644 --- a/src/distributed_machines/boundary_inventory.test_support.ts +++ b/src/distributed_machines/boundary_inventory.test_support.ts @@ -278,6 +278,7 @@ export const nonRemoteDispatchOrEnqueueInventory = [ // A local main-process machine: dispatch here is its own transition, not // distributed-machine transport. owned("coolify_deploy/controller.ts", 7), + owned("coolify_setup/controller.ts", 6), owned("hooks/useRunApp.ts", 1), owned("ipc/services/app_runtime_service.ts", 2), owned("ipc/services/app_runtime_transport.ts", 1), diff --git a/src/ipc/handlers/coolify_handlers.test.ts b/src/ipc/handlers/coolify_handlers.test.ts index 1fa14c8244..b6e6ed4f1e 100644 --- a/src/ipc/handlers/coolify_handlers.test.ts +++ b/src/ipc/handlers/coolify_handlers.test.ts @@ -10,6 +10,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; */ const settings: Record = {}; + +/** The settings fixture is untyped, and these assertions are about secrets. */ +function storedCoolify() { + return (settings.coolify ?? {}) as { + accessToken?: { value: string }; + previousAccessToken?: { value: string }; + adminEmail?: string; + adminPassword?: { value: string }; + adminInstanceUrl?: string; + }; +} const rows: Record[] = []; vi.mock("../../main/settings", () => ({ @@ -240,6 +251,36 @@ describe("clearing the token", () => { expect(updateSet).not.toHaveBeenCalled(); }); + it("keeps the token where it can be read back", async () => { + // Dyad minted this one itself. Throwing it away means the way back in is + // making another in Coolify, for a token Dyad had a moment ago. + await call("coolify:clear-token"); + + expect(storedCoolify().accessToken).toBeUndefined(); + expect(storedCoolify().previousAccessToken?.value).toBe("tok"); + }); + + it("does not lose it when signing out twice", async () => { + await call("coolify:clear-token"); + await call("coolify:clear-token"); + + expect(storedCoolify().previousAccessToken?.value).toBe("tok"); + }); + + it("drops the old one once a new token is saved", async () => { + // Otherwise the next sign-out puts a token from two connections ago on + // screen. + await call("coolify:clear-token"); + await call("coolify:save-token", { + instanceUrl: "https://coolify.example.com", + token: "tok-2", + acknowledgedInsecure: false, + }); + + expect(storedCoolify().previousAccessToken).toBeUndefined(); + expect(storedCoolify().accessToken?.value).toBe("tok-2"); + }); + it("still reports the app as disconnected", async () => { await call("coolify:clear-token"); @@ -306,6 +347,60 @@ describe("clearing the token", () => { }); }); +describe("the admin account Dyad created", () => { + beforeEach(() => { + settings.coolify = { + ...(settings.coolify as Record), + adminEmail: "me@gmail.com", + adminPassword: { value: "Abc123@xyz" }, + adminInstanceUrl: "https://coolify.example.com", + }; + }); + + it("survives connecting to a different Coolify", async () => { + // Dyad invented this password for a machine that is still running, and + // this is the only copy. It is kept with the address it belongs to and + // shown only beside that address, so nothing here pairs one server's + // password with another's — hiding does that job, and hiding a wrong + // guess costs a moment where deleting one costs the password. + listServers.mockResolvedValueOnce([{ uuid: "srv-elsewhere" }]); + await call("coolify:save-token", { + instanceUrl: "https://other.example.com", + token: "tok-2", + acknowledgedInsecure: false, + }); + + expect(storedCoolify().adminPassword?.value).toBe("Abc123@xyz"); + expect(storedCoolify().adminInstanceUrl).toBe( + "https://coolify.example.com", + ); + }); + + it("survives signing back in to the instance it belongs to", async () => { + // The reason it is kept at all: Dyad invented this password for a server + // the user owns, and signing out must not cost them the way in. + await call("coolify:clear-token"); + await call("coolify:save-token", { + instanceUrl: "https://coolify.example.com", + token: "tok-2", + acknowledgedInsecure: false, + }); + + expect(storedCoolify().adminEmail).toBe("me@gmail.com"); + expect(storedCoolify().adminPassword?.value).toBe("Abc123@xyz"); + }); + + it("matches the address however it was typed", async () => { + await call("coolify:save-token", { + instanceUrl: "https://coolify.example.com/", + token: "tok-2", + acknowledgedInsecure: false, + }); + + expect(storedCoolify().adminEmail).toBe("me@gmail.com"); + }); +}); + describe("moving an app to a different server or project", () => { it("releases the application, which cannot move with it", async () => { // Coolify cannot move an application between servers, so keeping its id diff --git a/src/ipc/handlers/coolify_handlers.ts b/src/ipc/handlers/coolify_handlers.ts index d5039eb97a..8151d66120 100644 --- a/src/ipc/handlers/coolify_handlers.ts +++ b/src/ipc/handlers/coolify_handlers.ts @@ -1,10 +1,10 @@ import { BrowserWindow } from "electron"; import { eq } from "drizzle-orm"; import log from "electron-log"; -import * as dns from "node:dns/promises"; import { createHash } from "node:crypto"; import { db } from "../../db"; import { apps } from "../../db/schema"; +import { resolveBoth } from "../utils/dns_resolve"; import { readSettings, writeSettings } from "../../main/settings"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { @@ -26,7 +26,7 @@ import { isLoopbackAddress, isNonRoutableAddress, isDeferredIpv6, -} from "@/coolify_deploy/domain_check"; +} from "@/shared/domain_check"; import { applyCoolifyConnectionChange, isHostMove, @@ -47,56 +47,6 @@ async function getApp(appId: number) { return app; } -/** A resolver saying "no such record" — anything else is our problem, not DNS's. */ -const NO_RECORD_CODES = new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]); - -/** - * Bounded, because Save waits on it. - * - * The check is advisory, so a resolver that never answers must not be able to - * hold the button indefinitely. The bound is per attempt per configured - * nameserver, so the wait scales with how many the machine has: six seconds - * against a single stub resolver, and proportionally more where several are - * listed. Two tries rather than the default four keeps that multiple small. A - * timeout still arrives as an error code the caller reads as "could not ask" - * rather than as a missing record. - */ -const resolver = new dns.Resolver({ timeout: 3_000, tries: 2 }); - -/** - * Both families, distinguishing "no record" from "could not ask". - * - * A timeout or an unreachable resolver must not be reported as a missing - * record: telling someone to fix DNS that is already correct is exactly the - * confident-but-wrong advice the unknown verdict exists to avoid. - */ -async function resolveBoth( - hostname: string, -): Promise<{ addresses: string[]; failed: boolean }> { - const attempt = async (fn: (h: string) => Promise) => { - try { - return { addresses: await fn(hostname), failed: false }; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code ?? ""; - return { addresses: [] as string[], failed: !NO_RECORD_CODES.has(code) }; - } - }; - const [v4, v6] = await Promise.all([ - attempt((h) => resolver.resolve4(h)), - attempt((h) => resolver.resolve6(h)), - ]); - return { - addresses: [...v4.addresses, ...v6.addresses], - // One family answering is enough. But a definitive "no such record" from - // one and a failed lookup from the other is not the same as no records: - // the family we could not reach may hold the one that works. - failed: - v4.addresses.length === 0 && - v6.addresses.length === 0 && - (v4.failed || v6.failed), - }; -} - /** The app's stored connection, or nothing when it has none. */ function readConnection( state: CoolifyConnectionState, @@ -184,6 +134,14 @@ export function registerCoolifyHandlers() { ...readSettings().coolify, instanceUrl: normalized, accessToken: { value: token }, + // Superseded. Leaving it would put a token from two connections ago + // on screen after the next sign-out. + previousAccessToken: undefined, + // The admin account is left alone. It is kept with the address it + // belongs to and only shown beside that address, so connecting + // elsewhere cannot pair one server's password with another's — and + // deleting on a mismatched address would throw away the only copy + // of a password Dyad invented for a machine still running. }, }); // Nothing is cleared here. Server, project and application ids are @@ -223,8 +181,22 @@ export function registerCoolifyHandlers() { coolifyDeployRegistry.cancelAll(); // The address survives; only the token goes. Spread rather than replaced, // so a field added to CoolifySchema later is not silently dropped here. + const current = readSettings().coolify; + const carried = current?.accessToken ?? current?.previousAccessToken; writeSettings({ - coolify: { ...readSettings().coolify, accessToken: undefined }, + coolify: { + ...current, + accessToken: undefined, + // Kept where it can be read back rather than deleted. Dyad usually + // minted this itself, so losing it means making another in Coolify to + // get back in. Signing out twice must not overwrite it with nothing. + // + // Named only when there is something readable to name: a secret this + // machine cannot decrypt reads as absent, and writing the key as + // undefined would be taken as a deliberate clear and throw away + // ciphertext that a repaired keychain could still open. + ...(carried ? { previousAccessToken: carried } : {}), + }, }); }); diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts new file mode 100644 index 0000000000..4c369a85e6 --- /dev/null +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -0,0 +1,545 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + settings: {} as Record, + written: [] as Array>, + serverKey: { publicKey: "ssh-ed25519 AAAAPUB dyad", privateKey: "PRIVATE" }, + setupResult: null as unknown, + setupError: null as unknown, + lastSetupOptions: null as Record | null, + sessionEnded: 0, + reportsAccount: true, + runCalls: 0, +})); + +vi.mock("electron", () => ({ BrowserWindow: { getAllWindows: () => [] } })); + +const handlers = new Map Promise>(); +vi.mock("./base", () => ({ + createTypedHandler: ( + contract: { channel: string }, + handler: (...args: unknown[]) => Promise, + ) => handlers.set(contract.channel, handler), +})); + +vi.mock("@/main/settings", () => ({ + readSettings: () => h.settings, + writeSettings: (value: Record) => { + h.written.push(value); + Object.assign(h.settings, value); + }, +})); + +vi.mock("@/coolify_setup/server_key", () => ({ + ensureServerKey: () => h.serverKey, +})); + +vi.mock("../utils/ssh_client", () => ({ + // The real client runs the verifier during the handshake, which is what + // reports the fingerprint. A mock that skipped it would leave the handler + // looking correct while reporting nothing. + connectSsh: vi.fn( + async (_target: unknown, verify: (fp: string) => boolean) => { + verify("SHA256:fingerprint"); + return { + run: vi.fn(), + end: () => { + h.sessionEnded += 1; + }, + }; + }, + ), + trustOnFirstUse: (onSeen: (fp: string) => void) => (fingerprint: string) => { + onSeen(fingerprint); + return true; + }, +})); + +vi.mock("@/coolify_setup/install", () => ({ + preflight: vi.fn(async () => ({ + ready: true, + alreadyInstalled: false, + memoryMb: 1967, + })), +})); + +vi.mock("@/coolify_setup/setup_flow", () => ({ + runServerSetup: vi.fn(async (options: Record) => { + h.runCalls += 1; + h.lastSetupOptions = options; + // The real flow reports the account the moment it exists, before the + // steps that can still fail. + if (h.reportsAccount) { + ( + options.onAccountKnown as (a: { + credentials: { email: string; password: string }; + dashboardUrl: string; + }) => void + )({ + credentials: { email: "me@gmail.com", password: "Abc123@xyz" }, + dashboardUrl: "http://203.0.113.5:8000", + }); + } + if (h.setupError) throw h.setupError; + return h.setupResult; + }), +})); + +const { registerCoolifySetupHandlers } = + await import("./coolify_setup_handlers"); + +function call(channel: string, input?: unknown) { + const handler = handlers.get(channel); + if (!handler) throw new Error(`No handler for ${channel}`); + return handler({}, input); +} + +const TARGET = { + host: "203.0.113.5", + username: "root", + adminEmail: "me@gmail.com", +}; + +const RESULT = { + dashboardUrl: "http://203.0.113.5:8000", + credentials: { + username: "dyad-admin", + email: "me@gmail.com", + password: "Abc123@xyz", + }, + token: "1|abc", + version: "4.3.2", +}; + +beforeEach(() => { + handlers.clear(); + h.settings = {}; + h.written.length = 0; + h.setupResult = RESULT; + h.setupError = null; + h.lastSetupOptions = null; + h.sessionEnded = 0; + h.reportsAccount = true; + h.runCalls = 0; + registerCoolifySetupHandlers(); +}); + +describe("getServerKey", () => { + it("hands over only the public half", async () => { + // The private key is what reaches the user's server; it belongs in the + // main process, exactly like the API token. + const result = (await call("coolify-setup:get-server-key")) as Record< + string, + unknown + >; + expect(result).toEqual({ publicKey: h.serverKey.publicKey }); + expect(JSON.stringify(result)).not.toContain("PRIVATE"); + }); +}); + +describe("inspect", () => { + it("reports the fingerprint it saw", async () => { + const result = (await call("coolify-setup:inspect", TARGET)) as Record< + string, + unknown + >; + expect(result.hostFingerprint).toBe("SHA256:fingerprint"); + expect(result.ready).toBe(true); + }); + + it("closes the connection it opened", async () => { + await call("coolify-setup:inspect", TARGET); + expect(h.sessionEnded).toBe(1); + }); +}); + +describe("run", () => { + it("refuses an address Coolify will not accept, before doing anything", async () => { + // Its seeder resolves the domain. Finding out afterwards costs the whole + // install and leaves an instance with no account on it. + await expect( + call("coolify-setup:run", { ...TARGET, adminEmail: "admin@dyad.test" }), + ).rejects.toMatchObject({ kind: "validation" }); + }); + + it("stores the token it minted", async () => { + await call("coolify-setup:run", TARGET); + const saved = h.written.at(-1) as { + coolify: { accessToken: { value: string }; instanceUrl: string }; + }; + expect(saved.coolify.accessToken.value).toBe("1|abc"); + expect(saved.coolify.instanceUrl).toBe("http://203.0.113.5:8000"); + }); + + it("stores the admin password, so the user is not locked out later", async () => { + // Dyad invented this password for a machine the user owns. Storing the + // token but not this leaves them unable to sign in to their own server. + await call("coolify-setup:run", TARGET); + const saved = h.written.at(-1) as { + coolify: { adminPassword?: { value: string }; adminEmail?: string }; + }; + expect(saved.coolify.adminPassword?.value).toBe("Abc123@xyz"); + expect(saved.coolify.adminEmail).toBe("me@gmail.com"); + }); + + it("records which instance the account is on", async () => { + // Connecting Dyad to a different Coolify later has to know this account + // does not come along. + await call("coolify-setup:run", TARGET); + const saved = h.written.at(-1) as { + coolify: { adminInstanceUrl?: string }; + }; + expect(saved.coolify.adminInstanceUrl).toBe("http://203.0.113.5:8000"); + }); + + it("returns the password so it can be shown once", async () => { + const result = (await call("coolify-setup:run", TARGET)) as Record< + string, + unknown + >; + expect(result.adminPassword).toBe("Abc123@xyz"); + expect(result.tokenStored).toBe(true); + }); + + it("keeps the install when no token could be created", async () => { + h.setupResult = { + ...RESULT, + token: null, + tokenUnavailableReason: "too old", + }; + const result = (await call("coolify-setup:run", TARGET)) as Record< + string, + unknown + >; + + expect(result.tokenStored).toBe(false); + expect(result.tokenUnavailableReason).toBe("too old"); + expect(result.adminPassword).toBe("Abc123@xyz"); + + // The account is kept even though the token is not: this is the one case + // where the user has to sign in to Coolify themselves, so throwing the + // password away here would take away the only way to do it. + const saved = h.written.at(-1) as { + coolify: { + adminPassword?: { value: string }; + accessToken?: unknown; + instanceUrl?: string; + }; + }; + expect(saved.coolify.adminPassword?.value).toBe("Abc123@xyz"); + // No token and no address, because there is no instance Dyad can talk to. + expect(saved.coolify.accessToken).toBeUndefined(); + expect(saved.coolify.instanceUrl).toBeUndefined(); + }); + + it("keeps the password when the install fails after the account exists", async () => { + // The dashboard never answering does not un-create the account. Dyad is + // the only thing that knows the password it invented, so failing here + // without storing it locks the user out of a server that is running. + h.setupError = new Error( + "Coolify was installed but its dashboard did not start.", + ); + await call("coolify-setup:run", TARGET).catch(() => {}); + + const saved = h.written.at(-1) as { + coolify: { adminPassword?: { value: string }; adminInstanceUrl?: string }; + }; + expect(saved.coolify.adminPassword?.value).toBe("Abc123@xyz"); + expect(saved.coolify.adminInstanceUrl).toBe("http://203.0.113.5:8000"); + }); + + it("writes nothing when the failure came before any account", async () => { + h.reportsAccount = false; + h.setupError = new Error("This server cannot be set up automatically."); + await call("coolify-setup:run", TARGET).catch(() => {}); + + expect(h.written).toHaveLength(0); + }); + + it("refuses a second setup on a different machine", async () => { + // Two installs at once would interleave their output, and the second + // machine's run has nothing to do with the first's. + let release!: () => void; + h.setupResult = new Promise((resolve) => { + release = () => resolve(RESULT); + }); + const first = call("coolify-setup:run", TARGET); + await expect( + call("coolify-setup:run", { ...TARGET, host: "198.51.100.7" }), + ).rejects.toMatchObject({ kind: "precondition" }); + release(); + await first; + }); + + it("refuses a second setup on the same machine too", async () => { + // Nobody needs to press Install to get back to a run any more: the panel + // asks what is going on and shows it. So a second press is a genuine + // second request, and two installs on one machine would fight. + let release!: () => void; + h.setupResult = new Promise((resolve) => { + release = () => resolve(RESULT); + }); + const first = call("coolify-setup:run", TARGET); + await expect(call("coolify-setup:run", TARGET)).rejects.toMatchObject({ + kind: "precondition", + }); + release(); + await first; + }); + + it("hands back what is going on, so a panel can show it", async () => { + let release!: () => void; + h.setupResult = new Promise((resolve) => { + release = () => resolve(RESULT); + }); + const running = call("coolify-setup:run", TARGET); + + const snapshot = (await call("coolify-setup:snapshot")) as { + type: string; + host: string; + }; + expect(snapshot.type).toBe("running"); + expect(snapshot.host).toBe("203.0.113.5"); + + release(); + await running; + expect( + ((await call("coolify-setup:snapshot")) as { type: string }).type, + ).toBe("done"); + }); + + it("puts the finished screen away when the user moves on", async () => { + await call("coolify-setup:run", TARGET); + await call("coolify-setup:dismiss"); + + expect( + ((await call("coolify-setup:snapshot")) as { type: string }).type, + ).toBe("idle"); + }); + + it("frees the slot even when setup failed", async () => { + h.setupError = new Error("boom"); + await call("coolify-setup:run", TARGET).catch(() => {}); + h.setupError = null; + await expect(call("coolify-setup:run", TARGET)).resolves.toBeTruthy(); + }); +}); + +describe("revealCredentials", () => { + it("hands back what Dyad knows about getting in", async () => { + h.settings = { + coolify: { + instanceUrl: "http://203.0.113.5:8000", + accessToken: { value: "1|abc" }, + adminEmail: "me@gmail.com", + adminPassword: { value: "Abc123@xyz" }, + adminInstanceUrl: "http://203.0.113.5:8000", + }, + }; + const result = (await call("coolify-setup:reveal-credentials")) as Record< + string, + unknown + >; + expect(result).toEqual({ + dashboardUrl: "http://203.0.113.5:8000", + adminEmail: "me@gmail.com", + adminPassword: "Abc123@xyz", + apiToken: "1|abc", + isPreviousConnection: true, + }); + }); + + it("hands back the token from before signing out", async () => { + // Signing back in is then a paste, rather than a trip into Coolify to + // mint a token Dyad already had. + h.settings = { + coolify: { + instanceUrl: "http://203.0.113.5:8000", + previousAccessToken: { value: "1|old" }, + adminEmail: "me@gmail.com", + adminPassword: { value: "Abc123@xyz" }, + adminInstanceUrl: "http://203.0.113.5:8000", + }, + }; + const result = (await call("coolify-setup:reveal-credentials")) as Record< + string, + unknown + >; + expect(result.apiToken).toBe("1|old"); + }); + + it("prefers the live token over the one kept from before", async () => { + h.settings = { + coolify: { + instanceUrl: "http://203.0.113.5:8000", + accessToken: { value: "1|current" }, + previousAccessToken: { value: "1|old" }, + }, + }; + const result = (await call("coolify-setup:reveal-credentials")) as Record< + string, + unknown + >; + expect(result.apiToken).toBe("1|current"); + }); + + it("recognises one server through its different spellings", async () => { + // Dyad asks for a certificate under the sslip.io name, so the address it + // stores can differ from the one the user types for the same box. Read as + // two servers, the password for the one in front of them is hidden. + h.settings = { + coolify: { + instanceUrl: "http://203.0.113.5:8000", + accessToken: { value: "1|abc" }, + adminEmail: "me@gmail.com", + adminPassword: { value: "Abc123@xyz" }, + adminInstanceUrl: "https://203.0.113.5.sslip.io", + }, + }; + const result = (await call("coolify-setup:reveal-credentials")) as Record< + string, + unknown + >; + expect(result.adminPassword).toBe("Abc123@xyz"); + }); + + it("still tells two different servers apart", async () => { + h.settings = { + coolify: { + instanceUrl: "https://someone-else.example.com", + accessToken: { value: "1|abc" }, + adminEmail: "me@gmail.com", + adminPassword: { value: "Abc123@xyz" }, + adminInstanceUrl: "https://203.0.113.5.sslip.io", + }, + }; + const result = (await call("coolify-setup:reveal-credentials")) as Record< + string, + unknown + >; + expect(result.adminPassword).toBeNull(); + }); + + it("does not call a server with no token yet a previous connection", async () => { + // Installed a moment ago, with no token minted for it. Calling it + // previous reads as something being over. + h.settings = { + coolify: { + adminEmail: "me@gmail.com", + adminPassword: { value: "Abc123@xyz" }, + adminInstanceUrl: "http://203.0.113.5:8000", + }, + }; + const result = (await call("coolify-setup:reveal-credentials")) as Record< + string, + unknown + >; + expect(result.isPreviousConnection).toBe(false); + }); + + it("describes one server, not two at once", async () => { + // Connected to one Coolify, signed out, then installed another whose + // token could not be minted. Showing the first one's address over the + // second one's password reads as a way in and is not one. + h.settings = { + coolify: { + instanceUrl: "https://old.example.com", + previousAccessToken: { value: "1|for-the-old-one" }, + adminEmail: "me@gmail.com", + adminPassword: { value: "PasswordForTheNewOne" }, + adminInstanceUrl: "http://203.0.113.5:8000", + }, + }; + const result = (await call("coolify-setup:reveal-credentials")) as Record< + string, + unknown + >; + + // The server Dyad installed: its password is what nothing else knows. + expect(result.dashboardUrl).toBe("http://203.0.113.5:8000"); + expect(result.adminPassword).toBe("PasswordForTheNewOne"); + // The other instance's token would not open this one. + expect(result.apiToken).toBeNull(); + }); + + it("gives the address of a server installed before any token", async () => { + // Nothing was ever connected, so there is no instanceUrl — but the user + // still has to know which machine these open. + h.settings = { + coolify: { + adminEmail: "me@gmail.com", + adminPassword: { value: "Abc123@xyz" }, + adminInstanceUrl: "http://203.0.113.5:8000", + }, + }; + const result = (await call("coolify-setup:reveal-credentials")) as Record< + string, + unknown + >; + expect(result.dashboardUrl).toBe("http://203.0.113.5:8000"); + expect(result.adminPassword).toBe("Abc123@xyz"); + }); + + it("keeps the connected instance as the subject while it is connected", async () => { + // A live token means Dyad is talking to that one, so it is what the panel + // is about — and the account from elsewhere is not shown beside it. + h.settings = { + coolify: { + instanceUrl: "https://connected.example.com", + accessToken: { value: "1|live" }, + adminEmail: "me@gmail.com", + adminPassword: { value: "PasswordForElsewhere" }, + adminInstanceUrl: "http://203.0.113.5:8000", + }, + }; + const result = (await call("coolify-setup:reveal-credentials")) as Record< + string, + unknown + >; + + expect(result.dashboardUrl).toBe("https://connected.example.com"); + expect(result.apiToken).toBe("1|live"); + expect(result.adminPassword).toBeNull(); + expect(result.adminEmail).toBeNull(); + }); + + it("answers nulls for an instance Dyad did not set up", async () => { + // Connected by pasting a token, so there is no account Dyad created and + // nothing here it could hand back. + h.settings = { + coolify: { + instanceUrl: "https://coolify.example.com", + accessToken: { value: "1|abc" }, + }, + }; + const result = (await call("coolify-setup:reveal-credentials")) as Record< + string, + unknown + >; + expect(result.adminPassword).toBeNull(); + expect(result.adminEmail).toBeNull(); + expect(result.apiToken).toBe("1|abc"); + }); +}); + +describe("cancel", () => { + it("aborts the running setup", async () => { + let release!: () => void; + h.setupResult = new Promise((resolve) => { + release = () => resolve(RESULT); + }); + const running = call("coolify-setup:run", TARGET); + // The flow is handed a signal; cancelling is what trips it. + const signal = h.lastSetupOptions?.signal as AbortSignal; + expect(signal.aborted).toBe(false); + + await call("coolify-setup:cancel"); + expect(signal.aborted).toBe(true); + release(); + await running; + }); + + it("does nothing when nothing is running", async () => { + await expect(call("coolify-setup:cancel")).resolves.toBeUndefined(); + }); +}); diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts new file mode 100644 index 0000000000..504ff216ea --- /dev/null +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -0,0 +1,290 @@ +import { BrowserWindow } from "electron"; +import log from "electron-log"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { createTypedHandler } from "./base"; +import { + coolifySetupContracts, + coolifySetupEvents, +} from "../types/coolify_setup"; +import type { SetupServer, SetupSnapshot } from "../types/coolify_setup"; +import { safeSend } from "../utils/safe_sender"; +import { readSettings, writeSettings } from "@/main/settings"; +import { connectSsh, trustOnFirstUse } from "../utils/ssh_client"; +import type { SshSession } from "../utils/ssh_client"; +import { ensureServerKey } from "@/coolify_setup/server_key"; +import { preflight } from "@/coolify_setup/install"; +import { runServerSetup } from "@/coolify_setup/setup_flow"; +import { CoolifySetupController } from "@/coolify_setup/controller"; +import { uuidIdSource } from "@/state_machines/clock"; +import { isPlausibleAdminEmail } from "@/shared/coolify_admin_email"; +import { IS_TEST_BUILD } from "../utils/test_utils"; + +const logger = log.scope("coolify_setup_handlers"); + +/** + * How long looking at a server may take. + * + * Generous, because the probe runs on somebody else's machine and a slow one + * is not a broken one — but bounded, because a wedged docker daemon never + * answers at all and the button would spin for as long as the panel was open. + */ +const INSPECT_TIMEOUT_MS = 30_000; + +/** + * The setup, and everything anyone needs to know about it. + * + * Held here rather than per window, because the machine being set up is the + * shared resource — and because an install outlives any one window. What is + * going on is asked for, not remembered on the other side. + */ +let controller: CoolifySetupController | null = null; + +function broadcastState(state: SetupSnapshot) { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + safeSend(window.webContents, coolifySetupEvents.changed.channel, state); + } + } +} + +function setupController(): CoolifySetupController { + controller ??= new CoolifySetupController({ + ids: uuidIdSource, + onChanged: broadcastState, + execute: (target, hooks) => { + const key = ensureServerKey(); + return runServerSetup({ + target: targetFrom(target, key.privateKey), + adminEmail: target.adminEmail, + verifyHostKey: trustOnFirstUse(() => {}), + customDomain: target.customDomain, + signal: hooks.signal, + connect: (t, verify, signal): Promise => + connectSsh(t, verify, { signal }), + onProgress: ({ step, output }) => hooks.onProgress(step, output), + // Written the moment the account exists rather than at the end. A + // server whose dashboard never answers still has this account on it, + // and Dyad is the only thing that knows the password it invented. + onAccountKnown: ({ credentials, dashboardUrl }) => { + writeSettings({ + coolify: { + ...readSettings().coolify, + adminEmail: credentials.email, + adminPassword: { value: credentials.password }, + adminInstanceUrl: dashboardUrl, + }, + }); + }, + }).then((result) => { + // The account exists either way, so it is stored either way. Keeping + // it only when a token was also minted discards the password in the + // one case the user needs it — where they must sign in to Coolify and + // make a token by hand, which is what the token failing means. + writeSettings({ + coolify: { + ...readSettings().coolify, + adminEmail: result.credentials.email, + adminPassword: { value: result.credentials.password }, + // Stored even when no token was minted: it names the server this + // account is on, which is how connecting elsewhere later knows + // the account does not come along. + adminInstanceUrl: result.dashboardUrl, + // The address and token go together: an address stored without a + // token would read as an instance Dyad can talk to and cannot. + ...(result.token + ? { + instanceUrl: result.dashboardUrl, + accessToken: { value: result.token }, + } + : {}), + }, + }); + return { + dashboardUrl: result.dashboardUrl, + secure: result.secure, + insecureReason: result.insecureReason ?? null, + adminEmail: result.credentials.email, + adminPassword: result.credentials.password, + tokenStored: Boolean(result.token), + tokenUnavailableReason: result.tokenUnavailableReason ?? null, + version: result.version, + }; + }); + }, + }); + return controller; +} + +/** + * The machine an address names, ignoring how it was written. + * + * One server has several valid spellings — http://1.2.3.4:8000 and the + * https://1.2.3.4.sslip.io Dyad asks for a certificate under are the same + * box — and treating them as different servers hides the credentials for the + * one the user is looking at. Only ever used to decide what to show, so it + * can afford to be generous. + */ +function serverIdentity(url: string): string { + try { + const host = new URL(url).hostname.toLowerCase().replace(/^\[|\]$/g, ""); + // sslip.io spells an address as a name; the address is the identity. + const derived = /^(.+)\.sslip\.io$/.exec(host); + return derived ? derived[1] : host; + } catch { + return url.trim().toLowerCase(); + } +} + +function sameServer(a: string | null | undefined, b: string | null): boolean { + if (!a || b === null) return false; + return serverIdentity(a) === serverIdentity(b); +} + +/** + * Which port to knock on. + * + * The form asks for an address rather than an address and a port, because a + * server that has moved sshd is not the case this is for. Under an e2e build + * the port is named by the environment, so a test can stand a server on one + * it is allowed to bind — the same seam every other e2e-only behaviour here + * goes through. + */ +function sshPort(input: SetupServer): number | undefined { + const override = IS_TEST_BUILD ? process.env.DYAD_E2E_SSH_PORT : undefined; + return override ? Number(override) : input.port; +} + +function targetFrom(input: SetupServer, privateKey: string) { + return { + host: input.host.trim(), + port: sshPort(input), + username: input.username.trim(), + privateKey, + }; +} + +export function registerCoolifySetupHandlers() { + createTypedHandler(coolifySetupContracts.getServerKey, async () => { + const key = ensureServerKey(); + // Only the public half crosses to the renderer. The private half never + // leaves the main process, the same rule the API token follows. + return { publicKey: key.publicKey }; + }); + + createTypedHandler(coolifySetupContracts.inspect, async (_, input) => { + const key = ensureServerKey(); + let inspectTimer: ReturnType | undefined; + let fingerprint: string | null = null; + const session = await connectSsh( + targetFrom(input, key.privateKey), + trustOnFirstUse((fp) => { + fingerprint = fp; + }), + ); + try { + // Bounded, because nothing else bounds it: the probe asks docker, and a + // wedged daemon never answers. Left unbounded the button span forever + // and every retry leaked another connection. + const checks = await Promise.race([ + preflight(session), + new Promise((_, reject) => { + inspectTimer = setTimeout( + () => + reject( + new DyadError( + "The server did not answer. It is reachable over SSH, so " + + "something on it is not responding — try again in a moment.", + DyadErrorKind.External, + ), + ), + INSPECT_TIMEOUT_MS, + ); + }), + ]); + return { + ready: checks.ready, + reason: checks.reason ?? null, + alreadyInstalled: checks.alreadyInstalled, + memoryMb: checks.memoryMb, + hostFingerprint: fingerprint, + }; + } finally { + clearTimeout(inspectTimer); + session.end(); + } + }); + + // DO NOT LOG this handler: its result carries the generated admin password. + createTypedHandler(coolifySetupContracts.run, async (_, input) => { + // Checked before anything is done, because Coolify resolves the domain when + // it seeds its admin and a rejected address leaves an install with no + // account on it — minutes later, with nothing to show for them. + if (!isPlausibleAdminEmail(input.adminEmail)) { + throw new DyadError( + "Enter an email address whose domain resolves. Coolify checks this " + + "when it creates the admin account, and rejects addresses like " + + "admin@example.test.", + DyadErrorKind.Validation, + ); + } + // One at a time is the machine's rule, not a check here; it refuses by + // throwing, and the panel shows that. + return setupController().start(input).result; + }); + + createTypedHandler(coolifySetupContracts.snapshot, async () => + setupController().getState(), + ); + + createTypedHandler(coolifySetupContracts.dismiss, async () => { + setupController().dismiss(); + }); + + // DO NOT LOG this handler: it exists to return secrets. + createTypedHandler(coolifySetupContracts.revealCredentials, async () => { + // The user's own credentials for their own server, on their own machine. + // Dyad generated the password on their behalf, so refusing to show it + // would lock them out of something they own. + const coolify = readSettings().coolify; + // One server, described consistently. Dyad can hold details for two — an + // instance connected by pasting a token, and a server it installed whose + // token could not be minted — and pairing one's address with the other's + // password reads as a way in that is not one. + // + // Connected wins when there is a live token, since that is the instance + // Dyad is talking to. Otherwise the server Dyad installed does: its + // password is the thing nothing else in the world knows. + const liveToken = coolify?.accessToken?.value ?? null; + const dashboardUrl = + (liveToken + ? coolify?.instanceUrl + : (coolify?.adminInstanceUrl ?? coolify?.instanceUrl)) ?? null; + const adminIsHere = sameServer(coolify?.adminInstanceUrl, dashboardUrl); + const tokenIsHere = sameServer(coolify?.instanceUrl, dashboardUrl); + return { + dashboardUrl, + adminEmail: adminIsHere ? (coolify?.adminEmail ?? null) : null, + adminPassword: adminIsHere + ? (coolify?.adminPassword?.value ?? null) + : null, + // A server described through its own address, with no token, is one + // Dyad has just set up rather than one it used to talk to. + isPreviousConnection: dashboardUrl !== null && tokenIsHere, + // The one from before signing out, when there is no live one. Signing + // back in is then a paste rather than a trip into Coolify to mint + // another. + apiToken: tokenIsHere + ? (liveToken ?? coolify?.previousAccessToken?.value ?? null) + : null, + }; + }); + + createTypedHandler(coolifySetupContracts.cancel, async () => { + // Abandoning mid-install leaves whatever the installer had done on the + // server. Nothing here tries to undo it: a half-installed Coolify is + // something the user can see and remove, whereas a Dyad that started + // deleting directories on their machine is not. + logger.info("Cancelling Coolify server setup"); + setupController().cancel(); + }); +} diff --git a/src/ipc/ipc_host.ts b/src/ipc/ipc_host.ts index 2be0fb59e2..a8068a6d1a 100644 --- a/src/ipc/ipc_host.ts +++ b/src/ipc/ipc_host.ts @@ -59,6 +59,7 @@ import { registerImageGenerationHandlers } from "./handlers/image_generation_han import { registerCoolifyHandlers } from "./handlers/coolify_handlers"; import { registerPreviewViewHandlers } from "./handlers/preview_view_handlers"; import { registerNativeThemeHandlers } from "./handlers/native_theme_handlers"; +import { registerCoolifySetupHandlers } from "./handlers/coolify_setup_handlers"; export function registerIpcHandlers() { // Register all IPC handlers by category @@ -123,4 +124,5 @@ export function registerIpcHandlers() { registerCoolifyHandlers(); registerPreviewViewHandlers(); registerNativeThemeHandlers(); + registerCoolifySetupHandlers(); } diff --git a/src/ipc/preload/channels.test.ts b/src/ipc/preload/channels.test.ts index 255b1a44a3..954753cf97 100644 --- a/src/ipc/preload/channels.test.ts +++ b/src/ipc/preload/channels.test.ts @@ -6,6 +6,10 @@ import { previewViewEvents, previewViewSendContracts, } from "../types/preview_view"; +import { + coolifySetupContracts, + coolifySetupEvents, +} from "../types/coolify_setup"; import { VALID_INVOKE_CHANNELS, VALID_RECEIVE_CHANNELS, @@ -49,3 +53,17 @@ describe("preview view preload channels", () => { } }); }); + +describe("coolify-setup preload channels", () => { + it("allows every Coolify setup invoke and receive contract", () => { + // The preload's on() takes `ValidReceiveChannel | string`, so a channel + // missing from the list compiles and fails only when the install runs — + // which is minutes in, on a real server. + for (const contract of Object.values(coolifySetupContracts)) { + expect(VALID_INVOKE_CHANNELS).toContain(contract.channel); + } + for (const event of Object.values(coolifySetupEvents)) { + expect(VALID_RECEIVE_CHANNELS).toContain(event.channel); + } + }); +}); diff --git a/src/ipc/preload/channels.ts b/src/ipc/preload/channels.ts index ec5b371aaf..a059d4f115 100644 --- a/src/ipc/preload/channels.ts +++ b/src/ipc/preload/channels.ts @@ -27,6 +27,10 @@ import { import { mcpContracts } from "../types/mcp"; import { vercelContracts } from "../types/vercel"; import { coolifyContracts, coolifyEvents } from "../types/coolify"; +import { + coolifySetupContracts, + coolifySetupEvents, +} from "../types/coolify_setup"; import { supabaseContracts, supabaseEvents } from "../types/supabase"; import { neonContracts } from "../types/neon"; import { migrationContracts } from "../types/migration"; @@ -115,6 +119,7 @@ export const VALID_INVOKE_CHANNELS = [ ...getInvokeChannels(mcpContracts), ...getInvokeChannels(vercelContracts), ...getInvokeChannels(coolifyContracts), + ...getInvokeChannels(coolifySetupContracts), ...getInvokeChannels(supabaseContracts), ...getInvokeChannels(neonContracts), ...getInvokeChannels(migrationContracts), @@ -184,6 +189,7 @@ export const VALID_RECEIVE_CHANNELS = [ ...getReceiveChannels(agentEvents), ...getReceiveChannels(gitEvents), ...getReceiveChannels(coolifyEvents), + ...getReceiveChannels(coolifySetupEvents), ...getReceiveChannels(connectionFlowEvents), ...getReceiveChannels(supabaseEvents), ...getReceiveChannels(systemEvents), diff --git a/src/ipc/types/coolify_setup.ts b/src/ipc/types/coolify_setup.ts new file mode 100644 index 0000000000..40564f1916 --- /dev/null +++ b/src/ipc/types/coolify_setup.ts @@ -0,0 +1,271 @@ +import { z } from "zod"; +import type { CoolifySetupState } from "@/coolify_setup/state"; +import { + defineContract, + defineEvent, + createClient, + createEventClient, +} from "../contracts/core"; + +// ============================================================================= +// Coolify Setup Schemas +// ============================================================================= + +export const SetupStepSchema = z.enum([ + "connecting", + "checking-server", + "installing", + "waiting-for-dashboard", + "verifying-account", + "securing", + "creating-token", + "done", +]); + +export const ServerKeySchema = z.object({ + /** The line the user adds to their server's authorized_keys. */ + publicKey: z.string(), +}); + +/** + * Where the server is. Everything needed to reach it, and nothing else. + * + * Separate from the address of the account to create, because looking at a + * server does not need one — demanding it there rejected a check the panel + * was offering before the email had been typed. + */ +export const SetupServerSchema = z.object({ + host: z.string().min(1), + /** Coolify's installer needs root, and says so in its own documentation. */ + username: z.string().min(1).default("root"), + port: z.number().int().positive().max(65535).optional(), +}); + +export const SetupTargetSchema = SetupServerSchema.extend({ + adminEmail: z.string().min(3), + /** + * A domain the user owns, pointed at this server. + * + * Optional because Dyad can derive one from the address. Supplying one is + * better where they have it: it is theirs, and it does not draw on the free + * shared service's certificate allowance. + */ + customDomain: z.string().optional(), +}); + +/** + * What Dyad found when it looked at the server, before touching it. + * + * Reported rather than acted on, so the panel can explain the two problems a + * user can fix immediately instead of failing several minutes into an install. + */ +export const SetupPreflightSchema = z.object({ + ready: z.boolean(), + reason: z.string().nullable(), + alreadyInstalled: z.boolean(), + memoryMb: z.number().nullable(), + /** Shown so the user can compare it against their provider's console. */ + hostFingerprint: z.string().nullable(), +}); + +export const SetupResultSchema = z.object({ + dashboardUrl: z.string(), + /** + * Whether that address is encrypted. + * + * Dyad asks for a certificate and settles for plain HTTP when none arrives, + * so this is the outcome rather than a setting — and the token it carries has + * root abilities and travels on every deploy. + */ + secure: z.boolean(), + insecureReason: z.string().nullable(), + adminEmail: z.string(), + /** + * Returned so the fallback screen can show it when no token was created. + * + * On the ordinary path it is stored instead, and read back through + * revealCredentials — a password shown once is a password nobody can use. + */ + adminPassword: z.string(), + /** Null when Coolify was installed but its API could not be opened. */ + tokenStored: z.boolean(), + tokenUnavailableReason: z.string().nullable(), + version: z.string().nullable(), +}); + +/** + * What Dyad can tell the user about getting into their own server. + * + * Null where Dyad never had it: an instance connected by pasting a token has + * no admin account Dyad created, so there is no password to hand back. + */ +export const RevealedCredentialsSchema = z.object({ + dashboardUrl: z.string().nullable(), + adminEmail: z.string().nullable(), + adminPassword: z.string().nullable(), + apiToken: z.string().nullable(), + /** + * Whether these describe a Coolify that was connected and is not now. + * + * False for a server Dyad has just installed and has no token for yet: + * that one is new, and calling it previous reads as something being over. + */ + isPreviousConnection: z.boolean(), +}); + +/** + * What the main process is doing with a server, as the panel sees it. + * + * The panel keeps none of this: an install outlives the screen that started + * it, so the screen asks rather than remembers. Mirrors CoolifySetupState, + * with an assertion beside that type so neither can drift from the other. + */ +export const SetupInvocationRefSchema = z.object({ + kind: z.literal("coolify-setup"), + entityKey: z.string(), + operationId: z.string(), +}); + +export const SetupSnapshotSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("idle") }), + z.object({ + type: z.literal("running"), + host: z.string(), + invocationRef: SetupInvocationRefSchema, + step: SetupStepSchema, + log: z.string(), + stopping: z.boolean(), + }), + z.object({ + type: z.literal("done"), + host: z.string(), + invocationRef: SetupInvocationRefSchema, + result: SetupResultSchema, + }), + z.object({ + type: z.literal("failed"), + host: z.string(), + invocationRef: SetupInvocationRefSchema, + message: z.string(), + log: z.string(), + cancelled: z.boolean(), + }), +]); + +// ============================================================================= +// Coolify Setup Contracts +// ============================================================================= + +export const coolifySetupContracts = { + /** + * The public key the user has to install before anything else can happen. + * + * First, because it is the one manual step and nothing works until it is + * done. Generated on demand and reused afterwards. + */ + getServerKey: defineContract({ + channel: "coolify-setup:get-server-key", + input: z.void(), + output: ServerKeySchema, + }), + + /** + * Connects and looks, without changing anything. + * + * Separate from running the setup so the panel can show what it found — the + * host fingerprint especially — and let the user decide before a minutes-long + * install starts. + */ + inspect: defineContract({ + channel: "coolify-setup:inspect", + input: SetupServerSchema, + output: SetupPreflightSchema, + }), + + // DO NOT LOG this handler: its result carries an admin password. + run: defineContract({ + channel: "coolify-setup:run", + input: SetupTargetSchema, + output: SetupResultSchema, + // A token stored here makes every app readable as connected, exactly as + // saving one by hand does. + invalidates: () => [{ family: "apps" }, { family: "coolify" }], + // Claimed, because this panel refreshes coolify itself and chooses when: + // an install that ended on plain HTTP has a warning to show first, and a + // refresh here flips the panel to connected and unmounts the screen + // carrying it. `apps` is not claimed — nothing repeats that locally. + originHandles: () => [{ family: "coolify" }], + }), + + /** + * The credentials for a server Dyad set up, on request. + * + * A separate call rather than part of the status, so secrets cross to the + * renderer only when someone asks to see them rather than on every poll. + */ + revealCredentials: defineContract({ + channel: "coolify-setup:reveal-credentials", + input: z.void(), + output: RevealedCredentialsSchema, + }), + + /** What is going on right now, asked on mount rather than remembered. */ + snapshot: defineContract({ + channel: "coolify-setup:snapshot", + input: z.void(), + output: SetupSnapshotSchema, + }), + + /** The user has read the finished screen; put the panel back to the form. */ + dismiss: defineContract({ + channel: "coolify-setup:dismiss", + input: z.void(), + output: z.void(), + }), + + cancel: defineContract({ + channel: "coolify-setup:cancel", + input: z.void(), + output: z.void(), + }), +} as const; + +export const coolifySetupEvents = { + /** + * The whole of what is going on, rather than a step at a time. + * + * Sending the state instead of a delta is what lets a window that was not + * there for the earlier events still show the install correctly. + */ + changed: defineEvent({ + channel: "coolify-setup:changed", + payload: SetupSnapshotSchema, + }), +} as const; + +/** + * The wire shape and the machine's state are the same thing. + * + * Asserted rather than assumed. The machine owns its types — a pure machine + * module may not import from here — so this is where the two are held + * together, and either drifting fails type-checking rather than failing in a + * window that cannot parse what it was sent. Tupled, because a bare `extends` + * distributes over the union and answers true when any one member fits. + */ +type AssignableTo = [Source] extends [Target] ? true : never; +const _snapshotMatchesState: [ + AssignableTo, + AssignableTo, +] = [true, true]; +void _snapshotMatchesState; + +export const coolifySetupClient = createClient(coolifySetupContracts); +export const coolifySetupEventClient = createEventClient(coolifySetupEvents); + +export type SetupTarget = z.infer; +export type SetupServer = z.infer; +export type SetupPreflight = z.infer; +export type SetupResult = z.infer; +export type SetupSnapshot = z.infer; +export type SetupStep = z.infer; +export type RevealedCredentials = z.infer; diff --git a/src/ipc/types/index.ts b/src/ipc/types/index.ts index 9ff868170b..e528546a69 100644 --- a/src/ipc/types/index.ts +++ b/src/ipc/types/index.ts @@ -38,6 +38,19 @@ export { export { mcpContracts } from "./mcp"; export { vercelContracts } from "./vercel"; export { coolifyContracts, coolifyEvents, coolifyEventClient } from "./coolify"; +export { + coolifySetupContracts, + coolifySetupEvents, + coolifySetupEventClient, +} from "./coolify_setup"; +export type { + SetupTarget, + SetupPreflight, + SetupResult, + SetupSnapshot, + SetupStep, + RevealedCredentials, +} from "./coolify_setup"; export type { CoolifyConnection, CoolifyDeploySnapshot, @@ -508,6 +521,7 @@ import { import { mcpClient } from "./mcp"; import { vercelClient } from "./vercel"; import { coolifyClient, coolifyEventClient } from "./coolify"; +import { coolifySetupClient, coolifySetupEventClient } from "./coolify_setup"; import { supabaseClient, supabaseEventClient } from "./supabase"; import { neonClient } from "./neon"; import { migrationClient } from "./migration"; @@ -591,6 +605,7 @@ export const ipc = { mcp: mcpClient, vercel: vercelClient, coolify: coolifyClient, + coolifySetup: coolifySetupClient, supabase: supabaseClient, neon: neonClient, migration: migrationClient, @@ -642,6 +657,7 @@ export const ipc = { tests: testsEventClient, userInput: userInputEventClient, coolify: coolifyEventClient, + coolifySetup: coolifySetupEventClient, imageGeneration: imageGenerationEventClient, windowInfrastructure: windowInfrastructureEventClient, distributedMachine: distributedMachineEventClient, diff --git a/src/ipc/utils/dns_resolve.ts b/src/ipc/utils/dns_resolve.ts new file mode 100644 index 0000000000..f0f30708dc --- /dev/null +++ b/src/ipc/utils/dns_resolve.ts @@ -0,0 +1,60 @@ +import * as dns from "node:dns/promises"; + +/** + * Asking DNS where a name points, without mistaking silence for an answer. + * + * Shared because two callers need the same distinction: the app-domain check + * the user runs before saving, and the instance domain Dyad points Coolify at + * during setup. Both are advisory, and both are wrong in the same expensive + * way if a resolver that could not be reached reads as "no such record". + */ + +/** A resolver saying "no such record" — anything else is our problem, not DNS's. */ +const NO_RECORD_CODES = new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]); + +/** + * Bounded, because Save waits on it. + * + * The check is advisory, so a resolver that never answers must not be able to + * hold the button indefinitely. The bound is per attempt per configured + * nameserver, so the wait scales with how many the machine has: six seconds + * against a single stub resolver, and proportionally more where several are + * listed. Two tries rather than the default four keeps that multiple small. A + * timeout still arrives as an error code the caller reads as "could not ask" + * rather than as a missing record. + */ +const resolver = new dns.Resolver({ timeout: 3_000, tries: 2 }); + +/** + * Both families, distinguishing "no record" from "could not ask". + * + * A timeout or an unreachable resolver must not be reported as a missing + * record: telling someone to fix DNS that is already correct is exactly the + * confident-but-wrong advice the unknown verdict exists to avoid. + */ +export async function resolveBoth( + hostname: string, +): Promise<{ addresses: string[]; failed: boolean }> { + const attempt = async (fn: (h: string) => Promise) => { + try { + return { addresses: await fn(hostname), failed: false }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code ?? ""; + return { addresses: [] as string[], failed: !NO_RECORD_CODES.has(code) }; + } + }; + const [v4, v6] = await Promise.all([ + attempt((h) => resolver.resolve4(h)), + attempt((h) => resolver.resolve6(h)), + ]); + return { + addresses: [...v4.addresses, ...v6.addresses], + // One family answering is enough. But a definitive "no such record" from + // one and a failed lookup from the other is not the same as no records: + // the family we could not reach may hold the one that works. + failed: + v4.addresses.length === 0 && + v6.addresses.length === 0 && + (v4.failed || v6.failed), + }; +} diff --git a/src/ipc/utils/ssh_client.test.ts b/src/ipc/utils/ssh_client.test.ts new file mode 100644 index 0000000000..cedc73798b --- /dev/null +++ b/src/ipc/utils/ssh_client.test.ts @@ -0,0 +1,559 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { EventEmitter } from "events"; + +const h = vi.hoisted(() => { + /** A real ed25519 host key, so the fingerprint below is a golden value. */ + const HOST_KEY_B64 = + "AAAAC3NzaC1lZDI1NTE5AAAAIJiGrEZTbNIEO9U84zpD4H6mNsXVcl4il3RsnZ4MQImg"; + return { + clients: [] as FakeClientShape[], + HOST_KEY_B64, + // Scripted here rather than on the target, because connectSsh builds the + // library's config from named fields and would drop anything extra. + nextFailure: null as unknown, + }; +}); + +interface FakeStreamShape extends EventEmitter { + stderr: EventEmitter; + written: string | null; + closed: boolean; + write(data: string, cb?: () => void): void; + eof(): void; + end(data?: string): void; + close(): void; +} + +interface FakeClientShape extends EventEmitter { + connectConfig: Record | null; + execHandler: + | ((command: string, cb: (err: unknown, stream: unknown) => void) => void) + | null; + ended: boolean; +} + +/** + * Stands in for ssh2's Client. + * + * Scripted rather than a real server: what this file checks is how the wrapper + * reads the library's own signals, and those are values the library hands over + * rather than behaviour a server produces. The values themselves were captured + * from a live box — see the failure table below. + */ +vi.mock("ssh2", () => { + const { EventEmitter: EE } = require("events"); + class FakeClient extends EE { + connectConfig: Record | null = null; + execHandler: + | ((command: string, cb: (err: unknown, stream: unknown) => void) => void) + | null = null; + ended = false; + + connect(config: Record) { + this.connectConfig = config; + h.clients.push(this as unknown as FakeClientShape); + const verifier = config.hostVerifier as + | ((key: Buffer) => boolean) + | undefined; + queueMicrotask(() => { + // The verifier runs during the handshake, before anything is sent. + if (verifier && !verifier(Buffer.from(h.HOST_KEY_B64, "base64"))) { + this.emit( + "error", + Object.assign(new Error("Host denied"), { level: "handshake" }), + ); + return; + } + if (h.nextFailure) { + this.emit("error", h.nextFailure); + return; + } + this.emit("ready"); + }); + } + + exec(command: string, cb: (err: unknown, stream: unknown) => void) { + this.execHandler?.(command, cb); + } + + end() { + this.ended = true; + } + } + return { Client: FakeClient }; +}); + +const HOST_KEY_FINGERPRINT = + "SHA256:3FQS9D0B0DVizoYtw1hNV09EClubwWqRUXoFnRTu6nA"; + +import { + connectSsh, + hostKeyFingerprint, + trustOnFirstUse, + expectFingerprint, +} from "./ssh_client"; +import type { SshError } from "./ssh_client"; + +const TARGET = { host: "203.0.113.5", username: "root", privateKey: "KEY" }; + +beforeEach(() => { + h.clients.length = 0; + h.nextFailure = null; +}); + +describe("hostKeyFingerprint", () => { + it("prints what ssh-keygen prints for the same key", () => { + // Golden pair captured from a live server, so this compares against + // OpenSSH rather than against a second run of our own arithmetic. The user + // reads this string next to their provider's console; two spellings of one + // key would read as two keys. + expect(hostKeyFingerprint(Buffer.from(h.HOST_KEY_B64, "base64"))).toBe( + HOST_KEY_FINGERPRINT, + ); + }); +}); + +describe("connecting", () => { + it("reports the fingerprint before trusting the server", async () => { + let seen: string | null = null; + const session = await connectSsh( + TARGET, + trustOnFirstUse((fp) => (seen = fp)), + ); + expect(seen).toBe(HOST_KEY_FINGERPRINT); + session.end(); + }); + + it("connects when the fingerprint is the one a provider promised", async () => { + const session = await connectSsh( + TARGET, + expectFingerprint(HOST_KEY_FINGERPRINT), + ); + expect(h.clients[0].connectConfig?.host).toBe("203.0.113.5"); + session.end(); + }); + + it("refuses a server whose key is not the promised one", async () => { + await expect( + connectSsh(TARGET, expectFingerprint("SHA256:something-else")), + ).rejects.toMatchObject({ failure: "host-key-rejected" }); + }); + + it("calls a declined key the user's decision, not a warning", async () => { + // Both arrive as a handshake failure, but only one of them means the + // server's identity changed. Reporting a decline as a mismatch would tell + // someone their machine was tampered with because they clicked no. + const error = (await connectSsh(TARGET, () => false).catch( + (e) => e, + )) as SshError; + expect(error.failure).toBe("host-key-rejected"); + expect((error as unknown as { kind: string }).kind).toBe("user_cancelled"); + }); +}); + +/** + * The five shapes a connection fails in, with the level and code the library + * actually produced for each against a real server. + */ +describe("classifying a failed connection", () => { + const CASES: Array<{ + name: string; + raw: Record; + failure: string; + kind: string; + }> = [ + { + name: "a key the server will not take", + raw: { + level: "client-authentication", + message: "All configured authentication methods failed", + }, + failure: "auth-rejected", + kind: "auth", + }, + { + name: "a host that never answers", + raw: { + level: "client-timeout", + message: "Timed out while waiting for handshake", + }, + failure: "timeout", + kind: "external", + }, + { + name: "a name that does not resolve", + raw: { + level: "client-socket", + code: "ENOTFOUND", + message: "getaddrinfo ENOTFOUND nope.invalid", + }, + failure: "unreachable", + kind: "external", + }, + { + name: "a closed port", + raw: { + level: "client-socket", + code: "ECONNREFUSED", + message: "connect ECONNREFUSED", + }, + failure: "unreachable", + kind: "external", + }, + { + name: "anything else", + raw: { level: "client-socket", message: "kernel exploded" }, + failure: "unknown", + kind: "external", + }, + ]; + + it.each(CASES)("reads $name as $failure", async ({ raw, failure, kind }) => { + h.nextFailure = Object.assign(new Error(String(raw.message)), raw); + const error = (await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ).catch((e) => e)) as SshError; + expect(error.failure).toBe(failure); + expect((error as unknown as { kind: string }).kind).toBe(kind); + }); +}); + +describe("running a command", () => { + function scriptStream( + client: FakeClientShape, + script: (stream: FakeStream) => void, + ) { + client.execHandler = (_command, cb) => { + const stream = new FakeStream(); + cb(null, stream); + script(stream); + }; + } + + class FakeStream extends EventEmitter implements FakeStreamShape { + stderr = new EventEmitter(); + written: string | null = null; + eofSent = false; + ended = false; + closed = false; + write(data: string, cb?: () => void) { + this.written = (this.written ?? "") + data; + cb?.(); + } + eof() { + this.eofSent = true; + } + end(data?: string) { + this.written = data ?? null; + this.ended = true; + } + close() { + this.closed = true; + } + } + + it("sends EOF on stdin without finishing the write side", async () => { + // Both halves matter. Without the EOF, a command that reads stdin waits + // for a line that is never coming. With `end()` instead, Node destroys + // the channel as soon as the server sends its own EOF, which loses the + // exit status and, against a real sshd, kills a command still running. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + let stream!: FakeStream; + scriptStream(h.clients[0], (s) => { + stream = s; + queueMicrotask(() => s.emit("close", 0)); + }); + + await session.run("uname -a"); + + expect(stream.eofSent).toBe(true); + expect(stream.ended).toBe(false); + expect(stream.written).toBeNull(); + }); + + it("hands input to stdin rather than the command line", async () => { + // Everything this runs remotely is a script, and a script on a command + // line has to survive a shell. Feeding stdin removes that layer entirely. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + let stream!: FakeStreamShape; + scriptStream(h.clients[0], (s) => { + stream = s; + queueMicrotask(() => s.emit("close", 0)); + }); + + await session.run("cat", { input: "$(rm -rf /) 'quoted'\n" }); + + expect(stream.written).toBe("$(rm -rf /) 'quoted'\n"); + }); + + it("reports output as it arrives, not only at the end", async () => { + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + scriptStream(h.clients[0], (s) => { + s.emit("data", Buffer.from("first\n")); + s.stderr.emit("data", Buffer.from("warning\n")); + s.emit("data", Buffer.from("second\n")); + queueMicrotask(() => s.emit("close", 0)); + }); + + const chunks: string[] = []; + const result = await session.run("build", { + onOutput: (c) => chunks.push(c), + }); + + expect(chunks).toEqual(["first\n", "warning\n", "second\n"]); + expect(result.stdout).toBe("first\nsecond\n"); + expect(result.stderr).toBe("warning\n"); + expect(result.code).toBe(0); + }); + + it("stops when cancelled mid-run", async () => { + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + let stream!: FakeStreamShape; + scriptStream(h.clients[0], (s) => { + stream = s; + }); + const controller = new AbortController(); + + const running = session.run("long-install", { signal: controller.signal }); + controller.abort(); + + await expect(running).rejects.toMatchObject({ kind: "user_cancelled" }); + expect(stream.closed).toBe(true); + }); + + it("stops when the server never opens the channel", async () => { + // A frozen box still answers TCP but stops answering SSH, so the callback + // that hands over the stream never runs. A listener attached inside it + // would never exist, and Cancel would do nothing for as long as the + // command was outstanding — which wedged the whole setup. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + // Asked for, never answered. + h.clients[0].execHandler = () => {}; + const controller = new AbortController(); + + const running = session.run("long-install", { signal: controller.signal }); + controller.abort(); + + await expect(running).rejects.toMatchObject({ kind: "user_cancelled" }); + }); + + it("lets go of a channel that arrives after the cancel", async () => { + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + let hand!: (stream: FakeStream) => void; + h.clients[0].execHandler = (_command, cb) => { + hand = (stream) => cb(null, stream); + }; + const controller = new AbortController(); + const running = session.run("long-install", { signal: controller.signal }); + + controller.abort(); + const arrived = new FakeStream(); + hand(arrived); + + await expect(running).rejects.toMatchObject({ kind: "user_cancelled" }); + expect(arrived.closed).toBe(true); + }); + + it("asks the connection to notice a peer that stops answering", async () => { + // Nothing else bounds a command: readyTimeout covers the handshake only. + await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + expect(h.clients.at(-1)?.connectConfig?.keepaliveInterval).toBeTruthy(); + }); + + it("reports the connection dying rather than an exit code", async () => { + // The keepalive tears down a peer that stopped answering, which closes + // any open channel with no exit code. Read as an ordinary finish, that + // becomes "the installer failed (exit undefined)" — the connection's + // death reported as the command's verdict. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + let stream!: FakeStream; + scriptStream(h.clients[0], (s) => { + stream = s; + }); + + const running = session.run("preflight"); + h.clients[0].emit("error", { + level: "client-timeout", + message: "Keepalive timeout", + }); + // What ssh2 actually emits when no exit-status arrived: its Channel + // initialises _exit.code to undefined, and utils.js emits that value. + stream.emit("close", undefined); + + await expect(running).rejects.toMatchObject({ kind: "external" }); + }); + + it("keeps a result the command did report, even if the link then died", async () => { + // The command finished and said how. A connection that dies immediately + // afterwards must not turn that into a failure of the install. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + let stream!: FakeStream; + scriptStream(h.clients[0], (s) => { + stream = s; + }); + + const running = session.run("preflight"); + h.clients[0].emit("error", { + level: "client-timeout", + message: "Keepalive timeout", + }); + stream.emit("close", 0); + + await expect(running).resolves.toMatchObject({ code: 0 }); + }); + + it("says the server stopped rather than telling them to check the address", async () => { + // Before the handshake, "check the address and that port 22 is open" is + // the right advice. After it, the address plainly worked — repeating it + // sends the user to look at the thing that is not wrong. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + let stream!: FakeStream; + scriptStream(h.clients[0], (s) => { + stream = s; + }); + + const running = session.run("preflight"); + h.clients[0].emit("error", { + level: "client-timeout", + message: "Keepalive timeout", + }); + stream.emit("close", undefined); + + await expect(running).rejects.toMatchObject({ + message: expect.stringContaining("stopped answering"), + }); + }); + + it("blames the connection, not the channel, when the link died", async () => { + // The channel error is only how a dead connection surfaced here. Reported + // as itself it says "Not connected", which describes the symptom. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + h.clients[0].emit("error", { + level: "client-timeout", + message: "Keepalive timeout", + }); + h.clients[0].execHandler = (_command, cb) => { + cb(new Error("Not connected"), undefined); + }; + + await expect(session.run("preflight")).rejects.toMatchObject({ + failure: "timeout", + }); + }); + + it("still reports an ordinary non-zero exit as itself", async () => { + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + scriptStream(h.clients[0], (s) => { + s.emit("close", 1); + }); + + await expect(session.run("preflight")).resolves.toMatchObject({ code: 1 }); + }); + + it("lets go of the signal when the socket is already gone", async () => { + // ssh2 throws where it stands rather than calling back, which would skip + // the cleanup and strand a listener on a signal nobody will ever answer. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + h.clients[0].execHandler = () => { + throw new Error("Not connected"); + }; + const controller = new AbortController(); + const letGo = vi.spyOn(controller.signal, "removeEventListener"); + + await expect( + session.run("preflight", { signal: controller.signal }), + ).rejects.toBeTruthy(); + + // Watched directly: an AbortSignal does not say how many listeners it + // holds, and "nothing bad happened afterwards" is true whether or not one + // was left behind. + expect(letGo).toHaveBeenCalled(); + }); + + it("gives up on a command that never answers, and closes the channel", async () => { + // Giving up on the answer is not the same as stopping the work: a command + // left running holds its channel open until the whole session ends. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + let stream!: FakeStream; + scriptStream(h.clients[0], (s) => { + stream = s; + }); + + // Named apart from a lost connection: the link is still good, so a + // caller that polls can ask again rather than giving up on the server. + await expect( + session.run("docker ps", { timeoutMs: 10 }), + ).rejects.toMatchObject({ failure: "command-timeout" }); + expect(stream.closed).toBe(true); + }); + + it("leaves a command alone when no bound was asked for", async () => { + // An installer legitimately runs for minutes with nothing to say, so + // unbounded stays the default. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + scriptStream(h.clients[0], (s) => { + setTimeout(() => s.emit("close", 0), 30); + }); + + await expect(session.run("install.sh")).resolves.toMatchObject({ code: 0 }); + }); + + it("refuses before starting when already cancelled", async () => { + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + const controller = new AbortController(); + controller.abort(); + + await expect( + session.run("long-install", { signal: controller.signal }), + ).rejects.toMatchObject({ kind: "user_cancelled" }); + }); +}); diff --git a/src/ipc/utils/ssh_client.ts b/src/ipc/utils/ssh_client.ts new file mode 100644 index 0000000000..6c47c38da2 --- /dev/null +++ b/src/ipc/utils/ssh_client.ts @@ -0,0 +1,444 @@ +import { Client, type ClientChannel, type ConnectConfig } from "ssh2"; +import { createHash } from "crypto"; +import log from "electron-log"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; + +const logger = log.scope("ssh_client"); + +/** + * Dyad's SSH client, for setting a server up before Coolify exists on it. + * + * Deploying needs no SSH at all — Coolify clones from GitHub with a key Dyad + * hands it. This is the other half: reaching a bare machine to install Coolify + * in the first place, which nothing else in Dyad does. + */ + +/** Long enough for a slow link, short enough that a wrong address gives up. */ +const CONNECT_TIMEOUT_MS = 20_000; + +export interface SshTarget { + host: string; + port?: number; + username: string; + /** OpenSSH format. Node's PEM export is rejected by the wire library. */ + privateKey: string; +} + +/** + * What went wrong, from the connection itself rather than from its message. + * + * Kept as a closed set because the caller decides what to tell the user from + * it: a rejected key asks them to check the key is on the server, and an + * unreachable host asks them to check the address. Reading English out of a + * message to make that choice breaks the first time the wording moves. + */ +export type SshFailure = + | "auth-rejected" + | "host-key-rejected" + | "unreachable" + /** The connection stopped answering: nothing on it will work again. */ + | "timeout" + /** + * We gave up on one command, having asked it to be quick. + * + * Distinct from "timeout" because the connection is still good and the + * question can be asked again — a caller that polls must be able to tell + * "this attempt was slow" from "this link is dead", or one slow answer ends + * a wait that had minutes left in it. + */ + | "command-timeout" + | "unknown"; + +export class SshError extends DyadError { + constructor( + readonly failure: SshFailure, + message: string, + kind: DyadErrorKind, + ) { + super(message, kind); + this.name = "SshError"; + } +} + +/** + * The fingerprint OpenSSH would print for the same key. + * + * Matching its format matters because the user checks this against something + * else — their provider's console, or `ssh-keyscan` — and two spellings of the + * same key read as two different keys. + */ +export function hostKeyFingerprint(key: Buffer): string { + return ( + "SHA256:" + + createHash("sha256").update(key).digest("base64").replace(/=+$/, "") + ); +} + +/** + * Decides whether to trust the server presenting this key. + * + * A parameter rather than a policy baked in here, because the answer differs by + * how we arrived. A server the user typed the address of has nothing to check + * against, so the honest answer is to show them the fingerprint. A server Dyad + * asked a provider to create comes with its fingerprint in the reply, and there + * the check can be exact without asking anyone anything. + */ +export type HostKeyVerifier = ( + fingerprint: string, +) => boolean | Promise; + +/** Accepts any host, reporting the fingerprint. Trust on first use. */ +export function trustOnFirstUse( + onSeen: (fingerprint: string) => void, +): HostKeyVerifier { + return (fingerprint) => { + onSeen(fingerprint); + return true; + }; +} + +/** Accepts only the fingerprint a provider already told us to expect. */ +export function expectFingerprint(expected: string): HostKeyVerifier { + return (fingerprint) => fingerprint === expected; +} + +function classify( + err: NodeJS.ErrnoException & { level?: string }, + { connected = false }: { connected?: boolean } = {}, +): SshError { + const level = err.level ?? ""; + if (level === "client-authentication") { + return new SshError( + "auth-rejected", + "The server refused this key. Add Dyad's public key to the server's " + + "authorized_keys and try again.", + DyadErrorKind.Auth, + ); + } + if (level === "handshake") { + return new SshError( + "host-key-rejected", + "The server presented a different host key than the one expected.", + DyadErrorKind.Precondition, + ); + } + if (level === "client-timeout") { + return new SshError( + "timeout", + connected + ? "The server stopped answering. It may have run out of memory or " + + "frozen; check it and try again." + : "The server did not answer in time. Check the address and that " + + "port 22 is reachable.", + DyadErrorKind.External, + ); + } + if ( + err.code === "ENOTFOUND" || + err.code === "ECONNREFUSED" || + err.code === "EHOSTUNREACH" + ) { + return new SshError( + "unreachable", + `Could not reach the server (${err.code}). Check the address and that ` + + `port 22 is open.`, + DyadErrorKind.External, + ); + } + return new SshError( + "unknown", + connected + ? `The connection to the server failed: ${err.message}` + : `Could not connect over SSH: ${err.message}`, + DyadErrorKind.External, + ); +} + +export interface SshResult { + code: number | null; + stdout: string; + stderr: string; +} + +export interface SshSession { + /** + * Runs a command, optionally feeding it on stdin. + * + * Input goes to stdin rather than into the command line because everything + * interesting here is a script, and a script on a command line has to survive + * a shell — quoting that is where this kind of code goes wrong. + */ + run( + command: string, + options?: { + input?: string; + onOutput?: (chunk: string) => void; + signal?: AbortSignal; + /** + * How long to wait for this command, in milliseconds. + * + * Unbounded by default, because an installer legitimately runs for + * minutes with nothing to say. Anything with a shorter honest answer — + * a probe, a tinker one-liner — should give one: without it, a server + * whose docker has wedged leaves the step it is on hanging forever, and + * the user has to work out for themselves that nothing is happening. + */ + timeoutMs?: number; + }, + ): Promise; + end(): void; +} + +/** + * Opens a session, or throws having said which part failed. + * + * The verifier runs before anything is sent, so a server that fails it never + * receives the credentials for the account being set up. + */ +export async function connectSsh( + target: SshTarget, + verifyHostKey: HostKeyVerifier, + { signal }: { signal?: AbortSignal } = {}, +): Promise { + const conn = new Client(); + /** + * Why the connection died, for commands that were in flight when it did. + * + * The keepalive above turns a frozen peer into a torn-down socket, which + * closes any open channel with no exit code — and a command that reads that + * as an ordinary finish reports the connection's death as the installer's + * verdict. Kept here so the command can say what actually happened. + */ + let connectionError: SshError | null = null; + /** Whether the handshake got through, which changes what advice fits. */ + let ready = false; + let rejectedHostKey = false; + + // Registered before the handshake is awaited, not after: an error arriving + // in the same tick as "ready" would otherwise have nothing listening. The + // promise's own listener still owns rejecting while it is pending; this one + // only remembers, and keeps an unhandled 'error' on a Client — which would + // take the process down — from ever being unhandled. + conn.on("error", (err) => { + connectionError = classify( + err as NodeJS.ErrnoException & { level?: string }, + // Anything reaching here after the handshake is a session that was + // working and stopped, so the advice is about the machine rather than + // about the address that was typed. + { connected: ready }, + ); + }); + + await new Promise((resolve, reject) => { + // Reaching an address that is firewalled takes as long as the handshake + // timeout allows, and until this landed Cancel did nothing at all for + // that whole stretch — the panel said "Stopping…" and kept going. + if (signal?.aborted) { + conn.end(); + reject(new DyadError("Cancelled.", DyadErrorKind.UserCancelled)); + return; + } + const onAbort = () => { + conn.end(); + reject(new DyadError("Cancelled.", DyadErrorKind.UserCancelled)); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + const settled = (fn: () => void) => () => { + signal?.removeEventListener("abort", onAbort); + fn(); + }; + conn.on( + "ready", + settled(() => { + ready = true; + resolve(); + }), + ); + conn.on("error", (rawError) => { + signal?.removeEventListener("abort", onAbort); + const err = rawError; + // A host key the verifier turned down surfaces as a handshake failure, + // which is also what a genuinely mismatched key looks like. Saying which + // it was matters: one is the user declining, the other is a warning. + if (rejectedHostKey) { + reject( + new SshError( + "host-key-rejected", + "The server's identity was not accepted, so nothing was sent to it.", + DyadErrorKind.UserCancelled, + ), + ); + return; + } + reject(classify(err as NodeJS.ErrnoException & { level?: string })); + }); + + const config: ConnectConfig = { + host: target.host, + port: target.port ?? 22, + username: target.username, + privateKey: target.privateKey, + readyTimeout: CONNECT_TIMEOUT_MS, + // Only the handshake is bounded by readyTimeout. Without a keepalive + // nothing notices a peer that still answers TCP but has stopped + // answering SSH — a frozen box — and a command sent to it never + // settles, taking the whole setup with it. + keepaliveInterval: 15_000, + hostVerifier: (key: Buffer) => { + const accepted = verifyHostKey(hostKeyFingerprint(key)); + // The library's hook is synchronous, so an async verifier cannot be + // awaited here. Callers that need to ask the user resolve that before + // connecting and pass the answer in. + if (accepted instanceof Promise) { + throw new DyadError( + "Host key verification must be decided before connecting.", + DyadErrorKind.Internal, + ); + } + if (!accepted) rejectedHostKey = true; + return accepted; + }, + }; + conn.connect(config); + }); + + return { + run(command, { input, onOutput, signal, timeoutMs } = {}) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DyadError("Cancelled.", DyadErrorKind.UserCancelled)); + return; + } + let timer: ReturnType | undefined; + // Wired before the channel is asked for, not inside the callback + // that answers. Opening a channel takes a round trip, and on a server + // that has stopped answering that callback never runs — so a listener + // attached there would never exist, and Cancel would do nothing at + // all for as long as the command was outstanding. + let openStream: ClientChannel | null = null; + let cancelled = false; + const onAbort = () => { + cancelled = true; + openStream?.close(); + reject(new DyadError("Cancelled.", DyadErrorKind.UserCancelled)); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + const stopListening = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + }; + + if (timeoutMs !== undefined) { + timer = setTimeout(() => { + // The channel is closed as well as the promise settled: giving up + // on an answer is not the same as stopping the work, and a + // command left running holds its channel open until the whole + // session ends. + cancelled = true; + openStream?.close(); + signal?.removeEventListener("abort", onAbort); + reject( + new SshError( + "command-timeout", + "The server did not answer in time.", + DyadErrorKind.External, + ), + ); + }, timeoutMs); + } + + const askForChannel = () => + conn.exec(command, (err, stream) => { + if (err) { + stopListening(); + // The latched reason first, as everywhere else: when the + // connection died, that is what went wrong, and the channel + // error is only how it surfaced here. + reject( + connectionError ?? + classify(err as NodeJS.ErrnoException & { level?: string }, { + connected: true, + }), + ); + return; + } + let stdout = ""; + let stderr = ""; + openStream = stream; + // The cancel landed while the channel was opening: the listener + // above has already rejected, so all this has to do is let go of + // the channel it was handed. + if (cancelled) { + stream.close(); + return; + } + + stream.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf8"); + stdout += text; + onOutput?.(text); + }); + stream.stderr.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf8"); + stderr += text; + onOutput?.(text); + }); + stream.on("close", (code: number | null) => { + stopListening(); + // Only when the command never said how it ended. A channel + // closed without an exit status is the connection dying under + // it, and reporting that as an exit code blames the installer + // for a lost link — but a command that did report its own + // result gets to keep it, even if the link died straight after. + // + // Loose equality on purpose: ssh2 initialises the exit code to + // undefined and only assigns it when an exit-status arrives, so + // "never said" is undefined here rather than null. + if (connectionError && code == null) { + reject(connectionError); + return; + } + resolve({ code: code ?? null, stdout, stderr }); + }); + + // Every command gets EOF on stdin, so one that reads it sees the + // end rather than a pipe that stays open for the life of the + // channel. `eof()` rather than `end()`: ending finishes the write + // side, and Node destroys a Duplex whose write side is finished as + // soon as its read side ends — which in ssh2 sends CHANNEL_CLOSE, + // dropping an exit status still on its way and killing a command + // still running. + if (input !== undefined) { + stream.write(input, () => stream.eof()); + } else { + stream.eof(); + } + }); + + try { + askForChannel(); + } catch (error) { + // ssh2 throws where it stands when the socket is already gone, + // which would skip the cleanup below and leave the abort listener + // attached to a signal nobody will ever answer. + stopListening(); + reject( + connectionError ?? + classify(error as NodeJS.ErrnoException & { level?: string }, { + connected: true, + }), + ); + } + }); + }, + end() { + try { + conn.end(); + } catch (error) { + // Closing a connection that already died is not a failure worth + // raising over whatever actually went wrong first. + logger.debug("Ignored error while closing SSH session", error); + } + }, + }; +} diff --git a/src/ipc/utils/telemetry.test.ts b/src/ipc/utils/telemetry.test.ts index 55840ecbb8..917db82dcc 100644 --- a/src/ipc/utils/telemetry.test.ts +++ b/src/ipc/utils/telemetry.test.ts @@ -137,6 +137,27 @@ describe("exceptions from a self-hosted instance", () => { expect(payload.ipc_channel).toBe("coolify:discover"); }); + it("redacts setting a server up, not only deploying to one", () => { + // Its failures quote the installer's own output, the server's address, and + // the address the user signs in with. The prefix differs from the deploy + // channels by one word, which is all it took to miss the filter. + sendTelemetryException( + new Error( + "Installing Coolify failed. The server said: connect ECONNRESET " + + "203.0.113.5:22 for someone@theirdomain.com", + ), + { ipc_channel: "coolify-setup:run" }, + ); + + const payload = sent.calls[0]; + // The stack header repeats the message, so the whole payload is checked + // rather than the message field alone. + expect(JSON.stringify(payload)).not.toContain("203.0.113.5"); + expect(JSON.stringify(payload)).not.toContain("theirdomain.com"); + expect(payload.exception_message).toBeUndefined(); + expect(payload.ipc_channel).toBe("coolify-setup:run"); + }); + it("keeps the message for every other channel", () => { sendTelemetryException(new Error("something broke"), { ipc_channel: "apps:list", diff --git a/src/ipc/utils/telemetry.ts b/src/ipc/utils/telemetry.ts index 5682115a16..f8428369b4 100644 --- a/src/ipc/utils/telemetry.ts +++ b/src/ipc/utils/telemetry.ts @@ -79,11 +79,21 @@ export function sendTelemetryException( }); } -/** Channels that talk to a server the user runs, rather than to Dyad's own. */ +/** + * Channels that talk to a server the user runs, rather than to Dyad's own. + * + * Every prefix a self-hosted surface uses has to be listed. Setting a server up + * is the same class as deploying to one and carries more: its failures quote + * the installer's own output, the address, and the address the user signs in + * with. + */ +const SELF_HOSTED_CHANNEL_PREFIXES = ["coolify:", "coolify-setup:"]; + function isSelfHostedChannel(context?: Record): boolean { - return ( - typeof context?.ipc_channel === "string" && - context.ipc_channel.startsWith("coolify:") + const channel = context?.ipc_channel; + if (typeof channel !== "string") return false; + return SELF_HOSTED_CHANNEL_PREFIXES.some((prefix) => + channel.startsWith(prefix), ); } diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 508232cda1..69e9ec2519 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -343,6 +343,12 @@ export const queryKeys = { instanceUrl ?? "none", tokenId ?? "none", ] as const, + /** What the main process is doing with a server right now. */ + setup: ["coolify", "setup"] as const, + /** The public half of the key Dyad puts on servers it sets up. */ + serverKey: ["coolify", "serverKey"] as const, + /** What Dyad knows about signing in to the server it set up. */ + credentials: ["coolify", "credentials"] as const, /** Every instance's list, for invalidating after a token change. */ discoveryAll: ["coolify", "discovery"] as const, }, diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 03efa1e73f..227ec3b386 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -221,6 +221,32 @@ export type SupabaseOrganizationCredentials = z.infer< export const CoolifySchema = z.object({ instanceUrl: z.string().optional(), accessToken: SecretSchema.optional(), + /** + * The admin account on a server Dyad set up itself. + * + * Kept because Dyad invented this password on the user's behalf, for their + * own machine — showing it once and forgetting it leaves them locked out of + * a server they own. Encrypted like the token, and only ever handed to the + * renderer when it is asked for. + */ + adminEmail: z.string().optional(), + adminPassword: SecretSchema.optional(), + /** + * The address the admin account above belongs to. + * + * An account is only good for the instance Dyad made it on. Without this, + * connecting to a second Coolify shows its address beside the first one's + * password, which reads as a way in and is not one. + */ + adminInstanceUrl: z.string().optional(), + /** + * The token from the last connection, kept when signing out. + * + * Dyad minted this one itself, so throwing it away means the only way back + * in is making another in Coolify — for a token Dyad still had a moment ago. + * Not used to talk to anything: it is shown so it can be pasted back. + */ + previousAccessToken: SecretSchema.optional(), }); export type Coolify = z.infer; diff --git a/src/main/settings.test.ts b/src/main/settings.test.ts index 29c71c3e07..668058f479 100644 --- a/src/main/settings.test.ts +++ b/src/main/settings.test.ts @@ -1120,6 +1120,38 @@ describe("preserving undecryptable secrets", () => { }); }); + it("hides a kept Coolify token that will not decrypt", () => { + // Handing the ciphertext through would put it on screen as the token to + // paste back, and it would be rejected with no way to tell why. + store[mockSettingsPath] = JSON.stringify({ + coolify: { + instanceUrl: "http://203.0.113.5:8000", + previousAccessToken: lockedSecret("coolify"), + }, + }); + + const read = readSettings(); + expect(read.coolify?.previousAccessToken).toBeUndefined(); + expect(read.coolify?.instanceUrl).toBe("http://203.0.113.5:8000"); + }); + + it("puts the Coolify token kept for signing back in through encryption", () => { + // It is the same token it was a moment ago, and it opens the same server. + // Keeping it readable is what makes signing back in a paste; keeping it in + // the clear on disk is a different thing. + writeSettings({ + coolify: { + instanceUrl: "http://203.0.113.5:8000", + previousAccessToken: { value: "1|kept-token" }, + }, + }); + + expect(readStoredFile().coolify.previousAccessToken).toEqual({ + value: "1|kept-token", + encryptionType: "plaintext", + }); + }); + it("preserves a locked provider apiKey when a write rebuilds providerSettings without it", () => { const locked = lockedSecret("openai"); store[mockSettingsPath] = JSON.stringify({ diff --git a/src/main/settings.ts b/src/main/settings.ts index 1403064de2..1f41f7e94f 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -413,6 +413,20 @@ export function writeSettings(settings: Partial): void { accessToken: encrypt(newSettings.coolify.accessToken.value), }; } + if (newSettings.coolify?.previousAccessToken) { + newSettings.coolify = { + ...newSettings.coolify, + previousAccessToken: encrypt( + newSettings.coolify.previousAccessToken.value, + ), + }; + } + if (newSettings.coolify?.adminPassword) { + newSettings.coolify = { + ...newSettings.coolify, + adminPassword: encrypt(newSettings.coolify.adminPassword.value), + }; + } if (newSettings.supabase) { // Encrypt legacy tokens (kept for backwards compat) if (newSettings.supabase.accessToken) { @@ -685,6 +699,43 @@ function readExistingSettingsFile( combinedSettings.coolify = rest; } } + if (combinedSettings.coolify?.previousAccessToken) { + const resolved = resolveStoredSecret( + combinedSettings.coolify.previousAccessToken, + "Coolify previous access token", + ["coolify", "previousAccessToken"], + ctx, + ); + if (resolved) { + combinedSettings.coolify = { + ...combinedSettings.coolify, + previousAccessToken: resolved, + }; + } else { + const { previousAccessToken: _dropped, ...rest } = + combinedSettings.coolify; + combinedSettings.coolify = rest; + } + } + if (combinedSettings.coolify?.adminPassword) { + const resolved = resolveStoredSecret( + combinedSettings.coolify.adminPassword, + "Coolify admin password", + ["coolify", "adminPassword"], + ctx, + ); + if (resolved) { + combinedSettings.coolify = { + ...combinedSettings.coolify, + adminPassword: resolved, + }; + } else { + // Dropped rather than kept as ciphertext nobody can read. The password + // still exists on the server's own .env, which is the honest fallback. + const { adminPassword: _dropped, ...rest } = combinedSettings.coolify; + combinedSettings.coolify = rest; + } + } for (const provider in combinedSettings.providerSettings) { if (combinedSettings.providerSettings[provider].apiKey) { const resolved = resolveStoredSecret( diff --git a/src/shared/coolify_admin_email.test.ts b/src/shared/coolify_admin_email.test.ts new file mode 100644 index 0000000000..84bfba127e --- /dev/null +++ b/src/shared/coolify_admin_email.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { isPlausibleAdminEmail } from "./coolify_admin_email"; + +describe("isPlausibleAdminEmail", () => { + // Coolify resolves the domain, so these fail on the server however + // well-formed they look. Catching them here means the user finds out while + // typing rather than after a multi-minute install that seeds nothing. + it.each([ + ["admin@dyad.test", false, "a reserved TLD that cannot resolve"], + ["admin@my.localhost", false, "reserved for loopback"], + ["admin@thing.invalid", false, "reserved to always fail"], + ["admin@example.com", false, "reserved for documentation"], + ["admin", false, "not an address at all"], + ["admin@nodomain", false, "no dot, so no resolvable domain"], + ["admin@ dyad.sh", false, "a space is not allowed"], + ["someone@gmail.com", true, "an ordinary address"], + ["dev+coolify@sub.domain.co.uk", true, "tagging and subdomains are fine"], + ])("reads %s as %s (%s)", (email, expected) => { + expect(isPlausibleAdminEmail(email)).toBe(expected); + }); + + it("does not reject a domain merely for containing a reserved word", () => { + // The check looks at the last label; a stricter rule would turn away + // addresses that work perfectly well. + expect(isPlausibleAdminEmail("admin@test-lab.com")).toBe(true); + expect(isPlausibleAdminEmail("admin@example-corp.io")).toBe(true); + }); +}); diff --git a/src/shared/coolify_admin_email.ts b/src/shared/coolify_admin_email.ts new file mode 100644 index 0000000000..16b161a99d --- /dev/null +++ b/src/shared/coolify_admin_email.ts @@ -0,0 +1,28 @@ +/** + * Whether Coolify will accept an address for the admin account it seeds. + * + * Lives in shared/ so the panel can warn while the user is still typing and + * the handler can refuse before it starts, without the two drifting apart. + * Getting it wrong is expensive in a way most validation is not: Coolify checks + * this when it seeds the account, minutes into an install, and a rejected + * address leaves a finished install with no account on it. + * + * Kept free of any Node import for the same reason — the renderer imports it, + * and a `crypto` import here would take the whole window down with it. + */ +export function isPlausibleAdminEmail(email: string): boolean { + const trimmed = email.trim(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return false; + // The address is sent to the server inside a quoted shell word, and + // buildInstallCommand refuses anything that could end that quoting. Said + // here too, so it is said while the address is being typed rather than + // after Dyad has connected and looked the server over. + if (/['"\\`$\n\r#!]/.test(trimmed)) return false; + const domain = trimmed.slice(trimmed.lastIndexOf("@") + 1).toLowerCase(); + // Reserved by RFC 2606 and RFC 6761 for testing and documentation, so none + // of them resolve and none of them can ever be accepted. + const reserved = ["test", "example", "invalid", "localhost", "local"]; + const lastLabel = domain.slice(domain.lastIndexOf(".") + 1); + if (reserved.includes(lastLabel)) return false; + return !["example.com", "example.net", "example.org"].includes(domain); +} diff --git a/src/shared/coolify_domain.ts b/src/shared/coolify_domain.ts new file mode 100644 index 0000000000..4e001bdfb8 --- /dev/null +++ b/src/shared/coolify_domain.ts @@ -0,0 +1,22 @@ +/** + * Whether a domain can be handed to Coolify's own configuration. + * + * The characters are what makes this narrow rather than the shape: the value + * ends up inside a script that runs on the user's server, so anything that + * could end the quoting around it is refused rather than escaped. + * + * Lives in shared/ so the panel can say so while the domain is being typed and + * the code that builds the script can refuse the same thing — that refusal is + * a guard, and a guard is not a good place to learn you made a typo, since it + * fires minutes into an install. + * + * Free of any Node import, because the renderer imports it. + */ +export function isPlausibleInstanceDomain(value: string): boolean { + const bare = value + .trim() + .replace(/^https?:\/\//, "") + .replace(/\/+$/, ""); + if (!bare) return false; + return /^[A-Za-z0-9.-]+$/.test(bare); +} diff --git a/src/coolify_deploy/domain_check.test.ts b/src/shared/domain_check.test.ts similarity index 100% rename from src/coolify_deploy/domain_check.test.ts rename to src/shared/domain_check.test.ts diff --git a/src/coolify_deploy/domain_check.ts b/src/shared/domain_check.ts similarity index 97% rename from src/coolify_deploy/domain_check.ts rename to src/shared/domain_check.ts index ffb1f0f417..f70ede58cd 100644 --- a/src/coolify_deploy/domain_check.ts +++ b/src/shared/domain_check.ts @@ -1,5 +1,13 @@ import { isIP } from "node:net"; +/** + * Reading DNS answers, for whoever needs to. + * + * Lives in shared/ rather than beside the deploy machine: the setup flow asks + * the same question about a server's own domain, and one machine reaching into + * another's directory is what the boundary check exists to stop. + */ + /** What Coolify reports as the address of the machine it runs on. */ const DOCKER_HOST_ALIAS = "host.docker.internal"; diff --git a/src/state_machines/boundaries.test.ts b/src/state_machines/boundaries.test.ts index c401c74c33..f40c1ca1fb 100644 --- a/src/state_machines/boundaries.test.ts +++ b/src/state_machines/boundaries.test.ts @@ -20,6 +20,7 @@ const MACHINE_DIRECTORIES = [ "version_preview", "voice_to_text", "user_input", + "coolify_setup", ] as const; type MachineDirectory = (typeof MACHINE_DIRECTORIES)[number]; type BoundaryRule = diff --git a/src/window_infrastructure/renderer_query_invalidation.test.ts b/src/window_infrastructure/renderer_query_invalidation.test.ts index 484725bd58..84b535a224 100644 --- a/src/window_infrastructure/renderer_query_invalidation.test.ts +++ b/src/window_infrastructure/renderer_query_invalidation.test.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { describe, expect, it, vi } from "vitest"; import { queryKeys } from "@/lib/queryKeys"; import { coolifyContracts } from "@/ipc/types/coolify"; +import { coolifySetupContracts } from "@/ipc/types/coolify_setup"; import { queryInvalidationScopeKey, type WindowSessionId } from "./types"; import { RendererQueryInvalidationConsumer } from "./renderer_query_invalidation"; @@ -201,6 +202,30 @@ describe("Coolify contracts and the window that acted", () => { } }); + it("lets the installer decide when its own window refreshes", () => { + // coolify-setup:run stores a token, which makes every app read as + // connected — so the panel that ran the install would be unmounted by its + // own invalidation, taking with it the screen that says the server ended + // up unencrypted. The panel refreshes coolify itself, when it is ready. + const contract = coolifySetupContracts.run as { + originHandles?: (input: unknown) => Array<{ family: string }>; + invalidates?: (input: unknown) => Array<{ family: string }>; + }; + const claims = (contract.originHandles?.({}) ?? []).map((s) => s.family); + const publishes = (contract.invalidates?.({}) ?? []).map((s) => s.family); + + expect(claims).toContain("coolify"); + // Not apps: nothing repeats that locally, so it must still arrive. + expect(claims).not.toContain("apps"); + expect(publishes).toContain("apps"); + expect(publishes).toContain("coolify"); + + // There is no second publisher to worry about any more: a window that + // wants to see a run in progress asks for the snapshot rather than + // pressing Install again, so every run is published by the one window + // that started it. + }); + it("publishes project creation so other windows see the new project", () => { const { publishes } = handled("createProject", { name: "x" }); expect(publishes).toContain("coolify"); diff --git a/testing/fake-llm-server/coolify.ts b/testing/fake-llm-server/coolify.ts index ba13b75880..2fd1335d38 100644 --- a/testing/fake-llm-server/coolify.ts +++ b/testing/fake-llm-server/coolify.ts @@ -76,6 +76,14 @@ export function registerFakeCoolify(app: Express): void { const base = "/coolify/api/v1"; + // The same API at the root of the host too: an installed Coolify lives + // there, while a pasted URL points at the /coolify mount. Rewritten rather + // than registered twice, which would drift. + app.use((req, _res, next) => { + if (req.url.startsWith("/api/v1")) req.url = `/coolify${req.url}`; + next(); + }); + // Lets a spec choose the shape of the run before it starts. Named fields // rather than a spread, which would also let a caller replace the Maps. app.post("/coolify/test/reset", (req, res) => { diff --git a/vite.main.config.mts b/vite.main.config.mts index 2b19f32d9a..72725330ab 100644 --- a/vite.main.config.mts +++ b/vite.main.config.mts @@ -39,6 +39,7 @@ export default defineConfig(({ forgeConfigSelf }) => ({ "better-sqlite3", "dyad-keychain-reader", "node-pty", + "ssh2", "mustardscript", "pg", "ws", From 6a520b7174b698ecdc994094030f6a9d9d8f90d1 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Thu, 20 Aug 2026 17:16:03 -0500 Subject: [PATCH 02/91] fix(coolify): address review feedback on the SSH setup flow Security and correctness: - Pin the host key the inspection showed. The panel displays a fingerprint and asks the user to commit minutes to it; the install now holds the server to it, and a mismatch says the identity changed rather than being filed as a cancellation. - Send the generated password over stdin instead of on the remote command line, where any other user on the machine can read it out of ps. - Run the installer under `set -o pipefail`. A curl that downloaded nothing still ended in a bash that exited 0, so a failed download read as a finished install. - Resolve a named SSH host before accepting a custom domain, so a domain pointing somewhere else cannot become the address a root token is sent to. - Take the domain back off on every exit that is not a certificate, including a cancel that lands while it is being applied. - Attach an error handler to the SSH channel. An unhandled stream error took the main process down rather than failing the command. - Refuse setup when Docker is installed but not answering, which otherwise reads exactly like a machine with no Coolify on it. - Carry out commands from a transition that stays put; they were dropped because the state had not changed. - Survive a settings write that fails, and say the token was not stored when it was not. Smaller: - Validate the custom domain and the admin email's shape before the install starts rather than minutes in. - Show the same public key line on every launch, and never one belonging to another key or naming another key type. - Validate the container name both tinker commands are built from. - Report a refused clipboard write, and stop the copy timer on unmount. - Keep Install disabled until the setup snapshot is known. - Make HostKeyVerifier synchronous, which is all connectSsh can honour. Tests: an inert assertion in setup_flow.test.ts now checks the revert it claims to; the connector test keeps one query client per test; new cases cover the channel error, the pin, the install script, the docker probe, the domain comparison, the cancel revert, and the server key. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyConnector.test.tsx | 11 +- src/components/CoolifyConnector.tsx | 7 +- src/components/CoolifyCredentials.tsx | 18 +- src/components/CoolifyServerSetup.tsx | 22 ++- src/coolify_setup/controller.test.ts | 18 ++ src/coolify_setup/controller.ts | 9 +- src/coolify_setup/https_setup.test.ts | 88 ++++++++- src/coolify_setup/https_setup.ts | 71 ++++--- src/coolify_setup/install.test.ts | 80 +++++++- src/coolify_setup/install.ts | 55 +++++- src/coolify_setup/server_key.test.ts | 80 ++++++++ src/coolify_setup/server_key.ts | 32 +++- src/coolify_setup/setup_flow.test.ts | 11 +- src/coolify_setup/sleep.ts | 18 ++ src/coolify_setup/tinker.test.ts | 18 ++ src/coolify_setup/tinker.ts | 12 ++ .../handlers/coolify_setup_handlers.test.ts | 76 +++++++- src/ipc/handlers/coolify_setup_handlers.ts | 175 +++++++++++++----- src/ipc/types/coolify_setup.ts | 6 +- src/ipc/utils/ssh_client.test.ts | 14 ++ src/ipc/utils/ssh_client.ts | 38 ++-- src/shared/coolify_admin_email.ts | 13 +- 22 files changed, 746 insertions(+), 126 deletions(-) create mode 100644 src/coolify_setup/server_key.test.ts create mode 100644 src/coolify_setup/sleep.ts diff --git a/src/components/CoolifyConnector.test.tsx b/src/components/CoolifyConnector.test.tsx index d5651df8bb..7e882bf867 100644 --- a/src/components/CoolifyConnector.test.tsx +++ b/src/components/CoolifyConnector.test.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { queryKeys } from "@/lib/queryKeys"; import { render, screen, waitFor } from "@testing-library/react"; @@ -108,9 +108,12 @@ beforeEach(() => { }); function CoolifyConnector(props: { appId: number | null }) { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); + // One client for the life of the test, so a rerender keeps the cache it + // built — a fresh one per render would throw away anything a test invalidated + // or cached and quietly pass on the re-seed below. + const [client] = useState( + () => new QueryClient({ defaultOptions: { queries: { retry: false } } }), + ); // Seeded rather than fetched: what the main process is doing is present on // the first render in the app too, once any window has asked. Waiting for // it here would make an "it is not shown" assertion pass before the answer diff --git a/src/components/CoolifyConnector.tsx b/src/components/CoolifyConnector.tsx index 554efcfbd4..9097f34e66 100644 --- a/src/components/CoolifyConnector.tsx +++ b/src/components/CoolifyConnector.tsx @@ -314,10 +314,9 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { // say about how it went, outranks anything else this panel could show. Read // from the main process rather than remembered here, so leaving the screen // and coming back finds it again. - // A failure leaves the form on screen with the installer's output under it, - // which is the same panel the user would land on anyway — so it is only a - // reason to hold the view when something is running or finished. Being held - // there after a failure made the token form unreachable. + // A failure is not one of them: it leaves the form on screen with the + // installer's output under it, which is the panel the user would land on + // anyway, and holding the view there puts the token form out of reach. if (setupState.type === "running" || setupState.type === "done") { return (
diff --git a/src/components/CoolifyCredentials.tsx b/src/components/CoolifyCredentials.tsx index 8e55f49caa..cf60604050 100644 --- a/src/components/CoolifyCredentials.tsx +++ b/src/components/CoolifyCredentials.tsx @@ -1,8 +1,9 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Eye, EyeOff, Copy, Check } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ipc } from "@/ipc/types"; +import { showError } from "@/lib/toast"; import { queryKeys } from "@/lib/queryKeys"; /** @@ -29,6 +30,8 @@ function Field({ }) { const [shown, setShown] = useState(false); const [copied, setCopied] = useState(false); + const resetTimer = useRef>(undefined); + useEffect(() => () => clearTimeout(resetTimer.current), []); const id = label.toLowerCase().replace(/\s+/g, "-"); return (
@@ -57,10 +60,15 @@ function Field({ + {snapshot.isError && ( +
+ Could not read what Dyad is doing with servers right now.{" "} + +
+ )}
)} + {snapshot.isError && ( +

+ Could not read what Dyad is doing with servers right now.{" "} + +

+ )} +
-
- )}
+ {!inspectionForHost && host.trim() && ( +

+ Check the server first. Dyad shows you its fingerprint, and installs + only onto the machine that answered. +

+ )} {children}
diff --git a/src/coolify_setup/https_setup.ts b/src/coolify_setup/https_setup.ts index 0f1c0ffe0c..b13d44ef24 100644 --- a/src/coolify_setup/https_setup.ts +++ b/src/coolify_setup/https_setup.ts @@ -297,8 +297,8 @@ export async function tryEnableHttps( // apply it, rebuild the proxy, wait out the whole certificate poll for an // answer that cannot come, and take it all back off again. const hostAddresses = - isIP(host) === 0 ? (await resolve(host)).addresses : undefined; - if (hostAddresses && !resolvesPublicly(hostAddresses)) { + isIP(host) === 0 ? (await resolve(host)).addresses : [host]; + if (!resolvesPublicly(hostAddresses)) { return { instanceUrl: plainUrlFor(host), secure: false, diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts index c7fa90ce3e..5061194fa0 100644 --- a/src/ipc/handlers/coolify_setup_handlers.test.ts +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -108,6 +108,12 @@ vi.mock("@/coolify_setup/setup_flow", () => ({ const { registerCoolifySetupHandlers, resetCoolifySetupStateForTests } = await import("./coolify_setup_handlers"); +/** Install requires a check first, so this is what "run it" means now. */ +async function checkThenRun(input: Record = TARGET) { + await call("coolify-setup:inspect", input); + return call("coolify-setup:run", input); +} + function call(channel: string, input?: unknown) { const handler = handlers.get(channel); if (!handler) throw new Error(`No handler for ${channel}`); @@ -189,8 +195,7 @@ describe("run", () => { // The panel shows that fingerprint and asks the user to commit minutes to // it. Without the pin, the install accepts whatever answers the address by // the time it starts. - await call("coolify-setup:inspect", TARGET); - await call("coolify-setup:run", TARGET); + await checkThenRun(); expect(h.verifiedAgainst).toContain("SHA256:fingerprint"); }); @@ -200,9 +205,10 @@ describe("run", () => { // like fe80::1 parses as a scheme with no hostname and shares one entry, // so a second server would be refused for the first one's key. await call("coolify-setup:inspect", { ...TARGET, host: "fe80::1" }); - await call("coolify-setup:run", { ...TARGET, host: "fe80::2" }); - expect(h.verifiedAgainst).toEqual([]); + await expect( + call("coolify-setup:run", { ...TARGET, host: "fe80::2" }), + ).rejects.toThrow(/Check the server/); }); it("says the identity changed rather than reporting a cancellation", async () => { @@ -216,9 +222,7 @@ describe("run", () => { DyadErrorKind.UserCancelled, ); - await expect(call("coolify-setup:run", TARGET)).rejects.toThrow( - /identity has changed/, - ); + await expect(checkThenRun()).rejects.toThrow(/identity has changed/); }); it("finishes when the account cannot be written down", async () => { @@ -227,7 +231,7 @@ describe("run", () => { // copy of a password Dyad invented. h.writeThrows = true; - await expect(call("coolify-setup:run", TARGET)).resolves.toMatchObject({ + await expect(checkThenRun()).resolves.toMatchObject({ adminPassword: "Abc123@xyz", // Nothing was written, so the next screen has no token to use — saying // otherwise sends the user to a panel that cannot work. @@ -243,7 +247,8 @@ describe("run", () => { coolify: { previousAccessToken: { value: "1|old" } }, } as Record; - await call("coolify-setup:run", TARGET); + await call("coolify-setup:inspect", TARGET); + await checkThenRun(); const saved = h.written.at(-1) as { coolify: { previousAccessToken?: unknown }; @@ -251,8 +256,16 @@ describe("run", () => { expect(saved.coolify.previousAccessToken).toBeUndefined(); }); + it("refuses a server it has not looked at", async () => { + // The form disables Install until the check has run, but this is the call + // that sends the credentials, so it says no on its own account. + await expect(call("coolify-setup:run", TARGET)).rejects.toThrow( + /Check the server/, + ); + }); + it("stores the token it minted", async () => { - await call("coolify-setup:run", TARGET); + await checkThenRun(); const saved = h.written.at(-1) as { coolify: { accessToken: { value: string }; instanceUrl: string }; }; @@ -263,7 +276,7 @@ describe("run", () => { it("stores the admin password, so the user is not locked out later", async () => { // Dyad invented this password for a machine the user owns. Storing the // token but not this leaves them unable to sign in to their own server. - await call("coolify-setup:run", TARGET); + await checkThenRun(); const saved = h.written.at(-1) as { coolify: { adminPassword?: { value: string }; adminEmail?: string }; }; @@ -274,7 +287,7 @@ describe("run", () => { it("records which instance the account is on", async () => { // Connecting Dyad to a different Coolify later has to know this account // does not come along. - await call("coolify-setup:run", TARGET); + await checkThenRun(); const saved = h.written.at(-1) as { coolify: { adminInstanceUrl?: string }; }; @@ -282,10 +295,7 @@ describe("run", () => { }); it("returns the password so it can be shown once", async () => { - const result = (await call("coolify-setup:run", TARGET)) as Record< - string, - unknown - >; + const result = (await checkThenRun()) as Record; expect(result.adminPassword).toBe("Abc123@xyz"); expect(result.tokenStored).toBe(true); }); @@ -296,10 +306,7 @@ describe("run", () => { token: null, tokenUnavailableReason: "too old", }; - const result = (await call("coolify-setup:run", TARGET)) as Record< - string, - unknown - >; + const result = (await checkThenRun()) as Record; expect(result.tokenStored).toBe(false); expect(result.tokenUnavailableReason).toBe("too old"); @@ -328,7 +335,7 @@ describe("run", () => { h.setupError = new Error( "Coolify was installed but its dashboard did not start.", ); - await call("coolify-setup:run", TARGET).catch(() => {}); + await checkThenRun().catch(() => {}); const saved = h.written.at(-1) as { coolify: { adminPassword?: { value: string }; adminInstanceUrl?: string }; @@ -340,7 +347,7 @@ describe("run", () => { it("writes nothing when the failure came before any account", async () => { h.reportsAccount = false; h.setupError = new Error("This server cannot be set up automatically."); - await call("coolify-setup:run", TARGET).catch(() => {}); + await checkThenRun().catch(() => {}); expect(h.written).toHaveLength(0); }); @@ -352,7 +359,7 @@ describe("run", () => { h.setupResult = new Promise((resolve) => { release = () => resolve(RESULT); }); - const first = call("coolify-setup:run", TARGET); + const first = checkThenRun(); await expect( call("coolify-setup:run", { ...TARGET, host: "198.51.100.7" }), ).rejects.toMatchObject({ kind: "precondition" }); @@ -368,8 +375,8 @@ describe("run", () => { h.setupResult = new Promise((resolve) => { release = () => resolve(RESULT); }); - const first = call("coolify-setup:run", TARGET); - await expect(call("coolify-setup:run", TARGET)).rejects.toMatchObject({ + const first = checkThenRun(); + await expect(checkThenRun()).rejects.toMatchObject({ kind: "precondition", }); release(); @@ -381,6 +388,7 @@ describe("run", () => { h.setupResult = new Promise((resolve) => { release = () => resolve(RESULT); }); + await call("coolify-setup:inspect", TARGET); const running = call("coolify-setup:run", TARGET); const snapshot = (await call("coolify-setup:snapshot")) as { @@ -398,7 +406,7 @@ describe("run", () => { }); it("puts the finished screen away when the user moves on", async () => { - await call("coolify-setup:run", TARGET); + await checkThenRun(); await call("coolify-setup:dismiss"); expect( @@ -408,9 +416,9 @@ describe("run", () => { it("frees the slot even when setup failed", async () => { h.setupError = new Error("boom"); - await call("coolify-setup:run", TARGET).catch(() => {}); + await checkThenRun().catch(() => {}); h.setupError = null; - await expect(call("coolify-setup:run", TARGET)).resolves.toBeTruthy(); + await expect(checkThenRun()).resolves.toBeTruthy(); }); }); @@ -617,6 +625,7 @@ describe("cancel", () => { h.setupResult = new Promise((resolve) => { release = () => resolve(RESULT); }); + await call("coolify-setup:inspect", TARGET); const running = call("coolify-setup:run", TARGET); // The flow is handed a signal; cancelling is what trips it. const signal = h.lastSetupOptions?.signal as AbortSignal; diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index bff2a73f2a..8fe1831ceb 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -246,6 +246,10 @@ function targetFrom(input: SetupServer, privateKey: string) { /** Test-only: the pin map and the controller both outlive a single case. */ export function resetCoolifySetupStateForTests(): void { inspectedFingerprints.clear(); + // Cancelled before disposed: disposing stops the controller talking, it does + // not stop what it started, and a run left going would go on writing + // settings while the next case is watching them. + controller?.cancel(); controller?.dispose(); controller = null; } @@ -322,6 +326,14 @@ export function registerCoolifySetupHandlers() { DyadErrorKind.Validation, ); } + if (!inspectedFingerprints.has(hostIdentity(input.host))) { + throw new DyadError( + "Check the server before installing. Dyad shows you its fingerprint " + + "first, so the install goes to the machine that answered rather " + + "than to whatever holds the address by then.", + DyadErrorKind.Precondition, + ); + } // One at a time is the machine's rule, not a check here; it refuses by // throwing, and the panel shows that. return setupController().start(input).result; diff --git a/src/ipc/utils/ssh_client.ts b/src/ipc/utils/ssh_client.ts index f6ae610b69..9fd67aa8b8 100644 --- a/src/ipc/utils/ssh_client.ts +++ b/src/ipc/utils/ssh_client.ts @@ -310,6 +310,7 @@ export async function connectSsh( let cancelled = false; const onAbort = () => { cancelled = true; + stopListening(); openStream?.close(); reject(new DyadError("Cancelled.", DyadErrorKind.UserCancelled)); }; diff --git a/testing/fake-llm-server/coolify.ts b/testing/fake-llm-server/coolify.ts index 2fd1335d38..7af1976cf4 100644 --- a/testing/fake-llm-server/coolify.ts +++ b/testing/fake-llm-server/coolify.ts @@ -1,3 +1,4 @@ +import { Router } from "express"; import type { Express, Request, Response } from "express"; /** @@ -77,12 +78,10 @@ export function registerFakeCoolify(app: Express): void { const base = "/coolify/api/v1"; // The same API at the root of the host too: an installed Coolify lives - // there, while a pasted URL points at the /coolify mount. Rewritten rather - // than registered twice, which would drift. - app.use((req, _res, next) => { - if (req.url.startsWith("/api/v1")) req.url = `/coolify${req.url}`; - next(); - }); + // there, while a pasted URL points at the /coolify mount. Mounted twice + // rather than rewriting the URL — a rewrite would move every /api/v1 + // request on this shared server, including one another fake meant to answer. + const api = Router(); // Lets a spec choose the shape of the run before it starts. Named fields // rather than a spread, which would also let a caller replace the Maps. @@ -100,31 +99,31 @@ export function registerFakeCoolify(app: Express): void { res.json([...state.applications.values()]); }); - app.get(`${base}/servers`, (req, res) => { + api.get("/servers", (req, res) => { if (!authed(req, res)) return; res.json(state.servers); }); - app.get(`${base}/projects`, (req, res) => { + api.get("/projects", (req, res) => { if (!authed(req, res)) return; res.json(state.projects); }); - app.post(`${base}/projects`, (req, res) => { + api.post("/projects", (req, res) => { if (!authed(req, res)) return; const project = { uuid: id("prj"), name: String(req.body?.name ?? "") }; state.projects.push(project); res.json(project); }); - app.get(`${base}/security/keys`, (req, res) => { + api.get("/security/keys", (req, res) => { if (!authed(req, res)) return; // The private half is echoed back the way an instance with // read:sensitive would, so a spec can assert what Dyad uploaded. res.json(state.keys); }); - app.post(`${base}/security/keys`, (req, res) => { + api.post("/security/keys", (req, res) => { if (!authed(req, res)) return; const key: FakeKey = { uuid: id("key"), @@ -136,7 +135,7 @@ export function registerFakeCoolify(app: Express): void { res.json({ uuid: key.uuid }); }); - app.post(`${base}/applications/private-deploy-key`, (req, res) => { + api.post("/applications/private-deploy-key", (req, res) => { if (!authed(req, res)) return; const uuid = id("app"); // Resolved, not guessed: the pipeline compares this against the key it @@ -170,7 +169,7 @@ export function registerFakeCoolify(app: Express): void { res.json({ uuid }); }); - app.get(`${base}/applications/:uuid`, (req, res) => { + api.get("/applications/:uuid", (req, res) => { if (!authed(req, res)) return; const found = state.applications.get(req.params.uuid); if (!found) { @@ -180,7 +179,7 @@ export function registerFakeCoolify(app: Express): void { res.json(found); }); - app.patch(`${base}/applications/:uuid`, (req, res) => { + api.patch("/applications/:uuid", (req, res) => { if (!authed(req, res)) return; const found = state.applications.get(req.params.uuid); if (!found) { @@ -191,13 +190,13 @@ export function registerFakeCoolify(app: Express): void { res.json({ uuid: found.uuid }); }); - app.delete(`${base}/applications/:uuid`, (req, res) => { + api.delete("/applications/:uuid", (req, res) => { if (!authed(req, res)) return; state.applications.delete(req.params.uuid); res.json({ ok: true }); }); - app.post(`${base}/applications/:uuid/envs`, (req, res) => { + api.post("/applications/:uuid/envs", (req, res) => { if (!authed(req, res)) return; const found = state.applications.get(req.params.uuid); if (!found) { @@ -209,7 +208,7 @@ export function registerFakeCoolify(app: Express): void { }); // The client falls back to this when POSTing an existing variable 409s. - app.patch(`${base}/applications/:uuid/envs`, (req, res) => { + api.patch("/applications/:uuid/envs", (req, res) => { if (!authed(req, res)) return; const found = state.applications.get(req.params.uuid); if (!found) { @@ -220,7 +219,7 @@ export function registerFakeCoolify(app: Express): void { res.json({ ok: true }); }); - app.post(`${base}/applications/:uuid/start`, (req, res) => { + api.post("/applications/:uuid/start", (req, res) => { if (!authed(req, res)) return; const deploymentUuid = id("dep"); state.deployments.set(deploymentUuid, { @@ -230,7 +229,7 @@ export function registerFakeCoolify(app: Express): void { res.json({ deployment_uuid: deploymentUuid }); }); - app.get(`${base}/deployments/:uuid`, (req, res) => { + api.get("/deployments/:uuid", (req, res) => { if (!authed(req, res)) return; const deployment = state.deployments.get(req.params.uuid); if (!deployment) { @@ -250,4 +249,7 @@ export function registerFakeCoolify(app: Express): void { : JSON.stringify([{ output: "Build finished" }]), }); }); + + app.use(base, api); + app.use("/api/v1", api); } From 19c253693accdf724b332889e373fcbbc33be738 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 21 Aug 2026 13:55:37 -0500 Subject: [PATCH 09/91] fix(coolify): show a running install first, and gate on a check that passed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit's message claimed the panel had been reordered to show a running install ahead of the per-app status read. It had not — the file was never touched. It is done here: an install belongs to the machine rather than to the app being looked at, and put behind that read it was replaced by a spinner, or by an error about something else, with its Cancel button and the installer's output out of reach. The check the install now requires has to have passed. The fingerprint is recorded during the handshake, before preflight has said anything, so asking whether one exists only meant "something answered at this address" — a wedged docker daemon or a server that already has Coolify would both let the install through. A server is remembered as ready only when a check got through and liked it, and the answer is dropped when a later check takes it back. A failure is reported once. Keying the toast on the error kind was a guess at whether the machine had taken the run on, and a wrong one: the flow raises Precondition too, when its own look at the server refuses at install time. A refusal that never reached the machine now says so on the error. Three tests were hollowed out by requiring the check, since Install was disabled before they pressed it: the in-flight guard, the unreadable key, and the rejected address all now check first, so what they assert is what they name. The one-at-a-time refusal on a second machine checks that machine too, or it stops at the gate instead of the rule it is about. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyConnector.test.tsx | 30 ++++++ src/components/CoolifyConnector.tsx | 96 +++++++++---------- src/components/CoolifyServerSetup.test.tsx | 23 ++++- src/components/CoolifyServerSetup.tsx | 27 +++--- .../handlers/coolify_setup_handlers.test.ts | 57 +++++++++-- src/ipc/handlers/coolify_setup_handlers.ts | 82 ++++++++++------ src/ipc/types/coolify_setup.ts | 10 ++ 7 files changed, 225 insertions(+), 100 deletions(-) diff --git a/src/components/CoolifyConnector.test.tsx b/src/components/CoolifyConnector.test.tsx index 7e882bf867..f37099dc0b 100644 --- a/src/components/CoolifyConnector.test.tsx +++ b/src/components/CoolifyConnector.test.tsx @@ -126,6 +126,36 @@ function CoolifyConnector(props: { appId: number | null }) { ); } +describe("an install that is going on", () => { + it("is shown even when this app's own status cannot be read", async () => { + // The install belongs to the machine, not to the app being looked at. Put + // behind that read, a multi-minute run is replaced by an error about + // something else, with its Cancel button out of reach. + deploy.value = { + status: undefined, + statusError: new Error("could not read the app"), + }; + setup.state = { + type: "running", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + step: "installing", + log: "", + stopping: false, + }; + render(); + + await waitFor(() => + expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(), + ); + expect(screen.queryByTestId("coolify-status-error")).toBeNull(); + }); +}); + describe("before the status query has answered", () => { it("waits rather than claiming a failure when it is merely paused", () => { // Offline: pending, no data, no error. Nothing has gone wrong. diff --git a/src/components/CoolifyConnector.tsx b/src/components/CoolifyConnector.tsx index 9097f34e66..89614c6072 100644 --- a/src/components/CoolifyConnector.tsx +++ b/src/components/CoolifyConnector.tsx @@ -153,6 +153,54 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { setIsEditingConnection(false); }, [appId, status?.hasToken]); + // On both screens below. Signing out is exactly when someone needs the + // password back, and it is the state that leaves them here. + const previousConnection = ; + + // One element, not one per branch: rendering a second copy elsewhere in the + // tree would remount the panel and lose the result it is being kept for. + const setupState: SetupSnapshot = setupSnapshot ?? { type: "idle" }; + + const serverSetup = ( + { + if (url) setInstanceUrl(url); + setIsEnteringToken(true); + }} + > + {previousConnection} + + {/* Last, under anything Dyad already knows about a Coolify: this is the + exit for the people the installer does not apply to, not another + control on it. */} +
+ +
+
+ ); + + // Before the token check: an install that is going on, or has something to + // say about how it went, outranks anything else this panel could show. Read + // from the main process rather than remembered here, so leaving the screen + // and coming back finds it again. + // A failure is not one of them: it leaves the form on screen with the + // installer's output under it, which is the panel the user would land on + // anyway, and holding the view there puts the token form out of reach. + if (setupState.type === "running" || setupState.type === "done") { + return ( +
+ {serverSetup} +
+ ); + } + // A query react-query has paused — the renderer is offline — is pending // with no data and no error, which is not a failure and must not read as // one. It is waiting, so it shows as waiting. @@ -277,54 +325,6 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
); - // On both screens below. Signing out is exactly when someone needs the - // password back, and it is the state that leaves them here. - const previousConnection = ; - - // One element, not one per branch: rendering a second copy elsewhere in the - // tree would remount the panel and lose the result it is being kept for. - const setupState: SetupSnapshot = setupSnapshot ?? { type: "idle" }; - - const serverSetup = ( - { - if (url) setInstanceUrl(url); - setIsEnteringToken(true); - }} - > - {previousConnection} - - {/* Last, under anything Dyad already knows about a Coolify: this is the - exit for the people the installer does not apply to, not another - control on it. */} -
- -
-
- ); - - // Before the token check: an install that is going on, or has something to - // say about how it went, outranks anything else this panel could show. Read - // from the main process rather than remembered here, so leaving the screen - // and coming back finds it again. - // A failure is not one of them: it leaves the form on screen with the - // installer's output under it, which is the panel the user would land on - // anyway, and holding the view there puts the token form out of reach. - if (setupState.type === "running" || setupState.type === "done") { - return ( -
- {serverSetup} -
- ); - } - // --- Step 1: get a Coolify, or connect to one --- if (!status.hasToken) { // Installing comes first: the token form asks for an address and a token, diff --git a/src/components/CoolifyServerSetup.test.tsx b/src/components/CoolifyServerSetup.test.tsx index 8533e51fa5..254629cc64 100644 --- a/src/components/CoolifyServerSetup.test.tsx +++ b/src/components/CoolifyServerSetup.test.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { queryKeys } from "@/lib/queryKeys"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { SETUP_NOT_STARTED } from "@/ipc/types/coolify_setup"; const toastMock = vi.hoisted(() => ({ warning: vi.fn(), @@ -151,6 +152,9 @@ describe("the admin address", () => { screen.getByTestId("coolify-setup-email"), "admin@dyad.test", ); + // Checked, so what refuses below is the address rather than the check the + // button is otherwise waiting for. + await checkServer(user); expect( screen.getByTestId("coolify-setup-install").hasAttribute("disabled"), @@ -458,7 +462,11 @@ describe("pressing Install", () => { renderPanel(); await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await checkServer(user); await user.click(screen.getByTestId("coolify-setup-install")); + // The press has to have landed, or what is disabled below is the button + // waiting for a check rather than the run it started. + expect(h.run).toHaveBeenCalledTimes(1); await waitFor(() => expect( @@ -476,6 +484,9 @@ describe("pressing Install", () => { renderPanel(); await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + // Checked, so what is disabled below is the missing key rather than the + // check the button is otherwise waiting for. + await checkServer(user); await waitFor(() => expect( @@ -571,11 +582,15 @@ describe("when the user stops it", () => { }); it("still reports a refusal to start", async () => { - // The shape the controller actually refuses with. + // The shape the handler actually refuses with: a refusal that never + // reached the machine says so on the error. h.run.mockRejectedValue( - new DyadError( - "A server is already being set up.", - DyadErrorKind.Precondition, + Object.assign( + new DyadError( + "A server is already being set up.", + DyadErrorKind.Precondition, + ), + { code: SETUP_NOT_STARTED }, ), ); const user = userEvent.setup(); diff --git a/src/components/CoolifyServerSetup.tsx b/src/components/CoolifyServerSetup.tsx index 66ea9abc9a..58e06c3e46 100644 --- a/src/components/CoolifyServerSetup.tsx +++ b/src/components/CoolifyServerSetup.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ipc } from "@/ipc/types"; +import { SETUP_NOT_STARTED } from "@/ipc/types/coolify_setup"; import type { SetupPreflight, SetupResult, @@ -12,7 +13,7 @@ import type { SetupStep, } from "@/ipc/types"; import { showError } from "@/lib/toast"; -import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { DyadError } from "@/errors/dyad_error"; import { queryKeys } from "@/lib/queryKeys"; import { isPlausibleAdminEmail } from "@/shared/coolify_admin_email"; import { isPlausibleInstanceDomain } from "@/shared/coolify_domain"; @@ -158,19 +159,17 @@ export function CoolifyServerSetup({ // one still watching by the time it arrives. Only a refusal to start — // one setup at a time — belongs to the caller. onError: (error) => { - // A cancel comes back here too, because the flow rethrows it. Reported - // as a red panel it says something went wrong, while the screen behind - // it correctly says nothing did. - // Anything the machine took on is on screen already, with the - // installer's own words under it, so saying it again in a toast reports - // one failure twice. What never reached the machine — a refusal to - // start, a rejected address, a call that did not arrive — has nowhere - // else to appear. - const theMachineHasIt = - error instanceof DyadError && - error.kind !== DyadErrorKind.Validation && - error.kind !== DyadErrorKind.Precondition; - if (theMachineHasIt) return; + // Anything the machine took on is on screen already — a failure with the + // installer's own words under it, or a cancel that correctly says + // nothing went wrong. Saying it again in a toast reports one event + // twice. What never reached the machine says so on the error, which the + // kind cannot: the flow raises Precondition as well, when its own look + // at the server refuses at install time. + const neverStarted = + (error as { code?: string }).code === SETUP_NOT_STARTED; + // Anything that is not one of ours did not come from the flow either, so + // it has nowhere else to appear. + if (!neverStarted && error instanceof DyadError) return; showError(error); }, }); diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts index 5061194fa0..58272718d1 100644 --- a/src/ipc/handlers/coolify_setup_handlers.test.ts +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -15,6 +15,8 @@ const h = vi.hoisted(() => ({ runCalls: 0, verifiedAgainst: [] as string[], writeThrows: false, + preflightThrows: false, + preflightReady: true, })); vi.mock("electron", () => ({ BrowserWindow: { getAllWindows: () => [] } })); @@ -76,11 +78,15 @@ vi.mock("../utils/ssh_client", () => ({ })); vi.mock("@/coolify_setup/install", () => ({ - preflight: vi.fn(async () => ({ - ready: true, - alreadyInstalled: false, - memoryMb: 1967, - })), + preflight: vi.fn(async () => { + if (h.preflightThrows) throw new Error("docker never answered"); + return { + ready: h.preflightReady, + reason: h.preflightReady ? undefined : "It already has Coolify on it.", + alreadyInstalled: !h.preflightReady, + memoryMb: 1967, + }; + }), })); vi.mock("@/coolify_setup/setup_flow", () => ({ @@ -149,6 +155,8 @@ beforeEach(() => { h.runCalls = 0; h.verifiedAgainst.length = 0; h.writeThrows = false; + h.preflightThrows = false; + h.preflightReady = true; resetCoolifySetupStateForTests(); registerCoolifySetupHandlers(); }); @@ -264,6 +272,40 @@ describe("run", () => { ); }); + it("refuses a server whose check never finished", async () => { + // The fingerprint is recorded during the handshake, before preflight has + // said anything — so a connection that opened is not a check that passed. + h.preflightThrows = true; + await expect(call("coolify-setup:inspect", TARGET)).rejects.toThrow(); + + await expect(call("coolify-setup:run", TARGET)).rejects.toThrow( + /Check the server/, + ); + expect(h.runCalls).toBe(0); + }); + + it("refuses a server the check turned down", async () => { + h.preflightReady = false; + await call("coolify-setup:inspect", TARGET); + + await expect(call("coolify-setup:run", TARGET)).rejects.toThrow( + /Check the server/, + ); + expect(h.runCalls).toBe(0); + }); + + it("drops a pass the next check takes back", async () => { + // A server that was ready and has since had Coolify put on it is not one + // to install onto, and the second answer is the true one. + await call("coolify-setup:inspect", TARGET); + h.preflightReady = false; + await call("coolify-setup:inspect", TARGET); + + await expect(call("coolify-setup:run", TARGET)).rejects.toThrow( + /Check the server/, + ); + }); + it("stores the token it minted", async () => { await checkThenRun(); const saved = h.written.at(-1) as { @@ -360,9 +402,12 @@ describe("run", () => { release = () => resolve(RESULT); }); const first = checkThenRun(); + // Checked, so the refusal below is the one-at-a-time rule rather than the + // gate that asks for a check — both refuse the same way. + await call("coolify-setup:inspect", { ...TARGET, host: "198.51.100.7" }); await expect( call("coolify-setup:run", { ...TARGET, host: "198.51.100.7" }), - ).rejects.toMatchObject({ kind: "precondition" }); + ).rejects.toThrow(/already being set up/); release(); await first; }); diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index 8fe1831ceb..38fb29831e 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -3,6 +3,7 @@ import log from "electron-log"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { createTypedHandler } from "./base"; import { + SETUP_NOT_STARTED, coolifySetupContracts, coolifySetupEvents, } from "../types/coolify_setup"; @@ -56,6 +57,15 @@ let controller: CoolifySetupController | null = null; */ const inspectedFingerprints = new Map(); +/** + * The servers a check got through and liked. + * + * Separate from the pin above, which is written during the handshake: a + * connection that opened is not a server that answered, and a server that + * answered "Coolify is already here" is not one to install onto. + */ +const readyHosts = new Set(); + function broadcastState(state: SetupSnapshot) { for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) { @@ -246,6 +256,7 @@ function targetFrom(input: SetupServer, privateKey: string) { /** Test-only: the pin map and the controller both outlive a single case. */ export function resetCoolifySetupStateForTests(): void { inspectedFingerprints.clear(); + readyHosts.clear(); // Cancelled before disposed: disposing stops the controller talking, it does // not stop what it started, and a run left going would go on writing // settings while the next case is watching them. @@ -293,6 +304,10 @@ export function registerCoolifySetupHandlers() { ); }), ]); + // Kept only while the answer stands: a server that was ready and has + // since had Coolify put on it must not keep an old pass. + if (checks.ready) readyHosts.add(hostIdentity(input.host)); + else readyHosts.delete(hostIdentity(input.host)); return { ready: checks.ready, reason: checks.reason ?? null, @@ -308,35 +323,46 @@ export function registerCoolifySetupHandlers() { // DO NOT LOG this handler: its result carries the generated admin password. createTypedHandler(coolifySetupContracts.run, async (_, input) => { - // Checked before anything is done, because Coolify resolves the domain when - // it seeds its admin and a rejected address leaves an install with no - // account on it — minutes later, with nothing to show for them. - if (!isPlausibleAdminEmail(input.adminEmail)) { - throw new DyadError( - "Enter an email address whose domain resolves. Coolify checks this " + - "when it creates the admin account, and rejects addresses like " + - "admin@example.test.", - DyadErrorKind.Validation, - ); - } - if (input.customDomain && !isPlausibleInstanceDomain(input.customDomain)) { - throw new DyadError( - "Enter the domain on its own, with no port or path — for example " + - "coolify.yourdomain.com.", - DyadErrorKind.Validation, - ); - } - if (!inspectedFingerprints.has(hostIdentity(input.host))) { - throw new DyadError( - "Check the server before installing. Dyad shows you its fingerprint " + - "first, so the install goes to the machine that answered rather " + - "than to whatever holds the address by then.", - DyadErrorKind.Precondition, - ); + try { + // Checked before anything is done, because Coolify resolves the domain when + // it seeds its admin and a rejected address leaves an install with no + // account on it — minutes later, with nothing to show for them. + if (!isPlausibleAdminEmail(input.adminEmail)) { + throw new DyadError( + "Enter an email address whose domain resolves. Coolify checks this " + + "when it creates the admin account, and rejects addresses like " + + "admin@example.test.", + DyadErrorKind.Validation, + ); + } + if ( + input.customDomain && + !isPlausibleInstanceDomain(input.customDomain) + ) { + throw new DyadError( + "Enter the domain on its own, with no port or path — for example " + + "coolify.yourdomain.com.", + DyadErrorKind.Validation, + ); + } + if (!readyHosts.has(hostIdentity(input.host))) { + throw new DyadError( + "Check the server before installing. Dyad shows you its fingerprint " + + "first, so the install goes to the machine that answered rather " + + "than to whatever holds the address by then.", + DyadErrorKind.Precondition, + ); + } + // One at a time is the machine's rule, not a check here; it refuses by + // throwing, and the panel shows that. Returned rather than awaited, so + // a run that fails later is the machine's to report. + return setupController().start(input).result; + } catch (error) { + if (error instanceof DyadError) { + Object.assign(error, { code: SETUP_NOT_STARTED }); + } + throw error; } - // One at a time is the machine's rule, not a check here; it refuses by - // throwing, and the panel shows that. - return setupController().start(input).result; }); createTypedHandler(coolifySetupContracts.snapshot, async () => diff --git a/src/ipc/types/coolify_setup.ts b/src/ipc/types/coolify_setup.ts index 71226abe97..82ca3a751c 100644 --- a/src/ipc/types/coolify_setup.ts +++ b/src/ipc/types/coolify_setup.ts @@ -34,6 +34,16 @@ export const ServerKeySchema = z.object({ * server does not need one — demanding it there rejected a check the panel * was offering before the email had been typed. */ +/** + * Marks a refusal that happened before the machine took the run on. + * + * The panel shows anything the machine recorded, with the installer's output + * under it. What never got that far — a rejected address, a server nobody + * checked, a setup already going — has nowhere to appear but a toast, and + * this is how the two are told apart. + */ +export const SETUP_NOT_STARTED = "coolify-setup-not-started"; + export const SetupServerSchema = z.object({ host: z.string().min(1), /** Coolify's installer needs root, and says so in its own documentation. */ From f02927abef4108916f8a3621fa4ba1e3d1f69ae3 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 21 Aug 2026 14:49:01 -0500 Subject: [PATCH 10/91] test(coolify): pin the mark a refusal carries, and reunite a doc with its schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the panel's side of the not-started mark was covered: the renderer test builds a marked error by hand, so deleting the handler that applies it left every coolify test passing. That matters more than it used to — the panel now suppresses any error it cannot attribute, so a lost mark means Install does nothing at all rather than saying something twice. The new const also landed between SetupServerSchema and the comment written for it, which left the schema with no documentation at all. Co-Authored-By: Claude Opus 5 --- .../handlers/coolify_setup_handlers.test.ts | 22 ++++++++++++++++++- src/ipc/types/coolify_setup.ts | 14 ++++++------ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts index 58272718d1..d53abe8cc3 100644 --- a/src/ipc/handlers/coolify_setup_handlers.test.ts +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; // The mocked class, so the handler recognises what it is handed. import { SshError } from "../utils/ssh_client"; -import { DyadErrorKind } from "@/errors/dyad_error"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { SETUP_NOT_STARTED } from "@/ipc/types/coolify_setup"; const h = vi.hoisted(() => ({ settings: {} as Record, @@ -306,6 +307,25 @@ describe("run", () => { ); }); + it("marks a refusal that never reached the machine", async () => { + // The panel shows nothing for an error it cannot attribute, because the + // machine has nothing to show either. This mark is what makes a refusal + // speak, so losing it would make Install do nothing at all. + await expect(call("coolify-setup:run", TARGET)).rejects.toMatchObject({ + code: SETUP_NOT_STARTED, + }); + }); + + it("leaves a failure the machine took on unmarked", async () => { + // The run is returned rather than awaited, so what fails afterwards is + // the machine's to report and the panel already has it. + h.setupError = new DyadError("exit 1", DyadErrorKind.External); + + await expect(checkThenRun()).rejects.not.toMatchObject({ + code: SETUP_NOT_STARTED, + }); + }); + it("stores the token it minted", async () => { await checkThenRun(); const saved = h.written.at(-1) as { diff --git a/src/ipc/types/coolify_setup.ts b/src/ipc/types/coolify_setup.ts index 82ca3a751c..5394ede312 100644 --- a/src/ipc/types/coolify_setup.ts +++ b/src/ipc/types/coolify_setup.ts @@ -27,13 +27,6 @@ export const ServerKeySchema = z.object({ publicKey: z.string(), }); -/** - * Where the server is. Everything needed to reach it, and nothing else. - * - * Separate from the address of the account to create, because looking at a - * server does not need one — demanding it there rejected a check the panel - * was offering before the email had been typed. - */ /** * Marks a refusal that happened before the machine took the run on. * @@ -44,6 +37,13 @@ export const ServerKeySchema = z.object({ */ export const SETUP_NOT_STARTED = "coolify-setup-not-started"; +/** + * Where the server is. Everything needed to reach it, and nothing else. + * + * Separate from the address of the account to create, because looking at a + * server does not need one — demanding it there rejected a check the panel + * was offering before the email had been typed. + */ export const SetupServerSchema = z.object({ host: z.string().min(1), /** Coolify's installer needs root, and says so in its own documentation. */ From f7347153d3c4343e04fa5f8d7cbf0e4ad9add590 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 21 Aug 2026 15:37:24 -0500 Subject: [PATCH 11/91] fix(coolify): show a setup error unless the machine already has it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mark was on the wrong side. Marking what never started meant silence was the default, so anything raised outside this handler — the IPC layer turning down bad input, a transport failure — reached the panel unmarked and was swallowed, leaving Install to do nothing at all. Nothing reaches that today, because the form will not offer Install until its fields pass the same checks, but the coupling is not one anything enforces. The mark now says the machine took the failure on and has it on screen, which the handler establishes by awaiting the run rather than guessing. Everything else is said out loud, so a mistake here is a duplicate toast rather than a button that does nothing. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyServerSetup.test.tsx | 48 ++++++++---- src/components/CoolifyServerSetup.tsx | 23 +++--- .../handlers/coolify_setup_handlers.test.ts | 27 ++++--- src/ipc/handlers/coolify_setup_handlers.ts | 73 ++++++++++--------- src/ipc/types/coolify_setup.ts | 14 ++-- 5 files changed, 103 insertions(+), 82 deletions(-) diff --git a/src/components/CoolifyServerSetup.test.tsx b/src/components/CoolifyServerSetup.test.tsx index 254629cc64..4d57e75c3c 100644 --- a/src/components/CoolifyServerSetup.test.tsx +++ b/src/components/CoolifyServerSetup.test.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { queryKeys } from "@/lib/queryKeys"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; -import { SETUP_NOT_STARTED } from "@/ipc/types/coolify_setup"; +import { SETUP_MACHINE_REPORTED } from "@/ipc/types/coolify_setup"; const toastMock = vi.hoisted(() => ({ warning: vi.fn(), @@ -568,7 +568,9 @@ describe("when the user stops it", () => { // and reporting it says something went wrong while the screen says // nothing did. h.run.mockRejectedValue( - new DyadError("Cancelled.", DyadErrorKind.UserCancelled), + Object.assign(new DyadError("Cancelled.", DyadErrorKind.UserCancelled), { + code: SETUP_MACHINE_REPORTED, + }), ); const user = userEvent.setup(); renderPanel(); @@ -581,16 +583,33 @@ describe("when the user stops it", () => { expect(h.showError).not.toHaveBeenCalled(); }); + it("says what the machine never saw, rather than swallowing it", async () => { + // The IPC layer turns down bad input before the handler is reached, so + // that error carries no mark and the machine has no state for it. Left + // unsaid, pressing Install would do nothing at all. + h.run.mockRejectedValue( + new DyadError( + "[coolify-setup:run] Invalid input", + DyadErrorKind.Validation, + ), + ); + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await checkServer(user); + await user.click(screen.getByTestId("coolify-setup-install")); + + await waitFor(() => expect(h.showError).toHaveBeenCalled()); + }); + it("still reports a refusal to start", async () => { - // The shape the handler actually refuses with: a refusal that never - // reached the machine says so on the error. + // The shape the handler actually refuses with: nothing reached the + // machine, so the error carries no mark and the panel says it out loud. h.run.mockRejectedValue( - Object.assign( - new DyadError( - "A server is already being set up.", - DyadErrorKind.Precondition, - ), - { code: SETUP_NOT_STARTED }, + new DyadError( + "A server is already being set up.", + DyadErrorKind.Precondition, ), ); const user = userEvent.setup(); @@ -607,9 +626,12 @@ describe("when the user stops it", () => { // The failure block carries the installer's own output; a toast beside it // repeats the same event with less to show. h.run.mockRejectedValue( - new DyadError( - "Installing Coolify failed (exit 1).", - DyadErrorKind.External, + Object.assign( + new DyadError( + "Installing Coolify failed (exit 1).", + DyadErrorKind.External, + ), + { code: SETUP_MACHINE_REPORTED }, ), ); const user = userEvent.setup(); diff --git a/src/components/CoolifyServerSetup.tsx b/src/components/CoolifyServerSetup.tsx index 58e06c3e46..5d63c4ed15 100644 --- a/src/components/CoolifyServerSetup.tsx +++ b/src/components/CoolifyServerSetup.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ipc } from "@/ipc/types"; -import { SETUP_NOT_STARTED } from "@/ipc/types/coolify_setup"; +import { SETUP_MACHINE_REPORTED } from "@/ipc/types/coolify_setup"; import type { SetupPreflight, SetupResult, @@ -13,7 +13,6 @@ import type { SetupStep, } from "@/ipc/types"; import { showError } from "@/lib/toast"; -import { DyadError } from "@/errors/dyad_error"; import { queryKeys } from "@/lib/queryKeys"; import { isPlausibleAdminEmail } from "@/shared/coolify_admin_email"; import { isPlausibleInstanceDomain } from "@/shared/coolify_domain"; @@ -159,17 +158,15 @@ export function CoolifyServerSetup({ // one still watching by the time it arrives. Only a refusal to start — // one setup at a time — belongs to the caller. onError: (error) => { - // Anything the machine took on is on screen already — a failure with the - // installer's own words under it, or a cancel that correctly says - // nothing went wrong. Saying it again in a toast reports one event - // twice. What never reached the machine says so on the error, which the - // kind cannot: the flow raises Precondition as well, when its own look - // at the server refuses at install time. - const neverStarted = - (error as { code?: string }).code === SETUP_NOT_STARTED; - // Anything that is not one of ours did not come from the flow either, so - // it has nowhere else to appear. - if (!neverStarted && error instanceof DyadError) return; + // Anything the machine took on is on screen already — a failure with + // the installer's own words under it, or a cancel that correctly says + // nothing went wrong — and it says so on the error. Everything else is + // shown, because an error nobody reports is a button that does nothing: + // a refusal that never started, and whatever the IPC layer turns down + // before the handler is reached. + const machineReported = + (error as { code?: string }).code === SETUP_MACHINE_REPORTED; + if (machineReported) return; showError(error); }, }); diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts index d53abe8cc3..931391f2ac 100644 --- a/src/ipc/handlers/coolify_setup_handlers.test.ts +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; // The mocked class, so the handler recognises what it is handed. import { SshError } from "../utils/ssh_client"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; -import { SETUP_NOT_STARTED } from "@/ipc/types/coolify_setup"; +import { SETUP_MACHINE_REPORTED } from "@/ipc/types/coolify_setup"; const h = vi.hoisted(() => ({ settings: {} as Record, @@ -307,22 +307,21 @@ describe("run", () => { ); }); - it("marks a refusal that never reached the machine", async () => { - // The panel shows nothing for an error it cannot attribute, because the - // machine has nothing to show either. This mark is what makes a refusal - // speak, so losing it would make Install do nothing at all. - await expect(call("coolify-setup:run", TARGET)).rejects.toMatchObject({ - code: SETUP_NOT_STARTED, + it("marks a failure the machine already put on screen", async () => { + // The panel suppresses what carries this and shows everything else, so + // the mark is what stops one failure being reported twice. + h.setupError = new DyadError("exit 1", DyadErrorKind.External); + + await expect(checkThenRun()).rejects.toMatchObject({ + code: SETUP_MACHINE_REPORTED, }); }); - it("leaves a failure the machine took on unmarked", async () => { - // The run is returned rather than awaited, so what fails afterwards is - // the machine's to report and the panel already has it. - h.setupError = new DyadError("exit 1", DyadErrorKind.External); - - await expect(checkThenRun()).rejects.not.toMatchObject({ - code: SETUP_NOT_STARTED, + it("leaves a refusal that never started unmarked", async () => { + // Nothing reached the machine, so nothing is on screen — and an unmarked + // error is the one the panel says out loud. + await expect(call("coolify-setup:run", TARGET)).rejects.not.toMatchObject({ + code: SETUP_MACHINE_REPORTED, }); }); diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index 38fb29831e..c059882457 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -3,7 +3,7 @@ import log from "electron-log"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { createTypedHandler } from "./base"; import { - SETUP_NOT_STARTED, + SETUP_MACHINE_REPORTED, coolifySetupContracts, coolifySetupEvents, } from "../types/coolify_setup"; @@ -323,43 +323,44 @@ export function registerCoolifySetupHandlers() { // DO NOT LOG this handler: its result carries the generated admin password. createTypedHandler(coolifySetupContracts.run, async (_, input) => { + // Checked before anything is done, because Coolify resolves the domain when + // it seeds its admin and a rejected address leaves an install with no + // account on it — minutes later, with nothing to show for them. + if (!isPlausibleAdminEmail(input.adminEmail)) { + throw new DyadError( + "Enter an email address whose domain resolves. Coolify checks this " + + "when it creates the admin account, and rejects addresses like " + + "admin@example.test.", + DyadErrorKind.Validation, + ); + } + if (input.customDomain && !isPlausibleInstanceDomain(input.customDomain)) { + throw new DyadError( + "Enter the domain on its own, with no port or path — for example " + + "coolify.yourdomain.com.", + DyadErrorKind.Validation, + ); + } + if (!readyHosts.has(hostIdentity(input.host))) { + throw new DyadError( + "Check the server before installing. Dyad shows you its fingerprint " + + "first, so the install goes to the machine that answered rather " + + "than to whatever holds the address by then.", + DyadErrorKind.Precondition, + ); + } + // One at a time is the machine's rule, not a check here; it refuses by + // throwing, and the panel shows that. + const run = setupController().start(input); try { - // Checked before anything is done, because Coolify resolves the domain when - // it seeds its admin and a rejected address leaves an install with no - // account on it — minutes later, with nothing to show for them. - if (!isPlausibleAdminEmail(input.adminEmail)) { - throw new DyadError( - "Enter an email address whose domain resolves. Coolify checks this " + - "when it creates the admin account, and rejects addresses like " + - "admin@example.test.", - DyadErrorKind.Validation, - ); - } - if ( - input.customDomain && - !isPlausibleInstanceDomain(input.customDomain) - ) { - throw new DyadError( - "Enter the domain on its own, with no port or path — for example " + - "coolify.yourdomain.com.", - DyadErrorKind.Validation, - ); - } - if (!readyHosts.has(hostIdentity(input.host))) { - throw new DyadError( - "Check the server before installing. Dyad shows you its fingerprint " + - "first, so the install goes to the machine that answered rather " + - "than to whatever holds the address by then.", - DyadErrorKind.Precondition, - ); - } - // One at a time is the machine's rule, not a check here; it refuses by - // throwing, and the panel shows that. Returned rather than awaited, so - // a run that fails later is the machine's to report. - return setupController().start(input).result; + return await run.result; } catch (error) { - if (error instanceof DyadError) { - Object.assign(error, { code: SETUP_NOT_STARTED }); + // Awaited only to mark it: the machine recorded this before rethrowing, + // so the panel is already showing it and a toast would be the same news + // twice. Everything above never got that far and stays unmarked, which + // is what makes it speak. + if (typeof error === "object" && error !== null) { + Object.assign(error, { code: SETUP_MACHINE_REPORTED }); } throw error; } diff --git a/src/ipc/types/coolify_setup.ts b/src/ipc/types/coolify_setup.ts index 5394ede312..3f92ded371 100644 --- a/src/ipc/types/coolify_setup.ts +++ b/src/ipc/types/coolify_setup.ts @@ -28,14 +28,16 @@ export const ServerKeySchema = z.object({ }); /** - * Marks a refusal that happened before the machine took the run on. + * Marks a failure the machine took on and has already put on screen. * - * The panel shows anything the machine recorded, with the installer's output - * under it. What never got that far — a rejected address, a server nobody - * checked, a setup already going — has nowhere to appear but a toast, and - * this is how the two are told apart. + * The panel shows anything it cannot attribute, because an error nobody + * reports is a button that does nothing. So the mark goes on the case that + * IS accounted for — a run that failed or was cancelled, which the finished + * screen carries with the installer's own words — and everything else, + * including whatever the IPC layer refuses before this handler is reached, + * is said out loud. */ -export const SETUP_NOT_STARTED = "coolify-setup-not-started"; +export const SETUP_MACHINE_REPORTED = "coolify-setup-machine-reported"; /** * Where the server is. Everything needed to reach it, and nothing else. From 6a65068ef5925d06a6119984b6fe79566515c0e5 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 21 Aug 2026 16:22:07 -0500 Subject: [PATCH 12/91] test(coolify): hold the placement that keeps a refusal audible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether the one-at-a-time refusal reaches the panel depends on start() sitting outside the try that marks a run's failures. Moved one line down it would be marked as something already on screen, and a second window's press would be swallowed — with the whole suite still passing. A test holds the line now, and the line says why it is where it is. An error that already names itself is also left alone. Overwriting the code on a system error loses what went wrong, and assigning to a frozen one would replace the failure with a TypeError; saying either twice is the better failure. Co-Authored-By: Claude Opus 5 --- .../handlers/coolify_setup_handlers.test.ts | 28 +++++++++++++++++++ src/ipc/handlers/coolify_setup_handlers.ts | 11 ++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts index 931391f2ac..8b92dd2e27 100644 --- a/src/ipc/handlers/coolify_setup_handlers.test.ts +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -307,6 +307,34 @@ describe("run", () => { ); }); + it("leaves the one-at-a-time refusal unmarked", async () => { + // That refusal comes from the machine declining to start, not from a run + // it took on — so it has nothing on screen of its own, and the panel has + // to say it. What keeps it unmarked is where start() sits. + let release!: () => void; + h.setupResult = new Promise((resolve) => { + release = () => resolve(RESULT); + }); + const first = checkThenRun(); + + await expect(checkThenRun()).rejects.not.toMatchObject({ + code: SETUP_MACHINE_REPORTED, + }); + + release(); + await first; + }); + + it("leaves an error that carries its own code alone", async () => { + // A system error names itself — ENOTFOUND and the like — and overwriting + // that loses what went wrong. Said twice is better than said wrongly. + h.setupError = Object.assign(new Error("getaddrinfo ENOTFOUND"), { + code: "ENOTFOUND", + }); + + await expect(checkThenRun()).rejects.toMatchObject({ code: "ENOTFOUND" }); + }); + it("marks a failure the machine already put on screen", async () => { // The panel suppresses what carries this and shows everything else, so // the mark is what stops one failure being reported twice. diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index c059882457..20c88b808a 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -350,7 +350,9 @@ export function registerCoolifySetupHandlers() { ); } // One at a time is the machine's rule, not a check here; it refuses by - // throwing, and the panel shows that. + // throwing, and the panel shows that. Outside the try on purpose: that + // refusal is the machine declining to start, so it must not be marked as + // something the machine is already showing. const run = setupController().start(input); try { return await run.result; @@ -359,7 +361,12 @@ export function registerCoolifySetupHandlers() { // so the panel is already showing it and a toast would be the same news // twice. Everything above never got that far and stays unmarked, which // is what makes it speak. - if (typeof error === "object" && error !== null) { + if ( + typeof error === "object" && + error !== null && + Object.isExtensible(error) && + !("code" in error) + ) { Object.assign(error, { code: SETUP_MACHINE_REPORTED }); } throw error; From 3e7d899d477f85c9035196fd086b2a4ec76ef531 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 21 Aug 2026 17:06:09 -0500 Subject: [PATCH 13/91] fix(coolify): remember a server by its port as well as its address Two services on one address are two servers. Keyed by address alone, checking one and installing the other held the second to the first one's fingerprint and refused a valid endpoint. The form has no port field today, so this is reachable only through the contract, which carries one. Co-Authored-By: Claude Opus 5 --- .../handlers/coolify_setup_handlers.test.ts | 10 +++++++ src/ipc/handlers/coolify_setup_handlers.ts | 27 ++++++++++--------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts index 8b92dd2e27..bd328d5147 100644 --- a/src/ipc/handlers/coolify_setup_handlers.test.ts +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -209,6 +209,16 @@ describe("run", () => { expect(h.verifiedAgainst).toContain("SHA256:fingerprint"); }); + it("does not hold one port to what another on the same address showed", async () => { + // Two services on one address are two servers. Keyed by address alone, + // the second is checked against the first one's fingerprint and refused. + await call("coolify-setup:inspect", { ...TARGET, port: 22 }); + + await expect( + call("coolify-setup:run", { ...TARGET, port: 2222 }), + ).rejects.toThrow(/Check the server/); + }); + it("does not hold one server to what another one showed", async () => { // Addresses are remembered as themselves. Read as URLs, everything shaped // like fe80::1 parses as a scheme with no hostname and shares one entry, diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index 20c88b808a..02e2e3b669 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -82,14 +82,14 @@ function setupController(): CoolifySetupController { const key = ensureServerKey(); // Trust on first use only when there has been no first use. A server // that was looked at is held to what it showed then. - const pinned = inspectedFingerprints.get(hostIdentity(target.host)); + const pinned = inspectedFingerprints.get(serverKeyFor(target)); return runServerSetup({ target: targetFrom(target, key.privateKey), adminEmail: target.adminEmail, verifyHostKey: pinned ? expectFingerprint(pinned) : trustOnFirstUse((fp) => { - inspectedFingerprints.set(hostIdentity(target.host), fp); + inspectedFingerprints.set(serverKeyFor(target), fp); }), customDomain: target.customDomain, signal: hooks.signal, @@ -212,17 +212,20 @@ function serverIdentity(url: string): string { } /** - * The key a server's fingerprint is remembered under. + * The key a server's fingerprint and verdict are remembered under. * - * serverIdentity is for URLs, and a bare address is not one: `fe80::1` parses - * as the scheme `fe80:` and leaves no hostname at all, so every address shaped - * that way would share one entry. + * The port is part of it: two services on one address are two servers, and + * holding the second to the first one's key would refuse a valid one. And + * serverIdentity is for URLs, which a bare address is not — `fe80::1` parses + * as the scheme `fe80:` and leaves no hostname at all, so every address + * shaped that way would share one entry. */ -function hostIdentity(host: string): string { - return host +function serverKeyFor(input: SetupServer): string { + const host = input.host .trim() .toLowerCase() .replace(/^\[|\]$/g, ""); + return `${host}:${sshPort(input) ?? 22}`; } function sameServer(a: string | null | undefined, b: string | null): boolean { @@ -281,7 +284,7 @@ export function registerCoolifySetupHandlers() { targetFrom(input, key.privateKey), trustOnFirstUse((fp) => { fingerprint = fp; - inspectedFingerprints.set(hostIdentity(input.host), fp); + inspectedFingerprints.set(serverKeyFor(input), fp); }), ); try { @@ -306,8 +309,8 @@ export function registerCoolifySetupHandlers() { ]); // Kept only while the answer stands: a server that was ready and has // since had Coolify put on it must not keep an old pass. - if (checks.ready) readyHosts.add(hostIdentity(input.host)); - else readyHosts.delete(hostIdentity(input.host)); + if (checks.ready) readyHosts.add(serverKeyFor(input)); + else readyHosts.delete(serverKeyFor(input)); return { ready: checks.ready, reason: checks.reason ?? null, @@ -341,7 +344,7 @@ export function registerCoolifySetupHandlers() { DyadErrorKind.Validation, ); } - if (!readyHosts.has(hostIdentity(input.host))) { + if (!readyHosts.has(serverKeyFor(input))) { throw new DyadError( "Check the server before installing. Dyad shows you its fingerprint " + "first, so the install goes to the machine that answered rather " + From cfdd6628701bf29aedfded2ee5dac4e587febd78 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 21 Aug 2026 22:38:37 -0500 Subject: [PATCH 14/91] fix(coolify): decide what a check saw only once it has finished The fingerprint was recorded during the handshake and the verdict after the probe, so a re-check that reached the server and then failed left the new machine's key beside the older machine's pass. The install stayed offered and would have pinned to a server whose check never came back, sending it the admin password and a token. Both are now written together, once the check has finished, and a check that does not finish changes neither. The panel had the same gap from its side: a failed re-check left the previous card on screen with Install still enabled. The verdict is cleared before the question is asked, so what is shown always belongs to the last answer. An account the store could not keep is also tried once more when the run ends badly. Coolify has the account either way and preflight refuses to install over it, so a password stored nowhere is a server nobody can sign into. The retry cannot become the failure the user is told about, and a later write replacing the address is not undone by it. Also: say at the point of the write why this path does not ask for the acknowledgement `coolify:save-token` demands, since whether HTTPS was possible is only known once the install has run. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyServerSetup.tsx | 4 + .../handlers/coolify_setup_handlers.test.ts | 87 ++++++++++++++++++- src/ipc/handlers/coolify_setup_handlers.ts | 53 ++++++++++- 3 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/components/CoolifyServerSetup.tsx b/src/components/CoolifyServerSetup.tsx index 5d63c4ed15..6abfe0e283 100644 --- a/src/components/CoolifyServerSetup.tsx +++ b/src/components/CoolifyServerSetup.tsx @@ -135,6 +135,10 @@ export function CoolifyServerSetup({ const inspect = useMutation({ mutationFn: async () => { const asked = host.trim(); + // Cleared before asking, so a check that fails leaves no verdict behind. + // The screen's premise is that Dyad installs onto the machine that just + // answered, and a pass from a previous answer contradicts it. + setInspection(null); const checks = await ipc.coolifySetup.inspect({ host: asked, username: "root", diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts index bd328d5147..b05287c80b 100644 --- a/src/ipc/handlers/coolify_setup_handlers.test.ts +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -16,8 +16,12 @@ const h = vi.hoisted(() => ({ runCalls: 0, verifiedAgainst: [] as string[], writeThrows: false, + /** How many writes fail before the store comes back. */ + writeFailures: 0, + reportsAccountTwice: false, preflightThrows: false, preflightReady: true, + fingerprint: "SHA256:fingerprint", })); vi.mock("electron", () => ({ BrowserWindow: { getAllWindows: () => [] } })); @@ -33,6 +37,10 @@ vi.mock("./base", () => ({ vi.mock("@/main/settings", () => ({ readSettings: () => h.settings, writeSettings: (value: Record) => { + if (h.writeFailures > 0) { + h.writeFailures -= 1; + throw new Error("keychain is unavailable"); + } if (h.writeThrows) throw new Error("keychain is unavailable"); h.written.push(value); Object.assign(h.settings, value); @@ -49,7 +57,7 @@ vi.mock("../utils/ssh_client", () => ({ // looking correct while reporting nothing. connectSsh: vi.fn( async (_target: unknown, verify: (fp: string) => boolean) => { - verify("SHA256:fingerprint"); + verify(h.fingerprint); return { run: vi.fn(), end: () => { @@ -106,6 +114,18 @@ vi.mock("@/coolify_setup/setup_flow", () => ({ credentials: { email: "me@gmail.com", password: "Abc123@xyz" }, dashboardUrl: "http://203.0.113.5:8000", }); + // And again once HTTPS has settled the address it is reachable at. + if (h.reportsAccountTwice) { + ( + options.onAccountKnown as (a: { + credentials: { email: string; password: string }; + dashboardUrl: string; + }) => void + )({ + credentials: { email: "me@gmail.com", password: "Abc123@xyz" }, + dashboardUrl: "https://203.0.113.5.sslip.io", + }); + } } if (h.setupError) throw h.setupError; return h.setupResult; @@ -156,8 +176,11 @@ beforeEach(() => { h.runCalls = 0; h.verifiedAgainst.length = 0; h.writeThrows = false; + h.writeFailures = 0; + h.reportsAccountTwice = false; h.preflightThrows = false; h.preflightReady = true; + h.fingerprint = "SHA256:fingerprint"; resetCoolifySetupStateForTests(); registerCoolifySetupHandlers(); }); @@ -266,7 +289,6 @@ describe("run", () => { coolify: { previousAccessToken: { value: "1|old" } }, } as Record; - await call("coolify-setup:inspect", TARGET); await checkThenRun(); const saved = h.written.at(-1) as { @@ -305,6 +327,24 @@ describe("run", () => { expect(h.runCalls).toBe(0); }); + it("keeps the answer that stands when a re-check does not finish", async () => { + // The handshake happens before preflight, so recording the key there left + // the new machine's key beside the old machine's pass — an install onto a + // server whose check never came back. + await call("coolify-setup:inspect", TARGET); + + // A different machine answers the address, and its check does not finish. + h.fingerprint = "SHA256:someone-else"; + h.preflightThrows = true; + await expect(call("coolify-setup:inspect", TARGET)).rejects.toThrow(); + h.preflightThrows = false; + + // The pass from the finished check still stands, and it is still paired + // with the key that check saw — not with the one nobody approved. + await call("coolify-setup:run", TARGET); + expect(h.verifiedAgainst).toEqual(["SHA256:fingerprint"]); + }); + it("drops a pass the next check takes back", async () => { // A server that was ready and has since had Coolify put on it is not one // to install onto, and the second answer is the true one. @@ -345,6 +385,49 @@ describe("run", () => { await expect(checkThenRun()).rejects.toMatchObject({ code: "ENOTFOUND" }); }); + it("stores the account on the way out when the first attempt failed", async () => { + // Coolify has the account either way, and preflight refuses to install + // over it — so a password stored nowhere is a server nobody can sign into. + // The store is busy for the first write and free by the second. + h.writeFailures = 1; + h.reportsAccount = true; + h.setupError = new DyadError("exit 1", DyadErrorKind.External); + + await expect(checkThenRun()).rejects.toThrow("exit 1"); + + const saved = h.written.at(-1) as { + coolify: { adminPassword: { value: string } }; + }; + expect(saved.coolify.adminPassword.value).toBe("Abc123@xyz"); + }); + + it("does not put back an address a later write replaced", async () => { + // The account is reported twice — once when it exists, and again once + // HTTPS has settled where it answers. A copy kept from the first would + // write the earlier address back over the later one on the way out. + h.writeFailures = 1; + h.reportsAccount = true; + h.reportsAccountTwice = true; + h.setupError = new DyadError("exit 1", DyadErrorKind.External); + + await expect(checkThenRun()).rejects.toThrow("exit 1"); + + const saved = h.written.at(-1) as { + coolify: { adminInstanceUrl: string }; + }; + expect(saved.coolify.adminInstanceUrl).toBe("https://203.0.113.5.sslip.io"); + }); + + it("reports what went wrong, not what the retry did", async () => { + // A write that fails again must not become the failure the user is told + // about — the install is what they were watching. + h.writeThrows = true; + h.reportsAccount = true; + h.setupError = new DyadError("exit 1", DyadErrorKind.External); + + await expect(checkThenRun()).rejects.toThrow("exit 1"); + }); + it("marks a failure the machine already put on screen", async () => { // The panel suppresses what carries this and shows everything else, so // the mark is what stops one failure being reported twice. diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index 02e2e3b669..47ebfda76d 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -83,6 +83,19 @@ function setupController(): CoolifySetupController { // Trust on first use only when there has been no first use. A server // that was looked at is held to what it showed then. const pinned = inspectedFingerprints.get(serverKeyFor(target)); + /** + * What the account write could not store, if it could not store it. + * + * A run that then fails takes the only copy of the password with it: + * the failed screen carries a message and a log, and the call never + * returns the result that shows it. So it is tried once more where it + * starts to matter, which turns a keychain that was briefly busy into + * nothing at all. + */ + let unsavedAccount: { + credentials: { email: string; password: string }; + dashboardUrl: string; + } | null = null; return runServerSetup({ target: targetFrom(target, key.privateKey), adminEmail: target.adminEmail, @@ -109,16 +122,37 @@ function setupController(): CoolifySetupController { adminInstanceUrl: dashboardUrl, }, }); + unsavedAccount = null; } catch (error) { // The account exists on the server whatever happened here, and a // second attempt is refused because Coolify is now installed. The // finished screen still shows the password, so ending the run // over this would throw away the only copy of it. + unsavedAccount = { credentials, dashboardUrl }; logger.error("Could not store the admin account", error); } }, }) .catch((error: unknown) => { + // The run is ending badly, so this is the last chance to keep a + // password nothing else holds. Guarded, because a write that fails + // again must not become the failure the user is told about. + if (unsavedAccount) { + try { + writeSettings({ + coolify: { + ...readSettings().coolify, + adminEmail: unsavedAccount.credentials.email, + adminPassword: { + value: unsavedAccount.credentials.password, + }, + adminInstanceUrl: unsavedAccount.dashboardUrl, + }, + }); + } catch (retryError) { + logger.error("Could not store the admin account", retryError); + } + } // A key that does not match is not the user declining, and reporting // it as one would file it as a cancellation and say nothing. if ( @@ -153,6 +187,14 @@ function setupController(): CoolifySetupController { adminInstanceUrl: result.dashboardUrl, // The address and token go together: an address stored without a // token would read as an instance Dyad can talk to and cannot. + // Stored without the acknowledgement `coolify:save-token` + // demands for an unencrypted address. Not an oversight and + // not a decision this path can make honestly: whether HTTPS + // was possible is only known once the install has run, so + // asking here is asking after the fact. The finished screen + // says the server is not encrypted, and asking beforehand — + // for the addresses that can never have a certificate — is a + // change of its own rather than a line here. ...(result.token ? { instanceUrl: result.dashboardUrl, @@ -282,9 +324,10 @@ export function registerCoolifySetupHandlers() { let fingerprint: string | null = null; const session = await connectSsh( targetFrom(input, key.privateKey), + // Only remembered here. What is recorded is decided once the check has + // finished, so the key and the verdict cannot disagree. trustOnFirstUse((fp) => { fingerprint = fp; - inspectedFingerprints.set(serverKeyFor(input), fp); }), ); try { @@ -307,6 +350,14 @@ export function registerCoolifySetupHandlers() { ); }), ]); + // Both together, and only now. Recording the key during the handshake + // left a check that then failed with the new machine's key beside the + // old machine's pass, which is an install onto a server nobody looked + // at. A check that does not finish changes neither, so what stands is + // whatever the last finished check said. + if (fingerprint) { + inspectedFingerprints.set(serverKeyFor(input), fingerprint); + } // Kept only while the answer stands: a server that was ready and has // since had Coolify put on it must not keep an old pass. if (checks.ready) readyHosts.add(serverKeyFor(input)); From a16ff7b668a8c69bd913767c9fb1f447079497d7 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 21 Aug 2026 22:57:04 -0500 Subject: [PATCH 15/91] test(coolify): hold the panel's half of the re-check rule The main process keeps the last finished check's pass on purpose, so clearing the verdict before asking again is the only thing between a check that never came back and an install that sends the admin password to whatever answered. That line could be deleted with the whole suite still green, while the handler half beside it was pinned four ways. The comment on the pass also still described the arrangement this replaced, and said the opposite of the one three hundred lines below it. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyServerSetup.test.tsx | 28 +++++++++++++++++++ .../handlers/coolify_setup_handlers.test.ts | 4 +-- src/ipc/handlers/coolify_setup_handlers.ts | 7 +++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/components/CoolifyServerSetup.test.tsx b/src/components/CoolifyServerSetup.test.tsx index 4d57e75c3c..b82f078134 100644 --- a/src/components/CoolifyServerSetup.test.tsx +++ b/src/components/CoolifyServerSetup.test.tsx @@ -335,6 +335,34 @@ describe("what it refuses before starting", () => { }); }); +describe("a re-check that does not finish", () => { + it("leaves no verdict behind, and no install to press", async () => { + // The main process keeps the last finished check's pass on purpose, so + // this is the only thing between a check that never came back and an + // install that sends the admin password to whatever answered. + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await checkServer(user); + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(false); + + h.inspect.mockRejectedValueOnce(new Error("connection reset")); + await user.click(screen.getByTestId("coolify-setup-inspect")); + + await waitFor(() => + expect(screen.queryByTestId("coolify-setup-inspection")).toBeNull(), + ); + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(true); + }); +}); + describe("an answer about a server the user has moved on from", () => { it("does not show one machine's check against another's address", async () => { // The answer arrives after a round trip. By then the address in the field diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts index b05287c80b..cef9ed62eb 100644 --- a/src/ipc/handlers/coolify_setup_handlers.test.ts +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -306,8 +306,8 @@ describe("run", () => { }); it("refuses a server whose check never finished", async () => { - // The fingerprint is recorded during the handshake, before preflight has - // said anything — so a connection that opened is not a check that passed. + // Neither the key nor the pass is recorded until a check has finished, so + // a connection that opened leaves nothing for an install to go on. h.preflightThrows = true; await expect(call("coolify-setup:inspect", TARGET)).rejects.toThrow(); diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index 47ebfda76d..fe812e5718 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -60,9 +60,10 @@ const inspectedFingerprints = new Map(); /** * The servers a check got through and liked. * - * Separate from the pin above, which is written during the handshake: a - * connection that opened is not a server that answered, and a server that - * answered "Coolify is already here" is not one to install onto. + * Separate from the pin above because the two do not come and go together: a + * server that was ready and has since had Coolify put on it loses its pass + * while the key it showed still stands. Both are written once a check has + * finished, so a pass here always belongs to the key recorded there. */ const readyHosts = new Set(); From c76978eed04c82d094aa962b87a8be98992da2e7 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Sun, 23 Aug 2026 16:00:12 -0500 Subject: [PATCH 16/91] feat(coolify): forget the instance when signing out of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dyad held one Coolify plus two leftovers: a bare token from the last connection, and the admin account for a server it installed. Neither was reachable to delete, so a password Dyad invented outlived every sign-out with no way to be rid of it. Signing out now clears the instance outright, behind a dialog that shows what is about to go — with its copy buttons — and asks for a tick before proceeding. The address is forgotten with the rest, so the token form no longer prefills it. With one instance there is nothing left to tell apart, so the pairing that hid one server's password under another's address goes, and with it previousAccessToken, isPreviousConnection, and sameServer. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyConnector.test.tsx | 67 +++++++- src/components/CoolifyConnector.tsx | 67 ++++---- src/components/CoolifyCredentials.test.tsx | 25 +-- src/components/CoolifyCredentials.tsx | 11 +- src/components/CoolifySignOutDialog.test.tsx | 132 +++++++++++++++ src/components/CoolifySignOutDialog.tsx | 92 +++++++++++ src/ipc/handlers/coolify_handlers.test.ts | 84 +++------- src/ipc/handlers/coolify_handlers.ts | 56 ++----- .../handlers/coolify_setup_handlers.test.ts | 156 +++--------------- src/ipc/handlers/coolify_setup_handlers.ts | 74 ++------- src/ipc/types/coolify_setup.ts | 7 - src/lib/schemas.ts | 13 +- src/main/settings.test.ts | 23 ++- src/main/settings.ts | 26 --- 14 files changed, 425 insertions(+), 408 deletions(-) create mode 100644 src/components/CoolifySignOutDialog.test.tsx create mode 100644 src/components/CoolifySignOutDialog.tsx diff --git a/src/components/CoolifyConnector.test.tsx b/src/components/CoolifyConnector.test.tsx index f37099dc0b..236038649c 100644 --- a/src/components/CoolifyConnector.test.tsx +++ b/src/components/CoolifyConnector.test.tsx @@ -18,11 +18,29 @@ vi.mock("sonner", () => ({ toast: toastMock })); vi.mock("@/components/CoolifyCredentials", () => ({ CoolifyCredentials: ({ showTitle }: { showTitle?: boolean }) => (
- {showTitle ? "Previous Coolify connection" : null} + {showTitle ? "Your new Coolify server" : null}
), })); +// Stubbed down to its two edges — that it is open, and the way to confirm — +// so these cases can check the wiring without the checkbox gating, which is +// the dialog's own test's job. +vi.mock("@/components/CoolifySignOutDialog", () => ({ + CoolifySignOutDialog: ({ + open, + onConfirm, + }: { + open: boolean; + onConfirm: () => void; + }) => + open ? ( + + ) : null, +})); + // The real installer fetches a key and runs mutations of its own, none of // which these cases are about. vi.mock("@/components/CoolifyServerSetup", () => ({ @@ -233,6 +251,44 @@ describe("the instance and the app are separate sections", () => { const section = screen.getByTestId("coolify-instance-section"); expect(section.textContent).toContain("Sign out of Coolify"); }); + + /** The default mock builds a fresh one per render, so nothing can watch it. */ + function watchClearToken() { + const mutateAsync = vi.fn(); + deploy.value.clearToken = { mutateAsync, isPending: false }; + return mutateAsync; + } + + it("asks before forgetting anything", async () => { + // Signing out throws away the password Dyad invented, and pressing the + // button is not the same as having read that. + connected(null); + const clearToken = watchClearToken(); + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Sign out of Coolify" }), + ); + + expect(clearToken).not.toHaveBeenCalled(); + expect(screen.getByTestId("confirm-sign-out")).toBeTruthy(); + }); + + it("forgets the instance once that is confirmed", async () => { + connected(null); + const clearToken = watchClearToken(); + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Sign out of Coolify" }), + ); + await user.click(screen.getByTestId("confirm-sign-out")); + + expect(clearToken).toHaveBeenCalled(); + expect(toastMock.success).toHaveBeenCalled(); + }); }); /** With no token, installing is the landing screen; this is the other route. */ @@ -281,19 +337,20 @@ describe("where someone with no Coolify lands", () => { render(); const text = screen.getByTestId("coolify-server-setup-stub").textContent; - expect(text?.indexOf("Previous Coolify connection")).toBeLessThan( + expect(text?.indexOf("Your new Coolify server")).toBeLessThan( text?.indexOf("I already have Coolify installed") ?? -1, ); }); - it("still shows what it knows about a Coolify signed out of", async () => { - // Signing out is exactly when the password is needed, and it lands here. + it("still shows what it knows about a server with no token yet", async () => { + // Installing a server whose token could not be minted lands here, and the + // account Dyad made is the only way into it. deploy.value = NO_TOKEN; const user = userEvent.setup(); render(); expect(screen.getByTestId("coolify-credentials-stub").textContent).toBe( - "Previous Coolify connection", + "Your new Coolify server", ); await openTokenForm(user); expect(screen.getByTestId("coolify-credentials-stub")).toBeTruthy(); diff --git a/src/components/CoolifyConnector.tsx b/src/components/CoolifyConnector.tsx index 89614c6072..c5a187281d 100644 --- a/src/components/CoolifyConnector.tsx +++ b/src/components/CoolifyConnector.tsx @@ -29,6 +29,7 @@ import { ipc } from "@/ipc/types"; import type { SetupSnapshot } from "@/ipc/types"; import { CoolifyServerSetup } from "@/components/CoolifyServerSetup"; import { CoolifyCredentials } from "@/components/CoolifyCredentials"; +import { CoolifySignOutDialog } from "@/components/CoolifySignOutDialog"; import { useLoadApp } from "@/hooks/useLoadApp"; import { useCoolifyDeploy } from "@/hooks/useCoolifyDeploy"; import { selectCoolifyDeployCapabilities } from "@/coolify_deploy/capabilities"; @@ -120,6 +121,7 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { // Installing is where this lands, so the flag is the other way round: it // records having said "I already have Coolify". const [isEnteringToken, setIsEnteringToken] = useState(false); + const [isConfirmingSignOut, setIsConfirmingSignOut] = useState(false); const [token, setToken] = useState(""); const [serverUuid, setServerUuid] = useState(""); const [projectUuid, setProjectUuid] = useState(""); @@ -153,9 +155,10 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { setIsEditingConnection(false); }, [appId, status?.hasToken]); - // On both screens below. Signing out is exactly when someone needs the - // password back, and it is the state that leaves them here. - const previousConnection = ; + // On both screens below. A server Dyad installed but could not mint a token + // for is named by its admin account and nothing else, and these are the two + // states that leaves the user in. + const newServerCredentials = ; // One element, not one per branch: rendering a second copy elsewhere in the // tree would remount the panel and lose the result it is being kept for. @@ -168,7 +171,7 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { setIsEnteringToken(true); }} > - {previousConnection} + {newServerCredentials} {/* Last, under anything Dyad already knows about a Coolify: this is the exit for the people the installer does not apply to, not another @@ -445,7 +448,7 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { Connect - {previousConnection} + {newServerCredentials} {/* Back to the installer, for someone who came here by mistake. */}
@@ -470,29 +473,37 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { // to live solely inside the discovery-error card, so rotating a token or // moving to another instance meant first breaking discovery on purpose. const signOut = ( - + <> + + { + try { + await clearToken.mutateAsync(); + // The component is not remounted when the token goes, so without + // this the form comes back holding the credential just forgotten — + // and pressing Connect would silently store it again, which is + // the opposite of what signing out to rotate a token is for. + setToken(""); + setAcknowledgedInsecure(false); + toast.success( + "Signed out of Coolify. Your server keeps running and your apps keep their settings.", + ); + } catch (error) { + toast.error(getErrorMessage(error)); + } + }} + /> + ); // Two things are on this screen and they are not the same thing: the // Coolify the user connected to, which is theirs and outlives any app, and diff --git a/src/components/CoolifyCredentials.test.tsx b/src/components/CoolifyCredentials.test.tsx index 7553cbe4ff..be926e4b4e 100644 --- a/src/components/CoolifyCredentials.test.tsx +++ b/src/components/CoolifyCredentials.test.tsx @@ -28,7 +28,6 @@ const FULL = { adminEmail: "me@gmail.com", adminPassword: "Abc123@xyzAbc123@xyz", apiToken: "1|abcdefghijklmnop", - isPreviousConnection: true, }; beforeEach(() => { @@ -114,21 +113,10 @@ describe("revealing one value", () => { }); describe("naming the section", () => { - it("calls a Coolify that was connected a previous one", async () => { - render(); - await waitFor(() => - expect(screen.getByText("Previous Coolify connection")).toBeTruthy(), - ); - }); - - it("does not call a server just installed a previous connection", async () => { - // Reached by installing a server whose API token could not be minted: - // it is new, and calling it previous reads as something being over. - h.revealCredentials.mockResolvedValue({ - ...FULL, - apiToken: null, - isPreviousConnection: false, - }); + it("names a server Dyad installed", async () => { + // Reached by installing a server whose API token could not be minted, so + // the account is all Dyad has for it. + h.revealCredentials.mockResolvedValue({ ...FULL, apiToken: null }); render(); await waitFor(() => @@ -143,12 +131,11 @@ describe("naming the section", () => { adminEmail: null, adminPassword: null, apiToken: null, - isPreviousConnection: false, }); render(); await waitFor(() => expect(h.revealCredentials).toHaveBeenCalled()); - expect(screen.queryByText("Previous Coolify connection")).toBeNull(); + expect(screen.queryByText("Your new Coolify server")).toBeNull(); }); }); @@ -161,7 +148,6 @@ describe("an instance Dyad did not set up", () => { adminEmail: null, adminPassword: null, apiToken: null, - isPreviousConnection: false, }); const { container } = render(); @@ -176,7 +162,6 @@ describe("an instance Dyad did not set up", () => { adminEmail: null, adminPassword: null, apiToken: "1|theirs", - isPreviousConnection: true, }); await renderAndSettle(); diff --git a/src/components/CoolifyCredentials.tsx b/src/components/CoolifyCredentials.tsx index cf60604050..01465727e4 100644 --- a/src/components/CoolifyCredentials.tsx +++ b/src/components/CoolifyCredentials.tsx @@ -10,8 +10,8 @@ import { queryKeys } from "@/lib/queryKeys"; * The way into a server Dyad set up. * * Dyad invents the admin password and mints the API token, so it is the only - * thing that knows either. Without somewhere to read them, signing out of - * Coolify in Dyad locks the user out of a machine they own. + * thing that knows either. Without somewhere to read them, a machine the user + * owns has no way in they can see. * * Shown rather than hidden behind a control: these belong to the user, and * making them click to discover that Dyad even has them means most people @@ -105,13 +105,10 @@ export function CoolifyCredentials({ return (
{/* Kept inside so a caller cannot leave a heading over nothing when - there is nothing to show — and so the wording follows which server - these turned out to describe, which only this knows. */} + there is nothing to show. */} {showTitle && (
- {credentials.isPreviousConnection - ? "Previous Coolify connection" - : "Your new Coolify server"} + Your new Coolify server
)} {dashboardUrl && } diff --git a/src/components/CoolifySignOutDialog.test.tsx b/src/components/CoolifySignOutDialog.test.tsx new file mode 100644 index 0000000000..f251132536 --- /dev/null +++ b/src/components/CoolifySignOutDialog.test.tsx @@ -0,0 +1,132 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/toast", () => ({ showError: vi.fn() })); + +const h = vi.hoisted(() => ({ revealCredentials: vi.fn() })); +vi.mock("@/ipc/types", () => ({ + ipc: { coolifySetup: { revealCredentials: h.revealCredentials } }, +})); + +const { CoolifySignOutDialog: Dialog } = await import("./CoolifySignOutDialog"); + +const FULL = { + dashboardUrl: "https://203.0.113.5.sslip.io", + adminEmail: "me@gmail.com", + adminPassword: "Abc123@xyzAbc123@xyz", + apiToken: "1|abcdefghijklmnop", +}; + +const onConfirm = vi.fn(); +const onOpenChange = vi.fn(); + +function Harness({ open }: { open: boolean }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + + + + ); +} + +function open(props: { open?: boolean } = {}) { + return render(); +} + +async function openAndSettle() { + const result = open(); + await waitFor(() => + expect(screen.getByTestId("coolify-sign-out-dialog")).toBeTruthy(), + ); + return result; +} + +function signOutButton() { + return screen.getByRole("button", { name: "Sign out" }) as HTMLButtonElement; +} + +beforeEach(() => { + vi.clearAllMocks(); + h.revealCredentials.mockResolvedValue(FULL); +}); + +describe("acknowledging the loss", () => { + it("will not sign out until the box is ticked", async () => { + // The whole point of the dialog: the password below is about to go and + // Dyad has the only copy, so confirming has to be a separate act. + await openAndSettle(); + + expect(signOutButton().disabled).toBe(true); + }); + + it("signs out once it is", async () => { + const user = userEvent.setup(); + await openAndSettle(); + + await user.click(screen.getByTestId("coolify-sign-out-acknowledge")); + await user.click(signOutButton()); + + expect(onConfirm).toHaveBeenCalled(); + }); + + it("starts unticked again the next time it opens", async () => { + // Otherwise a tick from an earlier sign-out arms this one, and the last + // look at the password is skipped. + const user = userEvent.setup(); + const { rerender } = await openAndSettle(); + await user.click(screen.getByTestId("coolify-sign-out-acknowledge")); + await waitFor(() => expect(signOutButton().disabled).toBe(false)); + + rerender(); + rerender(); + + await waitFor(() => expect(signOutButton().disabled).toBe(true)); + }); +}); + +describe("the last look", () => { + it("shows what is about to be forgotten", async () => { + await openAndSettle(); + + await waitFor(() => + expect(screen.getByTestId("coolify-credentials")).toBeTruthy(), + ); + expect(screen.getByTestId("coolify-field-address")).toBeTruthy(); + expect(screen.getByTestId("coolify-field-password")).toBeTruthy(); + }); + + it("says the password cannot be got back when there is one", async () => { + // Coolify can mint another token; it cannot tell anyone this password. + await openAndSettle(); + + await waitFor(() => + expect( + screen.getByText(/only thing holding it/, { exact: false }), + ).toBeTruthy(), + ); + }); + + it("does not say it for an instance Dyad did not set up", async () => { + // Connected by pasting a token, so nothing here was invented by Dyad and + // a warning about losing it forever would be untrue. + h.revealCredentials.mockResolvedValue({ + ...FULL, + adminEmail: null, + adminPassword: null, + }); + await openAndSettle(); + + await waitFor(() => expect(h.revealCredentials).toHaveBeenCalled()); + expect(screen.queryByText(/only thing holding it/)).toBeNull(); + }); + + it("asks for nothing while it is closed", async () => { + open({ open: false }); + + expect(h.revealCredentials).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/CoolifySignOutDialog.tsx b/src/components/CoolifySignOutDialog.tsx new file mode 100644 index 0000000000..13e9bc8ab6 --- /dev/null +++ b/src/components/CoolifySignOutDialog.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Checkbox } from "@/components/ui/checkbox"; +import { CoolifyCredentials } from "@/components/CoolifyCredentials"; +import { ipc } from "@/ipc/types"; +import { queryKeys } from "@/lib/queryKeys"; + +/** + * The last look at credentials Dyad is about to forget. + * + * Signing out clears the instance outright rather than keeping a copy around + * for an instance nothing is connected to. That is only fair if the user gets + * to take the details with them first, so they are on screen here with their + * copy buttons, behind an acknowledgement rather than a plain confirm. + */ +export function CoolifySignOutDialog({ + open, + onOpenChange, + onConfirm, + isPending, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + isPending?: boolean; +}) { + const [acknowledged, setAcknowledged] = useState(false); + + // Ticked once is not ticked forever: every open starts from unticked, so + // the acknowledgement is about this sign-out. Keyed to `open` rather than + // done on the way out, because closing is not always the user's doing. + useEffect(() => { + if (!open) setAcknowledged(false); + }, [open]); + + // Shares a key with the fields below, so opening this asks once. Only read + // here to decide whether the password gets a warning of its own. + const { data: credentials } = useQuery({ + queryKey: queryKeys.coolify.credentials, + queryFn: () => ipc.coolifySetup.revealCredentials(), + gcTime: 0, + enabled: open, + }); + + return ( + + + + Sign out of Coolify? + + Dyad will forget the details below. Your server keeps running and + your apps keep their settings. + {credentials?.adminPassword + ? " Dyad made this password up and is the only thing holding it — Coolify cannot show it to you again." + : ""} + + + + + + + + + Cancel + + Sign out + + + + + ); +} diff --git a/src/ipc/handlers/coolify_handlers.test.ts b/src/ipc/handlers/coolify_handlers.test.ts index b6e6ed4f1e..fe0fd4b32f 100644 --- a/src/ipc/handlers/coolify_handlers.test.ts +++ b/src/ipc/handlers/coolify_handlers.test.ts @@ -15,7 +15,6 @@ const settings: Record = {}; function storedCoolify() { return (settings.coolify ?? {}) as { accessToken?: { value: string }; - previousAccessToken?: { value: string }; adminEmail?: string; adminPassword?: { value: string }; adminInstanceUrl?: string; @@ -251,34 +250,21 @@ describe("clearing the token", () => { expect(updateSet).not.toHaveBeenCalled(); }); - it("keeps the token where it can be read back", async () => { - // Dyad minted this one itself. Throwing it away means the way back in is - // making another in Coolify, for a token Dyad had a moment ago. - await call("coolify:clear-token"); - - expect(storedCoolify().accessToken).toBeUndefined(); - expect(storedCoolify().previousAccessToken?.value).toBe("tok"); - }); - - it("does not lose it when signing out twice", async () => { - await call("coolify:clear-token"); - await call("coolify:clear-token"); - - expect(storedCoolify().previousAccessToken?.value).toBe("tok"); - }); + it("forgets every stored detail of the instance", async () => { + // Dyad holds one Coolify at a time, so anything surviving here belongs to + // an instance nothing is connected to — and the next connection would put + // a stranger's password under its address. The dialog in front of this + // shows all of it and asks the user to confirm before it goes. + settings.coolify = { + ...(settings.coolify as Record), + adminEmail: "me@gmail.com", + adminPassword: { value: "Abc123@xyz" }, + adminInstanceUrl: "https://coolify.example.com", + }; - it("drops the old one once a new token is saved", async () => { - // Otherwise the next sign-out puts a token from two connections ago on - // screen. await call("coolify:clear-token"); - await call("coolify:save-token", { - instanceUrl: "https://coolify.example.com", - token: "tok-2", - acknowledgedInsecure: false, - }); - expect(storedCoolify().previousAccessToken).toBeUndefined(); - expect(storedCoolify().accessToken?.value).toBe("tok-2"); + expect(storedCoolify()).toEqual({}); }); it("still reports the app as disconnected", async () => { @@ -291,8 +277,9 @@ describe("clearing the token", () => { }; expect(status.hasToken).toBe(false); expect(status.connection).toBeNull(); - // Remembered so the token form does not make the user retype it. - expect(status.instanceUrl).toBe("https://coolify.example.com"); + // The address is part of the instance being forgotten, not a leftover + // convenience. It was on screen with a copy button on the way out. + expect(status.instanceUrl).toBeNull(); }); it("lets the same instance pick up where it left off", async () => { @@ -357,29 +344,10 @@ describe("the admin account Dyad created", () => { }; }); - it("survives connecting to a different Coolify", async () => { - // Dyad invented this password for a machine that is still running, and - // this is the only copy. It is kept with the address it belongs to and - // shown only beside that address, so nothing here pairs one server's - // password with another's — hiding does that job, and hiding a wrong - // guess costs a moment where deleting one costs the password. - listServers.mockResolvedValueOnce([{ uuid: "srv-elsewhere" }]); - await call("coolify:save-token", { - instanceUrl: "https://other.example.com", - token: "tok-2", - acknowledgedInsecure: false, - }); - - expect(storedCoolify().adminPassword?.value).toBe("Abc123@xyz"); - expect(storedCoolify().adminInstanceUrl).toBe( - "https://coolify.example.com", - ); - }); - - it("survives signing back in to the instance it belongs to", async () => { - // The reason it is kept at all: Dyad invented this password for a server - // the user owns, and signing out must not cost them the way in. - await call("coolify:clear-token"); + it("survives the token for it being saved", async () => { + // Setting up a server writes the account before there is any token, and + // this is the call that supplies one. Replacing rather than merging here + // would drop the password on the way in, and Dyad has the only copy. await call("coolify:save-token", { instanceUrl: "https://coolify.example.com", token: "tok-2", @@ -390,14 +358,14 @@ describe("the admin account Dyad created", () => { expect(storedCoolify().adminPassword?.value).toBe("Abc123@xyz"); }); - it("matches the address however it was typed", async () => { - await call("coolify:save-token", { - instanceUrl: "https://coolify.example.com/", - token: "tok-2", - acknowledgedInsecure: false, - }); + it("goes when the instance is forgotten", async () => { + // The account only opens the instance being signed out of, so keeping it + // would leave a password for a Coolify nothing is connected to. + await call("coolify:clear-token"); - expect(storedCoolify().adminEmail).toBe("me@gmail.com"); + expect(storedCoolify().adminEmail).toBeUndefined(); + expect(storedCoolify().adminPassword).toBeUndefined(); + expect(storedCoolify().adminInstanceUrl).toBeUndefined(); }); }); diff --git a/src/ipc/handlers/coolify_handlers.ts b/src/ipc/handlers/coolify_handlers.ts index 8151d66120..3b705a5885 100644 --- a/src/ipc/handlers/coolify_handlers.ts +++ b/src/ipc/handlers/coolify_handlers.ts @@ -127,21 +127,14 @@ export function registerCoolifyHandlers() { await probe.listServers(); const normalized = instanceUrl.replace(/\/+$/, ""); const previous = readSettings().coolify?.instanceUrl; - // Spread, as clearToken does: a field added to CoolifySchema later - // should not be dropped by whichever of the two happens to run. + // Spread: the admin account for a server Dyad just installed is set + // before there is any token for it, and this is the call that supplies + // the token. Replacing would drop the password on the way in. writeSettings({ coolify: { ...readSettings().coolify, instanceUrl: normalized, accessToken: { value: token }, - // Superseded. Leaving it would put a token from two connections ago - // on screen after the next sign-out. - previousAccessToken: undefined, - // The admin account is left alone. It is kept with the address it - // belongs to and only shown beside that address, so connecting - // elsewhere cannot pair one server's password with another's — and - // deleting on a mismatched address would throw away the only copy - // of a password Dyad invented for a machine still running. }, }); // Nothing is cleared here. Server, project and application ids are @@ -168,36 +161,23 @@ export function registerCoolifyHandlers() { }); createTypedHandler(coolifyContracts.clearToken, async () => { - // Only the token. Every app reads as disconnected without one, so there is - // nothing to gain by clearing their rows — and doing so would throw away - // each app's Coolify application id, which is the one value that cannot be - // re-entered. The next deploy would then build a second application beside - // the one already running and lose a fight with it over the domain. + // Every field, not just the token. Dyad holds one Coolify at a time, so + // an address or an admin account left behind belongs to an instance + // nothing is connected to any more — and the next connection would put a + // stranger's password under its address. The screen that reaches this + // shows all of it one last time and asks the user to confirm, because + // Dyad invented the password and is the only thing that has it. // - // The instance URL stays so that saveToken can still tell whether the next - // token points somewhere else. Even then the rows are kept: those ids name - // applications still running on the old instance, and nothing in Dyad - // could reach them again once they were gone. + // Replaced rather than spread: this is the handler that forgets the + // instance, so a field added to CoolifySchema later should go too. + // + // The apps' rows are not touched. They read as disconnected without a + // token anyway, and they carry each app's Coolify application id — the + // one value that cannot be typed back in. Losing it would make the next + // deploy build a second application beside the one already running and + // lose a fight with it over the domain. coolifyDeployRegistry.cancelAll(); - // The address survives; only the token goes. Spread rather than replaced, - // so a field added to CoolifySchema later is not silently dropped here. - const current = readSettings().coolify; - const carried = current?.accessToken ?? current?.previousAccessToken; - writeSettings({ - coolify: { - ...current, - accessToken: undefined, - // Kept where it can be read back rather than deleted. Dyad usually - // minted this itself, so losing it means making another in Coolify to - // get back in. Signing out twice must not overwrite it with nothing. - // - // Named only when there is something readable to name: a secret this - // machine cannot decrypt reads as absent, and writing the key as - // undefined would be taken as a deliberate clear and throw away - // ciphertext that a repaired keychain could still open. - ...(carried ? { previousAccessToken: carried } : {}), - }, - }); + writeSettings({ coolify: {} }); }); createTypedHandler(coolifyContracts.createProject, async (_, { name }) => { diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts index cef9ed62eb..fabd256e82 100644 --- a/src/ipc/handlers/coolify_setup_handlers.test.ts +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -282,21 +282,6 @@ describe("run", () => { }); }); - it("clears a token kept from an instance it has moved off", async () => { - // Saving a token by hand clears it; a setup that stores its own has the - // same reason to. - h.settings = { - coolify: { previousAccessToken: { value: "1|old" } }, - } as Record; - - await checkThenRun(); - - const saved = h.written.at(-1) as { - coolify: { previousAccessToken?: unknown }; - }; - expect(saved.coolify.previousAccessToken).toBeUndefined(); - }); - it("refuses a server it has not looked at", async () => { // The form disables Install until the check has run, but this is the call // that sends the credentials, so it says no on its own account. @@ -627,17 +612,14 @@ describe("revealCredentials", () => { adminEmail: "me@gmail.com", adminPassword: "Abc123@xyz", apiToken: "1|abc", - isPreviousConnection: true, }); }); - it("hands back the token from before signing out", async () => { - // Signing back in is then a paste, rather than a trip into Coolify to - // mint a token Dyad already had. + it("gives the address of a server installed before any token", async () => { + // Nothing was ever connected, so there is no instanceUrl — but the user + // still has to know which machine these open. h.settings = { coolify: { - instanceUrl: "http://203.0.113.5:8000", - previousAccessToken: { value: "1|old" }, adminEmail: "me@gmail.com", adminPassword: { value: "Abc123@xyz" }, adminInstanceUrl: "http://203.0.113.5:8000", @@ -647,88 +629,20 @@ describe("revealCredentials", () => { string, unknown >; - expect(result.apiToken).toBe("1|old"); - }); - - it("prefers the live token over the one kept from before", async () => { - h.settings = { - coolify: { - instanceUrl: "http://203.0.113.5:8000", - accessToken: { value: "1|current" }, - previousAccessToken: { value: "1|old" }, - }, - }; - const result = (await call("coolify-setup:reveal-credentials")) as Record< - string, - unknown - >; - expect(result.apiToken).toBe("1|current"); - }); - - it("recognises one server through its different spellings", async () => { - // Dyad asks for a certificate under the sslip.io name, so the address it - // stores can differ from the one the user types for the same box. Read as - // two servers, the password for the one in front of them is hidden. - h.settings = { - coolify: { - instanceUrl: "http://203.0.113.5:8000", - accessToken: { value: "1|abc" }, - adminEmail: "me@gmail.com", - adminPassword: { value: "Abc123@xyz" }, - adminInstanceUrl: "https://203.0.113.5.sslip.io", - }, - }; - const result = (await call("coolify-setup:reveal-credentials")) as Record< - string, - unknown - >; + expect(result.dashboardUrl).toBe("http://203.0.113.5:8000"); expect(result.adminPassword).toBe("Abc123@xyz"); }); - it("still tells two different servers apart", async () => { + it("names the server by the address the token was saved for", async () => { + // The two differ when a server installed at its bare address is connected + // under the domain it was given afterwards. The address Dyad is talking + // to is the one that reaches Coolify, so it is the one shown. h.settings = { coolify: { - instanceUrl: "https://someone-else.example.com", + instanceUrl: "https://coolify.example.com", accessToken: { value: "1|abc" }, adminEmail: "me@gmail.com", adminPassword: { value: "Abc123@xyz" }, - adminInstanceUrl: "https://203.0.113.5.sslip.io", - }, - }; - const result = (await call("coolify-setup:reveal-credentials")) as Record< - string, - unknown - >; - expect(result.adminPassword).toBeNull(); - }); - - it("does not call a server with no token yet a previous connection", async () => { - // Installed a moment ago, with no token minted for it. Calling it - // previous reads as something being over. - h.settings = { - coolify: { - adminEmail: "me@gmail.com", - adminPassword: { value: "Abc123@xyz" }, - adminInstanceUrl: "http://203.0.113.5:8000", - }, - }; - const result = (await call("coolify-setup:reveal-credentials")) as Record< - string, - unknown - >; - expect(result.isPreviousConnection).toBe(false); - }); - - it("describes one server, not two at once", async () => { - // Connected to one Coolify, signed out, then installed another whose - // token could not be minted. Showing the first one's address over the - // second one's password reads as a way in and is not one. - h.settings = { - coolify: { - instanceUrl: "https://old.example.com", - previousAccessToken: { value: "1|for-the-old-one" }, - adminEmail: "me@gmail.com", - adminPassword: { value: "PasswordForTheNewOne" }, adminInstanceUrl: "http://203.0.113.5:8000", }, }; @@ -736,53 +650,25 @@ describe("revealCredentials", () => { string, unknown >; - - // The server Dyad installed: its password is what nothing else knows. - expect(result.dashboardUrl).toBe("http://203.0.113.5:8000"); - expect(result.adminPassword).toBe("PasswordForTheNewOne"); - // The other instance's token would not open this one. - expect(result.apiToken).toBeNull(); - }); - - it("gives the address of a server installed before any token", async () => { - // Nothing was ever connected, so there is no instanceUrl — but the user - // still has to know which machine these open. - h.settings = { - coolify: { - adminEmail: "me@gmail.com", - adminPassword: { value: "Abc123@xyz" }, - adminInstanceUrl: "http://203.0.113.5:8000", - }, - }; - const result = (await call("coolify-setup:reveal-credentials")) as Record< - string, - unknown - >; - expect(result.dashboardUrl).toBe("http://203.0.113.5:8000"); + expect(result.dashboardUrl).toBe("https://coolify.example.com"); + // Still the same box, so its account is still what opens it. expect(result.adminPassword).toBe("Abc123@xyz"); }); - it("keeps the connected instance as the subject while it is connected", async () => { - // A live token means Dyad is talking to that one, so it is what the panel - // is about — and the account from elsewhere is not shown beside it. - h.settings = { - coolify: { - instanceUrl: "https://connected.example.com", - accessToken: { value: "1|live" }, - adminEmail: "me@gmail.com", - adminPassword: { value: "PasswordForElsewhere" }, - adminInstanceUrl: "http://203.0.113.5:8000", - }, - }; + it("has nothing to hand back once the instance is forgotten", async () => { + // Signing out clears all of it, so there is no address or password left + // for the panel to put on screen. + h.settings = { coolify: {} }; const result = (await call("coolify-setup:reveal-credentials")) as Record< string, unknown >; - - expect(result.dashboardUrl).toBe("https://connected.example.com"); - expect(result.apiToken).toBe("1|live"); - expect(result.adminPassword).toBeNull(); - expect(result.adminEmail).toBeNull(); + expect(result).toEqual({ + dashboardUrl: null, + adminEmail: null, + adminPassword: null, + apiToken: null, + }); }); it("answers nulls for an instance Dyad did not set up", async () => { diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index fe812e5718..3cfef3eb1b 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -200,10 +200,6 @@ function setupController(): CoolifySetupController { ? { instanceUrl: result.dashboardUrl, accessToken: { value: result.token }, - // Cleared alongside, as saving a token by hand does: one - // from an instance Dyad has moved off is not a way back - // into the one it is on now. - previousAccessToken: undefined, } : {}), }, @@ -234,34 +230,14 @@ function setupController(): CoolifySetupController { return controller; } -/** - * The machine an address names, ignoring how it was written. - * - * One server has several valid spellings — http://1.2.3.4:8000 and the - * https://1.2.3.4.sslip.io Dyad asks for a certificate under are the same - * box — and treating them as different servers hides the credentials for the - * one the user is looking at. Only ever used to decide what to show, so it - * can afford to be generous. - */ -function serverIdentity(url: string): string { - try { - const host = new URL(url).hostname.toLowerCase().replace(/^\[|\]$/g, ""); - // sslip.io spells an address as a name; the address is the identity. - const derived = /^(.+)\.sslip\.io$/.exec(host); - return derived ? derived[1] : host; - } catch { - return url.trim().toLowerCase(); - } -} - /** * The key a server's fingerprint and verdict are remembered under. * * The port is part of it: two services on one address are two servers, and - * holding the second to the first one's key would refuse a valid one. And - * serverIdentity is for URLs, which a bare address is not — `fe80::1` parses - * as the scheme `fe80:` and leaves no hostname at all, so every address - * shaped that way would share one entry. + * holding the second to the first one's key would refuse a valid one. Built + * from the address as typed rather than by parsing it as a URL, which a bare + * address is not — `fe80::1` parses as the scheme `fe80:` and leaves no + * hostname at all, so every address shaped that way would share one entry. */ function serverKeyFor(input: SetupServer): string { const host = input.host @@ -271,11 +247,6 @@ function serverKeyFor(input: SetupServer): string { return `${host}:${sshPort(input) ?? 22}`; } -function sameServer(a: string | null | undefined, b: string | null): boolean { - if (!a || b === null) return false; - return serverIdentity(a) === serverIdentity(b); -} - /** * Which port to knock on. * @@ -442,36 +413,17 @@ export function registerCoolifySetupHandlers() { // Dyad generated the password on their behalf, so refusing to show it // would lock them out of something they own. const coolify = readSettings().coolify; - // One server, described consistently. Dyad can hold details for two — an - // instance connected by pasting a token, and a server it installed whose - // token could not be minted — and pairing one's address with the other's - // password reads as a way in that is not one. + // Everything here describes the same server, because Dyad holds one at a + // time and signing out forgets all of it together. So the fields are read + // straight out rather than checked against each other for whose they are. // - // Connected wins when there is a live token, since that is the instance - // Dyad is talking to. Otherwise the server Dyad installed does: its - // password is the thing nothing else in the world knows. - const liveToken = coolify?.accessToken?.value ?? null; - const dashboardUrl = - (liveToken - ? coolify?.instanceUrl - : (coolify?.adminInstanceUrl ?? coolify?.instanceUrl)) ?? null; - const adminIsHere = sameServer(coolify?.adminInstanceUrl, dashboardUrl); - const tokenIsHere = sameServer(coolify?.instanceUrl, dashboardUrl); + // adminInstanceUrl covers the window before a token exists: a server just + // installed is named by the account Dyad made on it and nothing else. return { - dashboardUrl, - adminEmail: adminIsHere ? (coolify?.adminEmail ?? null) : null, - adminPassword: adminIsHere - ? (coolify?.adminPassword?.value ?? null) - : null, - // A server described through its own address, with no token, is one - // Dyad has just set up rather than one it used to talk to. - isPreviousConnection: dashboardUrl !== null && tokenIsHere, - // The one from before signing out, when there is no live one. Signing - // back in is then a paste rather than a trip into Coolify to mint - // another. - apiToken: tokenIsHere - ? (liveToken ?? coolify?.previousAccessToken?.value ?? null) - : null, + dashboardUrl: coolify?.instanceUrl ?? coolify?.adminInstanceUrl ?? null, + adminEmail: coolify?.adminEmail ?? null, + adminPassword: coolify?.adminPassword?.value ?? null, + apiToken: coolify?.accessToken?.value ?? null, }; }); diff --git a/src/ipc/types/coolify_setup.ts b/src/ipc/types/coolify_setup.ts index 3f92ded371..dc23425ccf 100644 --- a/src/ipc/types/coolify_setup.ts +++ b/src/ipc/types/coolify_setup.ts @@ -116,13 +116,6 @@ export const RevealedCredentialsSchema = z.object({ adminEmail: z.string().nullable(), adminPassword: z.string().nullable(), apiToken: z.string().nullable(), - /** - * Whether these describe a Coolify that was connected and is not now. - * - * False for a server Dyad has just installed and has no token for yet: - * that one is new, and calling it previous reads as something being over. - */ - isPreviousConnection: z.boolean(), }); /** diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 227ec3b386..ef5adeb121 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -234,19 +234,10 @@ export const CoolifySchema = z.object({ /** * The address the admin account above belongs to. * - * An account is only good for the instance Dyad made it on. Without this, - * connecting to a second Coolify shows its address beside the first one's - * password, which reads as a way in and is not one. + * Set before there is a token to talk to the instance with, which is the + * window where nothing else here names the machine the account opens. */ adminInstanceUrl: z.string().optional(), - /** - * The token from the last connection, kept when signing out. - * - * Dyad minted this one itself, so throwing it away means the only way back - * in is making another in Coolify — for a token Dyad still had a moment ago. - * Not used to talk to anything: it is shown so it can be pasted back. - */ - previousAccessToken: SecretSchema.optional(), }); export type Coolify = z.infer; diff --git a/src/main/settings.test.ts b/src/main/settings.test.ts index 668058f479..e904b24b25 100644 --- a/src/main/settings.test.ts +++ b/src/main/settings.test.ts @@ -1120,34 +1120,33 @@ describe("preserving undecryptable secrets", () => { }); }); - it("hides a kept Coolify token that will not decrypt", () => { - // Handing the ciphertext through would put it on screen as the token to - // paste back, and it would be rejected with no way to tell why. + it("hides a Coolify token that will not decrypt", () => { + // Handing the ciphertext through would have it sent to Coolify as the + // token, and it would be rejected with no way to tell why. store[mockSettingsPath] = JSON.stringify({ coolify: { instanceUrl: "http://203.0.113.5:8000", - previousAccessToken: lockedSecret("coolify"), + accessToken: lockedSecret("coolify"), }, }); const read = readSettings(); - expect(read.coolify?.previousAccessToken).toBeUndefined(); + expect(read.coolify?.accessToken).toBeUndefined(); expect(read.coolify?.instanceUrl).toBe("http://203.0.113.5:8000"); }); - it("puts the Coolify token kept for signing back in through encryption", () => { - // It is the same token it was a moment ago, and it opens the same server. - // Keeping it readable is what makes signing back in a paste; keeping it in - // the clear on disk is a different thing. + it("puts the Coolify admin password through encryption", () => { + // Dyad made this one up and is the only thing holding it, which is a + // reason to keep it readable and not a reason to keep it in the clear. writeSettings({ coolify: { instanceUrl: "http://203.0.113.5:8000", - previousAccessToken: { value: "1|kept-token" }, + adminPassword: { value: "invented-password" }, }, }); - expect(readStoredFile().coolify.previousAccessToken).toEqual({ - value: "1|kept-token", + expect(readStoredFile().coolify.adminPassword).toEqual({ + value: "invented-password", encryptionType: "plaintext", }); }); diff --git a/src/main/settings.ts b/src/main/settings.ts index 1f41f7e94f..86d0909558 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -413,14 +413,6 @@ export function writeSettings(settings: Partial): void { accessToken: encrypt(newSettings.coolify.accessToken.value), }; } - if (newSettings.coolify?.previousAccessToken) { - newSettings.coolify = { - ...newSettings.coolify, - previousAccessToken: encrypt( - newSettings.coolify.previousAccessToken.value, - ), - }; - } if (newSettings.coolify?.adminPassword) { newSettings.coolify = { ...newSettings.coolify, @@ -699,24 +691,6 @@ function readExistingSettingsFile( combinedSettings.coolify = rest; } } - if (combinedSettings.coolify?.previousAccessToken) { - const resolved = resolveStoredSecret( - combinedSettings.coolify.previousAccessToken, - "Coolify previous access token", - ["coolify", "previousAccessToken"], - ctx, - ); - if (resolved) { - combinedSettings.coolify = { - ...combinedSettings.coolify, - previousAccessToken: resolved, - }; - } else { - const { previousAccessToken: _dropped, ...rest } = - combinedSettings.coolify; - combinedSettings.coolify = rest; - } - } if (combinedSettings.coolify?.adminPassword) { const resolved = resolveStoredSecret( combinedSettings.coolify.adminPassword, From ef8a034b1c06e318fca24b9558a481a765602e00 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Sun, 23 Aug 2026 16:12:51 -0500 Subject: [PATCH 17/91] fix(coolify): hear about a run that ends while the panel is mounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup panel asked what was going on before it started listening, so a run that finished inside that round trip was never heard about, and the answer — true when it was asked, stale by the time it landed — became the panel's state: an installing step under a Cancel button for a run that was already over. Subscribes first, and counts what has landed so a read that was overtaken in flight defers to it. The same ordering useCoolifyDeploy documents and rules/state-machines.md asks for. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyServerSetup.test.tsx | 57 ++++++++++++++++++++++ src/components/CoolifyServerSetup.tsx | 41 ++++++++++++---- 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src/components/CoolifyServerSetup.test.tsx b/src/components/CoolifyServerSetup.test.tsx index b82f078134..8d1012ba49 100644 --- a/src/components/CoolifyServerSetup.test.tsx +++ b/src/components/CoolifyServerSetup.test.tsx @@ -398,6 +398,63 @@ describe("an answer about a server the user has moved on from", () => { }); }); +describe("catching up with a run this window did not start", () => { + it("is listening before it asks what is going on", async () => { + // The answer takes a round trip. A run that finishes inside it is only + // heard about if the listener was already there when the question left. + let listenersWhenAsked = -1; + h.snapshot.mockImplementation(async () => { + listenersWhenAsked = h.changedListeners.length; + return IDLE; + }); + renderPanel(); + + await waitFor(() => expect(h.snapshot).toHaveBeenCalled()); + expect(listenersWhenAsked).toBeGreaterThan(0); + }); + + it("does not let a late answer undo what it was told meanwhile", async () => { + // The read says "installing" because that was true when it was asked. By + // the time it lands the run is over, and letting it win would put the + // panel back on a step the run has left, under a Cancel button for + // something that is no longer going. + let answer: (state: unknown) => void = () => {}; + h.snapshot.mockReturnValue( + new Promise((resolve) => { + answer = resolve; + }), + ); + renderPanel(); + + await waitFor(() => expect(h.changedListeners.length).toBeGreaterThan(0)); + push({ + type: "done", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + result: { + dashboardUrl: "https://203.0.113.5.sslip.io", + secure: true, + insecureReason: null, + adminEmail: "me@gmail.com", + adminPassword: "Abc123@xyz", + tokenStored: true, + tokenUnavailableReason: null, + version: "4.3.2", + }, + }); + answer(runningState()); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-done")).toBeTruthy(), + ); + expect(screen.queryByTestId("coolify-setup-running")).toBeNull(); + }); +}); + describe("when the panel cannot tell what is going on", () => { it("says so, and asking again clears it", async () => { // Install is disabled while this is unknown, and a disabled control with diff --git a/src/components/CoolifyServerSetup.tsx b/src/components/CoolifyServerSetup.tsx index 6abfe0e283..108c6957fa 100644 --- a/src/components/CoolifyServerSetup.tsx +++ b/src/components/CoolifyServerSetup.tsx @@ -107,12 +107,43 @@ export function CoolifyServerSetup({ }); const publicKey = serverKey.data?.publicKey ?? null; + /** + * How many pushed states have landed, so a read can tell whether it was + * overtaken. Counted rather than flagged: the read has to compare against + * what it started with, and a refetch after an earlier event must not read + * as overtaken by that old one. + */ + const eventCount = useRef(0); + + // Pushed rather than polled, so the step and the log keep up with a run + // this window did not start. Subscribed before the read below asks, so a + // run that finishes mid-mount is not missed. + useEffect(() => { + return ipc.events.coolifySetup.onChanged((state) => { + eventCount.current += 1; + queryClient.setQueryData(queryKeys.coolify.setup, state); + }); + }, [queryClient]); + // What is going on is asked for, not remembered. An install outlives this // screen — leaving it is invited, and a background refetch can replace it — // so anything kept here would be lost exactly when it mattered. const snapshot = useQuery({ queryKey: queryKeys.coolify.setup, - queryFn: () => ipc.coolifySetup.snapshot(), + queryFn: async () => { + const before = eventCount.current; + const read = await ipc.coolifySetup.snapshot(); + // Overtaken while in flight. Answering with the read would put the + // panel back on a step the run has already left, and leave a Cancel + // button over a run that has finished until something refetches. + if (eventCount.current !== before) { + return ( + queryClient.getQueryData(queryKeys.coolify.setup) ?? + read + ); + } + return read; + }, }); const setup: SetupSnapshot = snapshot.data ?? { type: "idle" }; // What the machine allows, asked once and answered the same way the @@ -120,14 +151,6 @@ export function CoolifyServerSetup({ // could be read — stays below with the fields it is about. const can = selectCoolifySetupCapabilities(setup); - // Pushed rather than polled, so the step and the log keep up with a run - // this window did not start. - useEffect(() => { - return ipc.events.coolifySetup.onChanged((state) => { - queryClient.setQueryData(queryKeys.coolify.setup, state); - }); - }, [queryClient]); - useEffect(() => { logRef.current?.scrollTo({ top: logRef.current.scrollHeight }); }, [setup]); From 9edcc3625d0b5581b3299c582b6634e3e2f28e74 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Sun, 23 Aug 2026 16:25:24 -0500 Subject: [PATCH 18/91] refactor(coolify): keep the admin account as one thing in settings adminEmail, adminPassword and adminInstanceUrl were three optional fields that only ever meant anything together: an account without the address it opens is a password for nothing, and all three are written and forgotten at once. They are now one optional `admin` object with required members, so a half-account is unrepresentable. One behaviour follows from that. A password that will not decrypt used to leave the email and address behind; now the account goes with it, because an address and an email with nothing to sign in with is not the way into a machine this is kept to be. Co-Authored-By: Claude Opus 5 --- src/ipc/handlers/coolify_handlers.test.ts | 28 +++++----- .../handlers/coolify_setup_handlers.test.ts | 56 +++++++++++-------- src/ipc/handlers/coolify_setup_handlers.ts | 42 ++++++++------ src/lib/schemas.ts | 24 +++++--- src/main/settings.test.ts | 32 +++++++++-- src/main/settings.ts | 24 +++++--- 6 files changed, 130 insertions(+), 76 deletions(-) diff --git a/src/ipc/handlers/coolify_handlers.test.ts b/src/ipc/handlers/coolify_handlers.test.ts index fe0fd4b32f..e73d6c21ef 100644 --- a/src/ipc/handlers/coolify_handlers.test.ts +++ b/src/ipc/handlers/coolify_handlers.test.ts @@ -15,9 +15,7 @@ const settings: Record = {}; function storedCoolify() { return (settings.coolify ?? {}) as { accessToken?: { value: string }; - adminEmail?: string; - adminPassword?: { value: string }; - adminInstanceUrl?: string; + admin?: { email: string; password: { value: string }; instanceUrl: string }; }; } const rows: Record[] = []; @@ -257,9 +255,11 @@ describe("clearing the token", () => { // shows all of it and asks the user to confirm before it goes. settings.coolify = { ...(settings.coolify as Record), - adminEmail: "me@gmail.com", - adminPassword: { value: "Abc123@xyz" }, - adminInstanceUrl: "https://coolify.example.com", + admin: { + email: "me@gmail.com", + password: { value: "Abc123@xyz" }, + instanceUrl: "https://coolify.example.com", + }, }; await call("coolify:clear-token"); @@ -338,9 +338,11 @@ describe("the admin account Dyad created", () => { beforeEach(() => { settings.coolify = { ...(settings.coolify as Record), - adminEmail: "me@gmail.com", - adminPassword: { value: "Abc123@xyz" }, - adminInstanceUrl: "https://coolify.example.com", + admin: { + email: "me@gmail.com", + password: { value: "Abc123@xyz" }, + instanceUrl: "https://coolify.example.com", + }, }; }); @@ -354,8 +356,8 @@ describe("the admin account Dyad created", () => { acknowledgedInsecure: false, }); - expect(storedCoolify().adminEmail).toBe("me@gmail.com"); - expect(storedCoolify().adminPassword?.value).toBe("Abc123@xyz"); + expect(storedCoolify().admin?.email).toBe("me@gmail.com"); + expect(storedCoolify().admin?.password.value).toBe("Abc123@xyz"); }); it("goes when the instance is forgotten", async () => { @@ -363,9 +365,7 @@ describe("the admin account Dyad created", () => { // would leave a password for a Coolify nothing is connected to. await call("coolify:clear-token"); - expect(storedCoolify().adminEmail).toBeUndefined(); - expect(storedCoolify().adminPassword).toBeUndefined(); - expect(storedCoolify().adminInstanceUrl).toBeUndefined(); + expect(storedCoolify().admin).toBeUndefined(); }); }); diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts index fabd256e82..4466430847 100644 --- a/src/ipc/handlers/coolify_setup_handlers.test.ts +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -381,9 +381,9 @@ describe("run", () => { await expect(checkThenRun()).rejects.toThrow("exit 1"); const saved = h.written.at(-1) as { - coolify: { adminPassword: { value: string } }; + coolify: { admin: { password: { value: string } } }; }; - expect(saved.coolify.adminPassword.value).toBe("Abc123@xyz"); + expect(saved.coolify.admin.password.value).toBe("Abc123@xyz"); }); it("does not put back an address a later write replaced", async () => { @@ -398,9 +398,11 @@ describe("run", () => { await expect(checkThenRun()).rejects.toThrow("exit 1"); const saved = h.written.at(-1) as { - coolify: { adminInstanceUrl: string }; + coolify: { admin: { instanceUrl: string } }; }; - expect(saved.coolify.adminInstanceUrl).toBe("https://203.0.113.5.sslip.io"); + expect(saved.coolify.admin.instanceUrl).toBe( + "https://203.0.113.5.sslip.io", + ); }); it("reports what went wrong, not what the retry did", async () => { @@ -445,10 +447,10 @@ describe("run", () => { // token but not this leaves them unable to sign in to their own server. await checkThenRun(); const saved = h.written.at(-1) as { - coolify: { adminPassword?: { value: string }; adminEmail?: string }; + coolify: { admin?: { password: { value: string }; email: string } }; }; - expect(saved.coolify.adminPassword?.value).toBe("Abc123@xyz"); - expect(saved.coolify.adminEmail).toBe("me@gmail.com"); + expect(saved.coolify.admin?.password.value).toBe("Abc123@xyz"); + expect(saved.coolify.admin?.email).toBe("me@gmail.com"); }); it("records which instance the account is on", async () => { @@ -456,9 +458,9 @@ describe("run", () => { // does not come along. await checkThenRun(); const saved = h.written.at(-1) as { - coolify: { adminInstanceUrl?: string }; + coolify: { admin?: { instanceUrl: string } }; }; - expect(saved.coolify.adminInstanceUrl).toBe("http://203.0.113.5:8000"); + expect(saved.coolify.admin?.instanceUrl).toBe("http://203.0.113.5:8000"); }); it("returns the password so it can be shown once", async () => { @@ -484,12 +486,12 @@ describe("run", () => { // password away here would take away the only way to do it. const saved = h.written.at(-1) as { coolify: { - adminPassword?: { value: string }; + admin?: { password: { value: string } }; accessToken?: unknown; instanceUrl?: string; }; }; - expect(saved.coolify.adminPassword?.value).toBe("Abc123@xyz"); + expect(saved.coolify.admin?.password.value).toBe("Abc123@xyz"); // No token and no address, because there is no instance Dyad can talk to. expect(saved.coolify.accessToken).toBeUndefined(); expect(saved.coolify.instanceUrl).toBeUndefined(); @@ -505,10 +507,12 @@ describe("run", () => { await checkThenRun().catch(() => {}); const saved = h.written.at(-1) as { - coolify: { adminPassword?: { value: string }; adminInstanceUrl?: string }; + coolify: { + admin?: { password: { value: string }; instanceUrl: string }; + }; }; - expect(saved.coolify.adminPassword?.value).toBe("Abc123@xyz"); - expect(saved.coolify.adminInstanceUrl).toBe("http://203.0.113.5:8000"); + expect(saved.coolify.admin?.password.value).toBe("Abc123@xyz"); + expect(saved.coolify.admin?.instanceUrl).toBe("http://203.0.113.5:8000"); }); it("writes nothing when the failure came before any account", async () => { @@ -598,9 +602,11 @@ describe("revealCredentials", () => { coolify: { instanceUrl: "http://203.0.113.5:8000", accessToken: { value: "1|abc" }, - adminEmail: "me@gmail.com", - adminPassword: { value: "Abc123@xyz" }, - adminInstanceUrl: "http://203.0.113.5:8000", + admin: { + email: "me@gmail.com", + password: { value: "Abc123@xyz" }, + instanceUrl: "http://203.0.113.5:8000", + }, }, }; const result = (await call("coolify-setup:reveal-credentials")) as Record< @@ -620,9 +626,11 @@ describe("revealCredentials", () => { // still has to know which machine these open. h.settings = { coolify: { - adminEmail: "me@gmail.com", - adminPassword: { value: "Abc123@xyz" }, - adminInstanceUrl: "http://203.0.113.5:8000", + admin: { + email: "me@gmail.com", + password: { value: "Abc123@xyz" }, + instanceUrl: "http://203.0.113.5:8000", + }, }, }; const result = (await call("coolify-setup:reveal-credentials")) as Record< @@ -641,9 +649,11 @@ describe("revealCredentials", () => { coolify: { instanceUrl: "https://coolify.example.com", accessToken: { value: "1|abc" }, - adminEmail: "me@gmail.com", - adminPassword: { value: "Abc123@xyz" }, - adminInstanceUrl: "http://203.0.113.5:8000", + admin: { + email: "me@gmail.com", + password: { value: "Abc123@xyz" }, + instanceUrl: "http://203.0.113.5:8000", + }, }, }; const result = (await call("coolify-setup:reveal-credentials")) as Record< diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index 3cfef3eb1b..7652274815 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -118,9 +118,11 @@ function setupController(): CoolifySetupController { writeSettings({ coolify: { ...readSettings().coolify, - adminEmail: credentials.email, - adminPassword: { value: credentials.password }, - adminInstanceUrl: dashboardUrl, + admin: { + email: credentials.email, + password: { value: credentials.password }, + instanceUrl: dashboardUrl, + }, }, }); unsavedAccount = null; @@ -143,11 +145,13 @@ function setupController(): CoolifySetupController { writeSettings({ coolify: { ...readSettings().coolify, - adminEmail: unsavedAccount.credentials.email, - adminPassword: { - value: unsavedAccount.credentials.password, + admin: { + email: unsavedAccount.credentials.email, + password: { + value: unsavedAccount.credentials.password, + }, + instanceUrl: unsavedAccount.dashboardUrl, }, - adminInstanceUrl: unsavedAccount.dashboardUrl, }, }); } catch (retryError) { @@ -180,12 +184,13 @@ function setupController(): CoolifySetupController { writeSettings({ coolify: { ...readSettings().coolify, - adminEmail: result.credentials.email, - adminPassword: { value: result.credentials.password }, - // Stored even when no token was minted: it names the server this - // account is on, which is how connecting elsewhere later knows - // the account does not come along. - adminInstanceUrl: result.dashboardUrl, + admin: { + email: result.credentials.email, + password: { value: result.credentials.password }, + // Stored even when no token was minted, because then it is + // the only thing naming the server this account is on. + instanceUrl: result.dashboardUrl, + }, // The address and token go together: an address stored without a // token would read as an instance Dyad can talk to and cannot. // Stored without the acknowledgement `coolify:save-token` @@ -417,12 +422,13 @@ export function registerCoolifySetupHandlers() { // time and signing out forgets all of it together. So the fields are read // straight out rather than checked against each other for whose they are. // - // adminInstanceUrl covers the window before a token exists: a server just - // installed is named by the account Dyad made on it and nothing else. + // The account's own address covers the window before a token exists: a + // server just installed is named by the account Dyad made on it and + // nothing else. return { - dashboardUrl: coolify?.instanceUrl ?? coolify?.adminInstanceUrl ?? null, - adminEmail: coolify?.adminEmail ?? null, - adminPassword: coolify?.adminPassword?.value ?? null, + dashboardUrl: coolify?.instanceUrl ?? coolify?.admin?.instanceUrl ?? null, + adminEmail: coolify?.admin?.email ?? null, + adminPassword: coolify?.admin?.password?.value ?? null, apiToken: coolify?.accessToken?.value ?? null, }; }); diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index ef5adeb121..dce8382068 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -218,6 +218,18 @@ export type SupabaseOrganizationCredentials = z.infer< * where lives in the coolify_app_connections table, not here — this is the * instance, and it is instance-wide. */ +export const CoolifyAdminSchema = z.object({ + email: z.string(), + password: SecretSchema, + /** + * The address this account belongs to. + * + * Written before there is a token to talk to the instance with, which is + * the window where nothing else names the machine the account opens. + */ + instanceUrl: z.string(), +}); + export const CoolifySchema = z.object({ instanceUrl: z.string().optional(), accessToken: SecretSchema.optional(), @@ -228,16 +240,12 @@ export const CoolifySchema = z.object({ * own machine — showing it once and forgetting it leaves them locked out of * a server they own. Encrypted like the token, and only ever handed to the * renderer when it is asked for. - */ - adminEmail: z.string().optional(), - adminPassword: SecretSchema.optional(), - /** - * The address the admin account above belongs to. * - * Set before there is a token to talk to the instance with, which is the - * window where nothing else here names the machine the account opens. + * One object rather than three fields, because the three are only ever + * meaningful together: an account without the address it opens is a + * password for nothing, and all three are written and forgotten at once. */ - adminInstanceUrl: z.string().optional(), + admin: CoolifyAdminSchema.optional(), }); export type Coolify = z.infer; diff --git a/src/main/settings.test.ts b/src/main/settings.test.ts index e904b24b25..695bcb2e29 100644 --- a/src/main/settings.test.ts +++ b/src/main/settings.test.ts @@ -1141,16 +1141,40 @@ describe("preserving undecryptable secrets", () => { writeSettings({ coolify: { instanceUrl: "http://203.0.113.5:8000", - adminPassword: { value: "invented-password" }, + admin: { + email: "me@gmail.com", + password: { value: "invented-password" }, + instanceUrl: "http://203.0.113.5:8000", + }, }, }); - expect(readStoredFile().coolify.adminPassword).toEqual({ - value: "invented-password", - encryptionType: "plaintext", + expect(readStoredFile().coolify.admin).toEqual({ + email: "me@gmail.com", + password: { value: "invented-password", encryptionType: "plaintext" }, + instanceUrl: "http://203.0.113.5:8000", }); }); + it("drops the whole admin account when its password will not decrypt", () => { + // An address and an email with nothing to sign in with is not a way into + // anything, and this is kept to be one. + store[mockSettingsPath] = JSON.stringify({ + coolify: { + instanceUrl: "http://203.0.113.5:8000", + admin: { + email: "me@gmail.com", + password: lockedSecret("coolify"), + instanceUrl: "http://203.0.113.5:8000", + }, + }, + }); + + const read = readSettings(); + expect(read.coolify?.admin).toBeUndefined(); + expect(read.coolify?.instanceUrl).toBe("http://203.0.113.5:8000"); + }); + it("preserves a locked provider apiKey when a write rebuilds providerSettings without it", () => { const locked = lockedSecret("openai"); store[mockSettingsPath] = JSON.stringify({ diff --git a/src/main/settings.ts b/src/main/settings.ts index 86d0909558..eae2c6b39a 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -413,10 +413,13 @@ export function writeSettings(settings: Partial): void { accessToken: encrypt(newSettings.coolify.accessToken.value), }; } - if (newSettings.coolify?.adminPassword) { + if (newSettings.coolify?.admin) { newSettings.coolify = { ...newSettings.coolify, - adminPassword: encrypt(newSettings.coolify.adminPassword.value), + admin: { + ...newSettings.coolify.admin, + password: encrypt(newSettings.coolify.admin.password.value), + }, }; } if (newSettings.supabase) { @@ -691,22 +694,25 @@ function readExistingSettingsFile( combinedSettings.coolify = rest; } } - if (combinedSettings.coolify?.adminPassword) { + if (combinedSettings.coolify?.admin) { + const admin = combinedSettings.coolify.admin; const resolved = resolveStoredSecret( - combinedSettings.coolify.adminPassword, + admin.password, "Coolify admin password", - ["coolify", "adminPassword"], + ["coolify", "admin", "password"], ctx, ); if (resolved) { combinedSettings.coolify = { ...combinedSettings.coolify, - adminPassword: resolved, + admin: { ...admin, password: resolved }, }; } else { - // Dropped rather than kept as ciphertext nobody can read. The password - // still exists on the server's own .env, which is the honest fallback. - const { adminPassword: _dropped, ...rest } = combinedSettings.coolify; + // The whole account goes, not just the password. Dyad keeps this to be + // the way into a machine the user owns, and an address and an email + // with nothing to sign in with is not one. The password still exists in + // the server's own .env, which is the honest fallback. + const { admin: _dropped, ...rest } = combinedSettings.coolify; combinedSettings.coolify = rest; } } From ec6bdf8b4f293d3fc012f454eb9ccfd76549a598 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Sun, 23 Aug 2026 16:50:38 -0500 Subject: [PATCH 19/91] fix(coolify): stop a locked admin password from breaking every settings write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grouping the admin fields moved the password to coolify.admin.password, and the machinery that preserves a secret it cannot decrypt keys off that path. Two things broke. The encryption pass guarded on the account but reached for the password. Preservation deletes the password from the merged settings before that runs, meaning to write the ciphertext back verbatim — so the account arrived with none, and every settings write threw, Coolify or not. The re-injection only puts a preserved secret back when its container is still there. Dropping the whole account on a failed decrypt took the container with it, so the next write of coolify rebuilt from readSettings read as the user having deleted the account, and the ciphertext a repaired keychain could still open was lost. The password is optional within the account again, and only it is dropped. Also gives the two panels that read the setup snapshot one hook. They share a query key, React Query runs whichever observer's queryFn it picked, and only one of the two had the guard added in a706a62c. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyConnector.test.tsx | 1 + src/components/CoolifyConnector.tsx | 8 +-- src/components/CoolifyServerSetup.tsx | 40 +-------------- src/hooks/useCoolifySetupSnapshot.ts | 59 ++++++++++++++++++++++ src/lib/schemas.ts | 12 +++-- src/main/settings.test.ts | 64 ++++++++++++++++++++++-- src/main/settings.ts | 29 ++++++----- 7 files changed, 149 insertions(+), 64 deletions(-) create mode 100644 src/hooks/useCoolifySetupSnapshot.ts diff --git a/src/components/CoolifyConnector.test.tsx b/src/components/CoolifyConnector.test.tsx index 236038649c..f3792c276d 100644 --- a/src/components/CoolifyConnector.test.tsx +++ b/src/components/CoolifyConnector.test.tsx @@ -114,6 +114,7 @@ vi.mock("@/ipc/types", () => ({ ipc: { system: { openExternalUrl: vi.fn() }, coolifySetup: { snapshot: () => Promise.resolve(setup.state) }, + events: { coolifySetup: { onChanged: () => () => {} } }, }, })); diff --git a/src/components/CoolifyConnector.tsx b/src/components/CoolifyConnector.tsx index c5a187281d..5d3e3a5b14 100644 --- a/src/components/CoolifyConnector.tsx +++ b/src/components/CoolifyConnector.tsx @@ -1,6 +1,4 @@ import { useEffect, useId, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { queryKeys } from "@/lib/queryKeys"; import { ExternalLink, Loader2, RefreshCw } from "lucide-react"; import { toast } from "sonner"; import { Button, buttonVariants } from "@/components/ui/button"; @@ -28,6 +26,7 @@ import { import { ipc } from "@/ipc/types"; import type { SetupSnapshot } from "@/ipc/types"; import { CoolifyServerSetup } from "@/components/CoolifyServerSetup"; +import { useCoolifySetupSnapshot } from "@/hooks/useCoolifySetupSnapshot"; import { CoolifyCredentials } from "@/components/CoolifyCredentials"; import { CoolifySignOutDialog } from "@/components/CoolifySignOutDialog"; import { useLoadApp } from "@/hooks/useLoadApp"; @@ -103,10 +102,7 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { // What the main process is doing with a server, if anything. Asked for // rather than remembered: an install outlives this panel, and the panel // being replaced — by a spinner, by a status error — must not lose it. - const { data: setupSnapshot } = useQuery({ - queryKey: queryKeys.coolify.setup, - queryFn: () => ipc.coolifySetup.snapshot(), - }); + const { data: setupSnapshot } = useCoolifySetupSnapshot(); const serverSelectId = useId(); const projectSelectId = useId(); diff --git a/src/components/CoolifyServerSetup.tsx b/src/components/CoolifyServerSetup.tsx index 108c6957fa..df67c72a3a 100644 --- a/src/components/CoolifyServerSetup.tsx +++ b/src/components/CoolifyServerSetup.tsx @@ -14,6 +14,7 @@ import type { } from "@/ipc/types"; import { showError } from "@/lib/toast"; import { queryKeys } from "@/lib/queryKeys"; +import { useCoolifySetupSnapshot } from "@/hooks/useCoolifySetupSnapshot"; import { isPlausibleAdminEmail } from "@/shared/coolify_admin_email"; import { isPlausibleInstanceDomain } from "@/shared/coolify_domain"; import { selectCoolifySetupCapabilities } from "@/coolify_setup/capabilities"; @@ -107,44 +108,7 @@ export function CoolifyServerSetup({ }); const publicKey = serverKey.data?.publicKey ?? null; - /** - * How many pushed states have landed, so a read can tell whether it was - * overtaken. Counted rather than flagged: the read has to compare against - * what it started with, and a refetch after an earlier event must not read - * as overtaken by that old one. - */ - const eventCount = useRef(0); - - // Pushed rather than polled, so the step and the log keep up with a run - // this window did not start. Subscribed before the read below asks, so a - // run that finishes mid-mount is not missed. - useEffect(() => { - return ipc.events.coolifySetup.onChanged((state) => { - eventCount.current += 1; - queryClient.setQueryData(queryKeys.coolify.setup, state); - }); - }, [queryClient]); - - // What is going on is asked for, not remembered. An install outlives this - // screen — leaving it is invited, and a background refetch can replace it — - // so anything kept here would be lost exactly when it mattered. - const snapshot = useQuery({ - queryKey: queryKeys.coolify.setup, - queryFn: async () => { - const before = eventCount.current; - const read = await ipc.coolifySetup.snapshot(); - // Overtaken while in flight. Answering with the read would put the - // panel back on a step the run has already left, and leave a Cancel - // button over a run that has finished until something refetches. - if (eventCount.current !== before) { - return ( - queryClient.getQueryData(queryKeys.coolify.setup) ?? - read - ); - } - return read; - }, - }); + const snapshot = useCoolifySetupSnapshot(); const setup: SetupSnapshot = snapshot.data ?? { type: "idle" }; // What the machine allows, asked once and answered the same way the // transition would. What the form allows — a usable address, a key that diff --git a/src/hooks/useCoolifySetupSnapshot.ts b/src/hooks/useCoolifySetupSnapshot.ts new file mode 100644 index 0000000000..a179a5691e --- /dev/null +++ b/src/hooks/useCoolifySetupSnapshot.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ipc } from "@/ipc/types"; +import { queryKeys } from "@/lib/queryKeys"; +import type { SetupSnapshot } from "@/ipc/types/coolify_setup"; + +/** + * Renderer binding for the Coolify setup machine. + * + * The snapshot is owned by the main process. Subscribing happens before the + * initial read so a run that finishes mid-mount is not missed, and a + * late-arriving read never overwrites a state already pushed. + * + * One hook rather than a query in each panel that wants it. Two components + * read this at once, and React Query runs whichever observer's queryFn it + * picked for the key — so a plain one anywhere is a plain one everywhere, + * and the guard below would be bypassed by the copy that did not have it. + */ +export function useCoolifySetupSnapshot() { + const queryClient = useQueryClient(); + + /** + * How many pushed states have landed, so a read can tell whether it was + * overtaken. Counted rather than flagged: the read compares against what it + * started with, and a refetch after an earlier event must not read as + * overtaken by that old one. + */ + const eventCount = useRef(0); + + // Pushed rather than polled, so the step and the log keep up with a run + // this window did not start. + useEffect(() => { + return ipc.events.coolifySetup.onChanged((state) => { + eventCount.current += 1; + queryClient.setQueryData(queryKeys.coolify.setup, state); + }); + }, [queryClient]); + + // What is going on is asked for, not remembered. An install outlives the + // screen — leaving it is invited, and a background refetch can replace it — + // so anything kept here would be lost exactly when it mattered. + return useQuery({ + queryKey: queryKeys.coolify.setup, + queryFn: async () => { + const before = eventCount.current; + const read = await ipc.coolifySetup.snapshot(); + // Overtaken while in flight. Answering with the read would put the + // panel back on a step the run has already left, and leave a Cancel + // button over a run that has finished until something refetches. + if (eventCount.current !== before) { + return ( + queryClient.getQueryData(queryKeys.coolify.setup) ?? + read + ); + } + return read; + }, + }); +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index dce8382068..1b23357404 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -220,7 +220,13 @@ export type SupabaseOrganizationCredentials = z.infer< */ export const CoolifyAdminSchema = z.object({ email: z.string(), - password: SecretSchema, + /** + * Optional only because it can become unreadable, never because it was not + * written: a keychain that cannot open it leaves the account behind without + * it. Required here instead would make the account itself vanish, and the + * ciphertext on disk has nowhere to be put back into once it has. + */ + password: SecretSchema.optional(), /** * The address this account belongs to. * @@ -241,9 +247,9 @@ export const CoolifySchema = z.object({ * a server they own. Encrypted like the token, and only ever handed to the * renderer when it is asked for. * - * One object rather than three fields, because the three are only ever + * One object rather than three fields, because they are only ever * meaningful together: an account without the address it opens is a - * password for nothing, and all three are written and forgotten at once. + * password for nothing, and they are written and forgotten at once. */ admin: CoolifyAdminSchema.optional(), }); diff --git a/src/main/settings.test.ts b/src/main/settings.test.ts index 695bcb2e29..84b8dbbf49 100644 --- a/src/main/settings.test.ts +++ b/src/main/settings.test.ts @@ -1156,9 +1156,62 @@ describe("preserving undecryptable secrets", () => { }); }); - it("drops the whole admin account when its password will not decrypt", () => { - // An address and an email with nothing to sign in with is not a way into - // anything, and this is kept to be one. + it("keeps a locked Coolify admin password across an unrelated write", () => { + // The preservation path hands the ciphertext back after encryption, having + // deleted it from the merged settings first. Reading the account and then + // reaching for its password without checking finds nothing there — and + // every settings write, Coolify or not, dies on it. + const locked = lockedSecret("coolify"); + store[mockSettingsPath] = JSON.stringify({ + coolify: { + instanceUrl: "http://203.0.113.5:8000", + admin: { + email: "me@gmail.com", + password: locked, + instanceUrl: "http://203.0.113.5:8000", + }, + }, + }); + + writeSettings({ enableAutoUpdate: false }); + + expect(readStoredFile().enableAutoUpdate).toBe(false); + expect(readStoredFile().coolify.admin.password).toEqual(locked); + }); + + it("keeps a locked admin password when a Coolify write rebuilds the object without it", () => { + // What `coolify:save-token` does: spread what readSettings could decrypt, + // then add the token. The account came back without its password, so + // without the container still standing there is nowhere to put the + // ciphertext back and the only copy of it goes. + const locked = lockedSecret("coolify"); + store[mockSettingsPath] = JSON.stringify({ + coolify: { + instanceUrl: "http://203.0.113.5:8000", + admin: { + email: "me@gmail.com", + password: locked, + instanceUrl: "http://203.0.113.5:8000", + }, + }, + }); + + writeSettings({ + coolify: { + ...readSettings().coolify, + instanceUrl: "http://203.0.113.5:8000", + accessToken: { value: "1|fresh" }, + }, + }); + + expect(readStoredFile().coolify.admin.password).toEqual(locked); + expect(readStoredFile().coolify.admin.email).toBe("me@gmail.com"); + }); + + it("hides an admin password that will not decrypt, keeping the account", () => { + // Handing the ciphertext through would put it on screen as the password, + // and it would not open anything. The account it belongs to stays, so a + // later write still has somewhere to put the ciphertext back. store[mockSettingsPath] = JSON.stringify({ coolify: { instanceUrl: "http://203.0.113.5:8000", @@ -1171,8 +1224,9 @@ describe("preserving undecryptable secrets", () => { }); const read = readSettings(); - expect(read.coolify?.admin).toBeUndefined(); - expect(read.coolify?.instanceUrl).toBe("http://203.0.113.5:8000"); + expect(read.coolify?.admin?.password).toBeUndefined(); + expect(read.coolify?.admin?.email).toBe("me@gmail.com"); + expect(read.coolify?.admin?.instanceUrl).toBe("http://203.0.113.5:8000"); }); it("preserves a locked provider apiKey when a write rebuilds providerSettings without it", () => { diff --git a/src/main/settings.ts b/src/main/settings.ts index eae2c6b39a..987a6befad 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -413,12 +413,16 @@ export function writeSettings(settings: Partial): void { accessToken: encrypt(newSettings.coolify.accessToken.value), }; } - if (newSettings.coolify?.admin) { + // Guarded on the password rather than the account, because the two do not + // arrive together: the preservation pass above strips a password it means + // to write back verbatim, leaving the account here with none. + const coolifyAdmin = newSettings.coolify?.admin; + if (coolifyAdmin?.password) { newSettings.coolify = { ...newSettings.coolify, admin: { - ...newSettings.coolify.admin, - password: encrypt(newSettings.coolify.admin.password.value), + ...coolifyAdmin, + password: encrypt(coolifyAdmin.password.value), }, }; } @@ -694,10 +698,11 @@ function readExistingSettingsFile( combinedSettings.coolify = rest; } } - if (combinedSettings.coolify?.admin) { - const admin = combinedSettings.coolify.admin; + const admin = combinedSettings.coolify?.admin; + const adminPassword = admin?.password; + if (admin && adminPassword) { const resolved = resolveStoredSecret( - admin.password, + adminPassword, "Coolify admin password", ["coolify", "admin", "password"], ctx, @@ -708,12 +713,12 @@ function readExistingSettingsFile( admin: { ...admin, password: resolved }, }; } else { - // The whole account goes, not just the password. Dyad keeps this to be - // the way into a machine the user owns, and an address and an email - // with nothing to sign in with is not one. The password still exists in - // the server's own .env, which is the honest fallback. - const { admin: _dropped, ...rest } = combinedSettings.coolify; - combinedSettings.coolify = rest; + // Only the password. The account stays so that the ciphertext has a + // container to be written back into on the next write — dropping it + // whole reads to the write path as the user having deleted the account, + // and throws away a password a repaired keychain could still open. + const { password: _dropped, ...rest } = admin; + combinedSettings.coolify = { ...combinedSettings.coolify, admin: rest }; } } for (const provider in combinedSettings.providerSettings) { From 48c3b542ad710b1af0a0d318cb323235cb76faa1 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Sun, 23 Aug 2026 17:13:45 -0500 Subject: [PATCH 20/91] fix(coolify): actually forget a token that will not decrypt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signing out wrote an empty coolify object. writeSettings reads an absent key as one some consumer read could not decrypt rather than as a clear, and puts the stored ciphertext back whenever its container is still there — and an empty object is still a container. So the one write whose whole job is to forget the instance handed the token straight back, leaving a token with no address behind it: a state nothing else produces, and one the user had just ticked a box to prevent. FORGOTTEN_COOLIFY names every field instead, which is what reads as a deliberate clear. It is typed off CoolifySchema, so a field added later stops compiling until it is named there too — the property the empty object was chosen for in the first place. Also drops the sign-out dialog's isPending. Its button is a Close, so it is gone before a pending state could show; the button that opened it already carries that. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyConnector.tsx | 1 - src/components/CoolifySignOutDialog.tsx | 10 ++++------ src/ipc/handlers/coolify_handlers.ts | 10 +++++++--- src/lib/schemas.ts | 19 +++++++++++++++++++ src/main/settings.test.ts | 20 +++++++++++++++++++- 5 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/components/CoolifyConnector.tsx b/src/components/CoolifyConnector.tsx index 5d3e3a5b14..53ad51d82e 100644 --- a/src/components/CoolifyConnector.tsx +++ b/src/components/CoolifyConnector.tsx @@ -481,7 +481,6 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { { try { await clearToken.mutateAsync(); diff --git a/src/components/CoolifySignOutDialog.tsx b/src/components/CoolifySignOutDialog.tsx index 13e9bc8ab6..4cad297996 100644 --- a/src/components/CoolifySignOutDialog.tsx +++ b/src/components/CoolifySignOutDialog.tsx @@ -27,12 +27,10 @@ export function CoolifySignOutDialog({ open, onOpenChange, onConfirm, - isPending, }: { open: boolean; onOpenChange: (open: boolean) => void; onConfirm: () => void; - isPending?: boolean; }) { const [acknowledged, setAcknowledged] = useState(false); @@ -79,10 +77,10 @@ export function CoolifySignOutDialog({ Cancel - + {/* Closes as it fires — it is a Close underneath — so there is no + pending state to show here. The button that opened this stays on + screen and carries that. */} + Sign out diff --git a/src/ipc/handlers/coolify_handlers.ts b/src/ipc/handlers/coolify_handlers.ts index 3b705a5885..b7a69aa173 100644 --- a/src/ipc/handlers/coolify_handlers.ts +++ b/src/ipc/handlers/coolify_handlers.ts @@ -7,6 +7,7 @@ import { apps } from "../../db/schema"; import { resolveBoth } from "../utils/dns_resolve"; import { readSettings, writeSettings } from "../../main/settings"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { FORGOTTEN_COOLIFY } from "@/lib/schemas"; import { getClient, readConnectionState, @@ -168,8 +169,11 @@ export function registerCoolifyHandlers() { // shows all of it one last time and asks the user to confirm, because // Dyad invented the password and is the only thing that has it. // - // Replaced rather than spread: this is the handler that forgets the - // instance, so a field added to CoolifySchema later should go too. + // Every field named rather than an empty object: an absent key reads to + // writeSettings as one a consumer read could not decrypt, and it hands + // the ciphertext back — so an empty coolify would return the token this + // is here to forget. FORGOTTEN_COOLIFY names them all and stops + // compiling when CoolifySchema grows one more. // // The apps' rows are not touched. They read as disconnected without a // token anyway, and they carry each app's Coolify application id — the @@ -177,7 +181,7 @@ export function registerCoolifyHandlers() { // deploy build a second application beside the one already running and // lose a fight with it over the domain. coolifyDeployRegistry.cancelAll(); - writeSettings({ coolify: {} }); + writeSettings({ coolify: FORGOTTEN_COOLIFY }); }); createTypedHandler(coolifyContracts.createProject, async (_, { name }) => { diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 1b23357404..217695dfec 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -255,6 +255,25 @@ export const CoolifySchema = z.object({ }); export type Coolify = z.infer; +/** + * Every field of a Coolify, named and empty. + * + * writeSettings reads an absent key as a field some consumer read could not + * decrypt and hands the stored ciphertext back, so forgetting an instance by + * writing an empty object returns the token instead of clearing it. Only a + * key that is present and undefined reads as a deliberate clear. + * + * Typed so that a field added to CoolifySchema later fails to compile until + * it is named here too, which is what an empty object was reaching for. + */ +export const FORGOTTEN_COOLIFY: { + [K in keyof Required]: undefined; +} = { + instanceUrl: undefined, + accessToken: undefined, + admin: undefined, +}; + export const SupabaseSchema = z.object({ // Map keyed by organizationSlug -> organization credentials organizations: z diff --git a/src/main/settings.test.ts b/src/main/settings.test.ts index 84b8dbbf49..606ba3a03e 100644 --- a/src/main/settings.test.ts +++ b/src/main/settings.test.ts @@ -21,7 +21,7 @@ import { rewriteRecoveredSafeStorageSecretsAfterKeychainUnlock, } from "@/main/settings"; import { getUserDataPath } from "@/paths/paths"; -import { UserSettings } from "@/lib/schemas"; +import { FORGOTTEN_COOLIFY, UserSettings } from "@/lib/schemas"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { getRemoteDesktopConfig } from "@/ipc/shared/remote_desktop_config"; import { @@ -1156,6 +1156,24 @@ describe("preserving undecryptable secrets", () => { }); }); + it("forgets a Coolify token that will not decrypt when signing out", () => { + // The preservation pass puts a secret back whenever the container it + // lives in is still there, treating an absent key as a consumer read that + // could not decrypt it rather than as a clear. An empty coolify object is + // still a container, so the one write whose whole job is to forget the + // instance would hand the token straight back. + store[mockSettingsPath] = JSON.stringify({ + coolify: { + instanceUrl: "http://203.0.113.5:8000", + accessToken: lockedSecret("coolify"), + }, + }); + + writeSettings({ coolify: FORGOTTEN_COOLIFY }); + + expect(readStoredFile().coolify).toEqual({}); + }); + it("keeps a locked Coolify admin password across an unrelated write", () => { // The preservation path hands the ciphertext back after encryption, having // deleted it from the merged settings first. Reading the account and then From b8092ef04f840d24e536390ce92fbba4ceb55777 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Sun, 23 Aug 2026 17:32:22 -0500 Subject: [PATCH 21/91] fix(coolify): keep the overtaken-read count from belonging to one caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React Query keeps one set of options per key, taken from whichever observer last fetched. Both panels that read the setup snapshot call this hook, and the setup panel unmounts while the connector stays — so the count deciding whether a read was overtaken could be the one belonging to a caller that is gone and no longer counting. Shared now, since only its changing matters. Co-Authored-By: Claude Opus 5 --- src/hooks/useCoolifySetupSnapshot.ts | 30 +++++++++++++++++----------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/hooks/useCoolifySetupSnapshot.ts b/src/hooks/useCoolifySetupSnapshot.ts index a179a5691e..265b260bd0 100644 --- a/src/hooks/useCoolifySetupSnapshot.ts +++ b/src/hooks/useCoolifySetupSnapshot.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ipc } from "@/ipc/types"; import { queryKeys } from "@/lib/queryKeys"; @@ -16,22 +16,28 @@ import type { SetupSnapshot } from "@/ipc/types/coolify_setup"; * picked for the key — so a plain one anywhere is a plain one everywhere, * and the guard below would be bypassed by the copy that did not have it. */ +/** + * How many pushed states have landed, so a read can tell whether it was + * overtaken. Counted rather than flagged: the read compares against what it + * started with, and a refetch after an earlier event must not read as + * overtaken by that old one. + * + * Shared rather than held per caller. React Query keeps one set of options + * per key, taken from whichever observer last fetched — and that caller can + * unmount while another still reads the query, leaving a count that has + * stopped advancing to decide whether a read was overtaken. Only its changing + * matters, so several callers counting the same event is not a problem. + */ +let eventCount = 0; + export function useCoolifySetupSnapshot() { const queryClient = useQueryClient(); - /** - * How many pushed states have landed, so a read can tell whether it was - * overtaken. Counted rather than flagged: the read compares against what it - * started with, and a refetch after an earlier event must not read as - * overtaken by that old one. - */ - const eventCount = useRef(0); - // Pushed rather than polled, so the step and the log keep up with a run // this window did not start. useEffect(() => { return ipc.events.coolifySetup.onChanged((state) => { - eventCount.current += 1; + eventCount += 1; queryClient.setQueryData(queryKeys.coolify.setup, state); }); }, [queryClient]); @@ -42,12 +48,12 @@ export function useCoolifySetupSnapshot() { return useQuery({ queryKey: queryKeys.coolify.setup, queryFn: async () => { - const before = eventCount.current; + const before = eventCount; const read = await ipc.coolifySetup.snapshot(); // Overtaken while in flight. Answering with the read would put the // panel back on a step the run has already left, and leave a Cancel // button over a run that has finished until something refetches. - if (eventCount.current !== before) { + if (eventCount !== before) { return ( queryClient.getQueryData(queryKeys.coolify.setup) ?? read From 197d6bf8077255c6ed45933ad99cacd2d0f825a2 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Sun, 23 Aug 2026 18:35:28 -0500 Subject: [PATCH 22/91] fix(coolify): keep each Coolify address with what it opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit revealCredentials handed back one address over four fields, but they are facts about two things: the instance Dyad talks to, and a machine Dyad built. Usually the same server — not always. An install whose token could not be minted leaves an account behind on a screen that also offers the token form, so connecting elsewhere from there put the installed server's password under the other one's address. Folding them together is what made that possible, and what made a same-server check necessary to undo. Two records now, each carrying its own address, so there is nothing to guess: the panel shows one block when the addresses match exactly and two when they do not. Guessing wrong that way costs a repeated address rather than a password under the wrong name. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyConnector.test.tsx | 6 +- src/components/CoolifyCredentials.test.tsx | 83 ++++++++----- src/components/CoolifyCredentials.tsx | 62 ++++++++-- src/components/CoolifySignOutDialog.test.tsx | 19 +-- src/components/CoolifySignOutDialog.tsx | 2 +- .../handlers/coolify_setup_handlers.test.ts | 114 +++++++++--------- src/ipc/handlers/coolify_setup_handlers.ts | 29 +++-- src/ipc/types/coolify_setup.ts | 28 ++++- 8 files changed, 222 insertions(+), 121 deletions(-) diff --git a/src/components/CoolifyConnector.test.tsx b/src/components/CoolifyConnector.test.tsx index f3792c276d..998f84005d 100644 --- a/src/components/CoolifyConnector.test.tsx +++ b/src/components/CoolifyConnector.test.tsx @@ -18,7 +18,7 @@ vi.mock("sonner", () => ({ toast: toastMock })); vi.mock("@/components/CoolifyCredentials", () => ({ CoolifyCredentials: ({ showTitle }: { showTitle?: boolean }) => (
- {showTitle ? "Your new Coolify server" : null} + {showTitle ? "Your Coolify server" : null}
), })); @@ -338,7 +338,7 @@ describe("where someone with no Coolify lands", () => { render(); const text = screen.getByTestId("coolify-server-setup-stub").textContent; - expect(text?.indexOf("Your new Coolify server")).toBeLessThan( + expect(text?.indexOf("Your Coolify server")).toBeLessThan( text?.indexOf("I already have Coolify installed") ?? -1, ); }); @@ -351,7 +351,7 @@ describe("where someone with no Coolify lands", () => { render(); expect(screen.getByTestId("coolify-credentials-stub").textContent).toBe( - "Your new Coolify server", + "Your Coolify server", ); await openTokenForm(user); expect(screen.getByTestId("coolify-credentials-stub")).toBeTruthy(); diff --git a/src/components/CoolifyCredentials.test.tsx b/src/components/CoolifyCredentials.test.tsx index be926e4b4e..9f0df5e249 100644 --- a/src/components/CoolifyCredentials.test.tsx +++ b/src/components/CoolifyCredentials.test.tsx @@ -24,10 +24,15 @@ function CoolifyCredentials(props: { showTitle?: boolean }) { } const FULL = { - dashboardUrl: "https://203.0.113.5.sslip.io", - adminEmail: "me@gmail.com", - adminPassword: "Abc123@xyzAbc123@xyz", - apiToken: "1|abcdefghijklmnop", + instance: { + url: "https://203.0.113.5.sslip.io", + apiToken: "1|abcdefghijklmnop", + }, + server: { + url: "https://203.0.113.5.sslip.io", + email: "me@gmail.com", + password: "Abc123@xyzAbc123@xyz", + }, }; beforeEach(() => { @@ -49,10 +54,10 @@ describe("what is on screen without asking", () => { await renderAndSettle(); expect(screen.getByTestId("coolify-field-address").textContent).toBe( - FULL.dashboardUrl, + FULL.instance.url, ); expect(screen.getByTestId("coolify-field-email").textContent).toBe( - FULL.adminEmail, + FULL.server.email, ); }); @@ -90,7 +95,7 @@ describe("revealing one value", () => { await user.click(screen.getByRole("button", { name: "Show Password" })); expect(screen.getByTestId("coolify-field-password").textContent).toBe( - FULL.adminPassword, + FULL.server.password, ); await user.click(screen.getByRole("button", { name: "Hide Password" })); @@ -113,29 +118,60 @@ describe("revealing one value", () => { }); describe("naming the section", () => { - it("names a server Dyad installed", async () => { + it("names a server Dyad installed but has no token for", async () => { // Reached by installing a server whose API token could not be minted, so // the account is all Dyad has for it. - h.revealCredentials.mockResolvedValue({ ...FULL, apiToken: null }); + h.revealCredentials.mockResolvedValue({ ...FULL, instance: null }); render(); await waitFor(() => - expect(screen.getByText("Your new Coolify server")).toBeTruthy(), + expect(screen.getByText("Your Coolify server")).toBeTruthy(), ); }); it("leaves no heading over nothing", async () => { // The caller cannot know there is anything to show until this has asked. - h.revealCredentials.mockResolvedValue({ - dashboardUrl: null, - adminEmail: null, - adminPassword: null, - apiToken: null, - }); + h.revealCredentials.mockResolvedValue({ instance: null, server: null }); render(); await waitFor(() => expect(h.revealCredentials).toHaveBeenCalled()); - expect(screen.queryByText("Your new Coolify server")).toBeNull(); + expect(screen.queryByText("Your Coolify server")).toBeNull(); + }); +}); + +describe("two servers that are not the same server", () => { + it("keeps each address with what it opens", async () => { + // Installed a server whose token could not be minted, then connected to a + // different Coolify. One address over both would read as a way into the + // connected one that is not one. + h.revealCredentials.mockResolvedValue({ + instance: { + url: "https://someone-elses.example.com", + apiToken: "1|other", + }, + server: { + url: "http://203.0.113.5:8000", + email: "me@gmail.com", + password: "Abc123@xyz", + }, + }); + await renderAndSettle(); + + const forServer = screen.getByTestId("coolify-credentials-server"); + const forInstance = screen.getByTestId("coolify-credentials-instance"); + expect(forServer.textContent).toContain("http://203.0.113.5:8000"); + expect(forServer.textContent).not.toContain("someone-elses"); + expect(forInstance.textContent).toContain("someone-elses.example.com"); + // The password belongs to the machine Dyad built, and stays with it. + expect(forInstance.textContent).not.toContain("Abc123@xyz"); + }); + + it("shows one block when both describe the same address", async () => { + await renderAndSettle(); + + expect(screen.queryByTestId("coolify-credentials-server")).toBeNull(); + expect(screen.queryByTestId("coolify-credentials-instance")).toBeNull(); + expect(screen.getAllByTestId(/^coolify-field-address$/)).toHaveLength(1); }); }); @@ -143,12 +179,7 @@ describe("an instance Dyad did not set up", () => { it("renders nothing rather than an empty heading", async () => { // Connected by pasting a token: no account Dyad created, no address it // chose. A panel of blanks would read as something having failed. - h.revealCredentials.mockResolvedValue({ - dashboardUrl: null, - adminEmail: null, - adminPassword: null, - apiToken: null, - }); + h.revealCredentials.mockResolvedValue({ instance: null, server: null }); const { container } = render(); await waitFor(() => expect(h.revealCredentials).toHaveBeenCalled()); @@ -158,10 +189,8 @@ describe("an instance Dyad did not set up", () => { it("still shows a token the user pasted themselves", async () => { h.revealCredentials.mockResolvedValue({ - dashboardUrl: "https://coolify.example.com", - adminEmail: null, - adminPassword: null, - apiToken: "1|theirs", + instance: { url: "https://coolify.example.com", apiToken: "1|theirs" }, + server: null, }); await renderAndSettle(); diff --git a/src/components/CoolifyCredentials.tsx b/src/components/CoolifyCredentials.tsx index 01465727e4..2c79da6b7e 100644 --- a/src/components/CoolifyCredentials.tsx +++ b/src/components/CoolifyCredentials.tsx @@ -97,24 +97,66 @@ export function CoolifyCredentials({ if (!credentials) return null; - const { dashboardUrl, adminEmail, adminPassword, apiToken } = credentials; + const { instance, server } = credentials; // An instance connected by pasting a token has no account Dyad created and - // no address it chose, so there is nothing here worth a heading. - if (!dashboardUrl && !adminEmail && !adminPassword && !apiToken) return null; + // no server it built, so there is nothing here worth a heading. + if (!instance && !server) return null; + // The usual case: Dyad set the server up and is connected to it. Merged on + // the address matching exactly, never on a guess at two spellings of one + // machine — guessing wrong the other way shows two blocks with two correct + // addresses, which is a moment's confusion rather than a wrong password. + const isOneServer = + instance !== null && server !== null && instance.url === server.url; return (
{/* Kept inside so a caller cannot leave a heading over nothing when there is nothing to show. */} {showTitle && ( -
- Your new Coolify server -
+
Your Coolify server
+ )} + + {isOneServer ? ( + <> + + + {server.password && ( + + )} + {instance.apiToken && ( + + )} + + ) : ( + <> + {server && ( +
+
+ The server Dyad set up +
+ + + {server.password && ( + + )} +
+ )} + {instance && ( +
+
+ The Coolify Dyad is connected to +
+ + {instance.apiToken && ( + + )} +
+ )} + )} - {dashboardUrl && } - {adminEmail && } - {adminPassword && } - {apiToken && }
); } diff --git a/src/components/CoolifySignOutDialog.test.tsx b/src/components/CoolifySignOutDialog.test.tsx index f251132536..c3e86f3c57 100644 --- a/src/components/CoolifySignOutDialog.test.tsx +++ b/src/components/CoolifySignOutDialog.test.tsx @@ -13,10 +13,15 @@ vi.mock("@/ipc/types", () => ({ const { CoolifySignOutDialog: Dialog } = await import("./CoolifySignOutDialog"); const FULL = { - dashboardUrl: "https://203.0.113.5.sslip.io", - adminEmail: "me@gmail.com", - adminPassword: "Abc123@xyzAbc123@xyz", - apiToken: "1|abcdefghijklmnop", + instance: { + url: "https://203.0.113.5.sslip.io", + apiToken: "1|abcdefghijklmnop", + }, + server: { + url: "https://203.0.113.5.sslip.io", + email: "me@gmail.com", + password: "Abc123@xyzAbc123@xyz", + }, }; const onConfirm = vi.fn(); @@ -113,11 +118,7 @@ describe("the last look", () => { it("does not say it for an instance Dyad did not set up", async () => { // Connected by pasting a token, so nothing here was invented by Dyad and // a warning about losing it forever would be untrue. - h.revealCredentials.mockResolvedValue({ - ...FULL, - adminEmail: null, - adminPassword: null, - }); + h.revealCredentials.mockResolvedValue({ ...FULL, server: null }); await openAndSettle(); await waitFor(() => expect(h.revealCredentials).toHaveBeenCalled()); diff --git a/src/components/CoolifySignOutDialog.tsx b/src/components/CoolifySignOutDialog.tsx index 4cad297996..cebe6f4073 100644 --- a/src/components/CoolifySignOutDialog.tsx +++ b/src/components/CoolifySignOutDialog.tsx @@ -58,7 +58,7 @@ export function CoolifySignOutDialog({ Dyad will forget the details below. Your server keeps running and your apps keep their settings. - {credentials?.adminPassword + {credentials?.server?.password ? " Dyad made this password up and is the only thing holding it — Coolify cannot show it to you again." : ""} diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts index 4466430847..585c07a780 100644 --- a/src/ipc/handlers/coolify_setup_handlers.test.ts +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -597,16 +597,18 @@ describe("run", () => { }); describe("revealCredentials", () => { + const ADMIN = { + email: "me@gmail.com", + password: { value: "Abc123@xyz" }, + instanceUrl: "http://203.0.113.5:8000", + }; + it("hands back what Dyad knows about getting in", async () => { h.settings = { coolify: { instanceUrl: "http://203.0.113.5:8000", accessToken: { value: "1|abc" }, - admin: { - email: "me@gmail.com", - password: { value: "Abc123@xyz" }, - instanceUrl: "http://203.0.113.5:8000", - }, + admin: ADMIN, }, }; const result = (await call("coolify-setup:reveal-credentials")) as Record< @@ -614,76 +616,76 @@ describe("revealCredentials", () => { unknown >; expect(result).toEqual({ - dashboardUrl: "http://203.0.113.5:8000", - adminEmail: "me@gmail.com", - adminPassword: "Abc123@xyz", - apiToken: "1|abc", + instance: { url: "http://203.0.113.5:8000", apiToken: "1|abc" }, + server: { + url: "http://203.0.113.5:8000", + email: "me@gmail.com", + password: "Abc123@xyz", + }, }); }); - it("gives the address of a server installed before any token", async () => { - // Nothing was ever connected, so there is no instanceUrl — but the user - // still has to know which machine these open. - h.settings = { - coolify: { - admin: { - email: "me@gmail.com", - password: { value: "Abc123@xyz" }, - instanceUrl: "http://203.0.113.5:8000", - }, - }, - }; + it("describes a server installed before any token as a server alone", async () => { + // Nothing was ever connected, so there is no instance — but the machine + // Dyad built is still named by the account it made on it. + h.settings = { coolify: { admin: ADMIN } }; const result = (await call("coolify-setup:reveal-credentials")) as Record< string, unknown >; - expect(result.dashboardUrl).toBe("http://203.0.113.5:8000"); - expect(result.adminPassword).toBe("Abc123@xyz"); + expect(result.instance).toBeNull(); + expect(result.server).toEqual({ + url: "http://203.0.113.5:8000", + email: "me@gmail.com", + password: "Abc123@xyz", + }); }); - it("names the server by the address the token was saved for", async () => { - // The two differ when a server installed at its bare address is connected - // under the domain it was given afterwards. The address Dyad is talking - // to is the one that reaches Coolify, so it is the one shown. + it("keeps each address with what it opens when they are different", async () => { + // Installed a server whose token could not be minted, then connected to a + // different Coolify. One address over both would put the installed + // server's password under the other one's address. h.settings = { coolify: { - instanceUrl: "https://coolify.example.com", - accessToken: { value: "1|abc" }, - admin: { - email: "me@gmail.com", - password: { value: "Abc123@xyz" }, - instanceUrl: "http://203.0.113.5:8000", - }, + instanceUrl: "https://someone-elses.example.com", + accessToken: { value: "1|for-the-other-one" }, + admin: ADMIN, }, }; - const result = (await call("coolify-setup:reveal-credentials")) as Record< - string, - unknown - >; - expect(result.dashboardUrl).toBe("https://coolify.example.com"); - // Still the same box, so its account is still what opens it. - expect(result.adminPassword).toBe("Abc123@xyz"); + const result = (await call("coolify-setup:reveal-credentials")) as { + instance: { url: string; apiToken: string }; + server: { url: string; password: string }; + }; + expect(result.instance.url).toBe("https://someone-elses.example.com"); + expect(result.instance.apiToken).toBe("1|for-the-other-one"); + expect(result.server.url).toBe("http://203.0.113.5:8000"); + expect(result.server.password).toBe("Abc123@xyz"); + }); + + it("hides a password it cannot read, keeping the server it belongs to", async () => { + h.settings = { + coolify: { + admin: { email: "me@gmail.com", instanceUrl: "http://h:8000" }, + }, + }; + const result = (await call("coolify-setup:reveal-credentials")) as { + server: { url: string; email: string; password: string | null }; + }; + expect(result.server.password).toBeNull(); + expect(result.server.email).toBe("me@gmail.com"); }); it("has nothing to hand back once the instance is forgotten", async () => { - // Signing out clears all of it, so there is no address or password left - // for the panel to put on screen. h.settings = { coolify: {} }; const result = (await call("coolify-setup:reveal-credentials")) as Record< string, unknown >; - expect(result).toEqual({ - dashboardUrl: null, - adminEmail: null, - adminPassword: null, - apiToken: null, - }); + expect(result).toEqual({ instance: null, server: null }); }); - it("answers nulls for an instance Dyad did not set up", async () => { - // Connected by pasting a token, so there is no account Dyad created and - // nothing here it could hand back. + it("answers a null server for an instance Dyad did not set up", async () => { + // Connected by pasting a token, so there is no account Dyad created. h.settings = { coolify: { instanceUrl: "https://coolify.example.com", @@ -694,9 +696,11 @@ describe("revealCredentials", () => { string, unknown >; - expect(result.adminPassword).toBeNull(); - expect(result.adminEmail).toBeNull(); - expect(result.apiToken).toBe("1|abc"); + expect(result.server).toBeNull(); + expect(result.instance).toEqual({ + url: "https://coolify.example.com", + apiToken: "1|abc", + }); }); }); diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index 7652274815..e3837340cd 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -418,18 +418,25 @@ export function registerCoolifySetupHandlers() { // Dyad generated the password on their behalf, so refusing to show it // would lock them out of something they own. const coolify = readSettings().coolify; - // Everything here describes the same server, because Dyad holds one at a - // time and signing out forgets all of it together. So the fields are read - // straight out rather than checked against each other for whose they are. - // - // The account's own address covers the window before a token exists: a - // server just installed is named by the account Dyad made on it and - // nothing else. + // Each with the address it belongs to, rather than one address over both. + // They are usually the same server and occasionally not, and handing back + // a single address would mean deciding which one it is — a decision that + // shows one server's password under another's address when it guesses + // wrong. Kept apart, there is nothing to guess. return { - dashboardUrl: coolify?.instanceUrl ?? coolify?.admin?.instanceUrl ?? null, - adminEmail: coolify?.admin?.email ?? null, - adminPassword: coolify?.admin?.password?.value ?? null, - apiToken: coolify?.accessToken?.value ?? null, + instance: coolify?.instanceUrl + ? { + url: coolify.instanceUrl, + apiToken: coolify.accessToken?.value ?? null, + } + : null, + server: coolify?.admin + ? { + url: coolify.admin.instanceUrl, + email: coolify.admin.email, + password: coolify.admin.password?.value ?? null, + } + : null, }; }); diff --git a/src/ipc/types/coolify_setup.ts b/src/ipc/types/coolify_setup.ts index dc23425ccf..ec836486b4 100644 --- a/src/ipc/types/coolify_setup.ts +++ b/src/ipc/types/coolify_setup.ts @@ -108,14 +108,32 @@ export const SetupResultSchema = z.object({ /** * What Dyad can tell the user about getting into their own server. * + * Two records, each carrying its own address, because they are facts about + * two different things: the instance Dyad talks to, and a machine Dyad built. + * Usually the same server, but not always — an install whose token could not + * be minted leaves an account behind while the user connects somewhere else. + * Folded into one address they would have to be checked against each other + * before either could be shown, and a check that guessed wrong would put one + * server's password under another's address. + * * Null where Dyad never had it: an instance connected by pasting a token has - * no admin account Dyad created, so there is no password to hand back. + * no admin account Dyad created, and a server set up but not connected to has + * no instance. */ export const RevealedCredentialsSchema = z.object({ - dashboardUrl: z.string().nullable(), - adminEmail: z.string().nullable(), - adminPassword: z.string().nullable(), - apiToken: z.string().nullable(), + instance: z + .object({ + url: z.string(), + apiToken: z.string().nullable(), + }) + .nullable(), + server: z + .object({ + url: z.string(), + email: z.string(), + password: z.string().nullable(), + }) + .nullable(), }); /** From c602dc74f933dc1ccfbdfcfa4f4715bd0c0ed53a Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Sun, 23 Aug 2026 20:58:33 -0500 Subject: [PATCH 23/91] fix(coolify): do not ask for an acknowledgement over an empty panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sign-out dialog rendered its credentials block straight away, and that block shows nothing until the read behind it answers. So the checkbox saying the details above have been saved, and the button that forgets them, were both live over a blank space — and over a read that had failed outright, where the details would go without ever being shown. Nothing can be confirmed now until the read has answered, a failed read says so, and a password Dyad holds but cannot decrypt is named rather than left as a row that never appears. Also from the same review: the shape Dyad writes to forget an instance is built fresh each call, since writeSettings edits the object it is handed and one kept at module scope would carry another write's edits into the next sign-out; CoolifySchema's doc block sits back on CoolifySchema rather than on the admin schema that was inserted under it; the two-address case is written down where the second address is declared; and the labels telling the two credential blocks apart appear only when there are two. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyCredentials.tsx | 23 +++++--- src/components/CoolifySignOutDialog.test.tsx | 44 +++++++++++++++ src/components/CoolifySignOutDialog.tsx | 45 +++++++++++++++- src/ipc/handlers/coolify_handlers.ts | 6 +-- src/lib/schemas.ts | 56 +++++++++++++++----- src/main/settings.test.ts | 26 ++++++++- 6 files changed, 171 insertions(+), 29 deletions(-) diff --git a/src/components/CoolifyCredentials.tsx b/src/components/CoolifyCredentials.tsx index 2c79da6b7e..7bf3cec883 100644 --- a/src/components/CoolifyCredentials.tsx +++ b/src/components/CoolifyCredentials.tsx @@ -98,8 +98,8 @@ export function CoolifyCredentials({ if (!credentials) return null; const { instance, server } = credentials; - // An instance connected by pasting a token has no account Dyad created and - // no server it built, so there is nothing here worth a heading. + // Nothing stored at all — signed out, or never connected. A panel of blanks + // would read as something having failed. if (!instance && !server) return null; // The usual case: Dyad set the server up and is connected to it. Merged on // the address matching exactly, never on a guess at two spellings of one @@ -107,6 +107,9 @@ export function CoolifyCredentials({ // addresses, which is a moment's confusion rather than a wrong password. const isOneServer = instance !== null && server !== null && instance.url === server.url; + // Only when there are two blocks to tell apart. Over a lone block they name + // something nothing is being distinguished from. + const showsBoth = instance !== null && server !== null; return (
@@ -131,9 +134,11 @@ export function CoolifyCredentials({ <> {server && (
-
- The server Dyad set up -
+ {showsBoth && ( +
+ The server Dyad set up +
+ )} {server.password && ( @@ -146,9 +151,11 @@ export function CoolifyCredentials({ className="space-y-2" data-testid="coolify-credentials-instance" > -
- The Coolify Dyad is connected to -
+ {showsBoth && ( +
+ The Coolify Dyad is connected to +
+ )} {instance.apiToken && ( diff --git a/src/components/CoolifySignOutDialog.test.tsx b/src/components/CoolifySignOutDialog.test.tsx index c3e86f3c57..e3fecba932 100644 --- a/src/components/CoolifySignOutDialog.test.tsx +++ b/src/components/CoolifySignOutDialog.test.tsx @@ -93,6 +93,50 @@ describe("acknowledging the loss", () => { }); }); +describe("nothing to look at yet", () => { + it("will not sign out while it is still finding out", async () => { + // Ticking a box that says the details above have been saved, over a panel + // that has not shown any, is not the acknowledgement this asks for. + h.revealCredentials.mockReturnValue(new Promise(() => {})); + const user = userEvent.setup(); + open(); + + await waitFor(() => + expect(screen.getByTestId("coolify-sign-out-dialog")).toBeTruthy(), + ); + await user.click(screen.getByTestId("coolify-sign-out-acknowledge")); + + expect(signOutButton().disabled).toBe(true); + }); + + it("says so when it cannot read what it has stored", async () => { + // Signing out still forgets them, so staying silent would destroy + // credentials the user was never shown. + h.revealCredentials.mockRejectedValue(new Error("keychain locked")); + open(); + + await waitFor(() => + expect(screen.getByTestId("coolify-sign-out-unreadable")).toBeTruthy(), + ); + }); + + it("says when it holds a password it cannot read", async () => { + // The panel cannot show a row for a value it does not have, so a missing + // password would otherwise read as there never having been one. + h.revealCredentials.mockResolvedValue({ + ...FULL, + server: { ...FULL.server, password: null }, + }); + open(); + + await waitFor(() => + expect( + screen.getByTestId("coolify-sign-out-locked-password"), + ).toBeTruthy(), + ); + }); +}); + describe("the last look", () => { it("shows what is about to be forgotten", async () => { await openAndSettle(); diff --git a/src/components/CoolifySignOutDialog.tsx b/src/components/CoolifySignOutDialog.tsx index cebe6f4073..84477a0a31 100644 --- a/src/components/CoolifySignOutDialog.tsx +++ b/src/components/CoolifySignOutDialog.tsx @@ -43,12 +43,26 @@ export function CoolifySignOutDialog({ // Shares a key with the fields below, so opening this asks once. Only read // here to decide whether the password gets a warning of its own. - const { data: credentials } = useQuery({ + const { + data: credentials, + isPending, + isError, + } = useQuery({ queryKey: queryKeys.coolify.credentials, queryFn: () => ipc.coolifySetup.revealCredentials(), gcTime: 0, enabled: open, }); + // A tick over an empty panel is not the acknowledgement this asks for, so + // nothing can be confirmed until the read has answered. `enabled` leaves the + // query pending while the dialog is closed, which reads the same here. + const answered = !isPending || isError; + // Held but unreadable, which the panel below cannot show because there is + // no value to put on screen. Saying so beats a silently missing row. + const passwordIsLocked = + credentials?.server !== null && + credentials?.server !== undefined && + credentials.server.password === null; return ( @@ -64,6 +78,30 @@ export function CoolifySignOutDialog({ + {isPending && !isError && ( +
+ Looking up what Dyad has stored… +
+ )} + {isError && ( +
+ Dyad could not read what it has stored for this Coolify. Signing out + still forgets it. +
+ )} + {passwordIsLocked && ( +
+ Dyad is holding an admin password for this server but cannot read it + on this machine, so it cannot show it to you before it goes. +
+ )} +
)} - {isError && ( + {readFailed && (
- Dyad could not read what it has stored for this Coolify. Signing out - still forgets it. + Signing out forgets it anyway.
)} {passwordIsLocked && ( From 2286ea52db468a04b7681fe950f838e31b92d0ed Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Mon, 24 Aug 2026 12:52:47 -0500 Subject: [PATCH 27/91] fix(coolify): do not take a custom domain on trust when DNS went unanswered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check for where a user's domain points objected only when it resolved somewhere else. A resolver that could not be reached came back with nothing to compare and read the same as a name with no records yet — which is not knowing, and was being treated as permission. What follows is a certificate poll that settles for any address answering with a certificate it trusts, and it resolves the name through the system rather than the resolver asked here, so the two can disagree. A domain still pointing at the machine the user is moving off would pass, and its address would be stored as the instance every deploy sends the API token to. The verdict now says which of the three it is, and a lookup that never answered stops the domain being used. Also gives a domain given by hand the same unreachable-name check the derived ones get, so coolify.local costs a sentence rather than the whole two-minute certificate wait. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/https_setup.test.ts | 88 ++++++++++++++++++++++----- src/coolify_setup/https_setup.ts | 77 ++++++++++++++++------- 2 files changed, 127 insertions(+), 38 deletions(-) diff --git a/src/coolify_setup/https_setup.test.ts b/src/coolify_setup/https_setup.test.ts index 1fc46072c7..6f5022d71c 100644 --- a/src/coolify_setup/https_setup.test.ts +++ b/src/coolify_setup/https_setup.test.ts @@ -165,17 +165,36 @@ describe("hasTrustedCertificate", () => { }); }); +describe("certificateDomainFor, for a domain given by hand", () => { + it("refuses a name nothing public can validate", async () => { + // Same reasoning the derived names get. Asking anyway spends the whole + // certificate wait — two minutes — on an answer that cannot arrive. + expect(certificateDomainFor("203.0.113.5", "coolify.local")).toBeNull(); + expect(certificateDomainFor("203.0.113.5", "localhost")).toBeNull(); + expect(certificateDomainFor("203.0.113.5", "192.168.1.10")).toBeNull(); + }); + + it("keeps a name that could be validated", async () => { + expect(certificateDomainFor("203.0.113.5", "coolify.example.com")).toBe( + "coolify.example.com", + ); + expect( + certificateDomainFor("203.0.113.5", "https://coolify.example.com/"), + ).toBe("coolify.example.com"); + }); +}); + describe("domainPointsAtServer", () => { const answers = (addresses: string[], failed = false) => async () => ({ addresses, failed }); - it("is true when the domain resolves to the server", async () => { + it("says it points here when the domain resolves to the server", async () => { expect( await domainPointsAtServer("coolify.example.com", "203.0.113.5", { resolve: answers(["203.0.113.5"]), }), - ).toBe(true); + ).toBe("points-here"); }); it("is false when it still points at something else", async () => { @@ -185,26 +204,28 @@ describe("domainPointsAtServer", () => { await domainPointsAtServer("example.com", "203.0.113.5", { resolve: answers(["198.51.100.9"]), }), - ).toBe(false); + ).toBe("points-elsewhere"); }); - it("does not object when the resolver could not be reached", async () => { - // Not knowing is not the same as knowing it is wrong, and this only - // decides whether to attempt something that is checked afterwards anyway. + it("says it does not know when the resolver could not be reached", async () => { + // Told apart from an answer, because the certificate poll that follows + // settles for any address serving a certificate it trusts — so a domain + // still pointing at the machine the user is moving off would pass it. expect( await domainPointsAtServer("example.com", "203.0.113.5", { resolve: answers([], true), }), - ).toBe(true); + ).toBe("unknown"); }); it("does not object when the domain has no records yet", async () => { - // It may be minutes old. The certificate wait is the real answer. + // The resolver answered; the name simply has nothing yet. It may be + // minutes old, and the certificate wait is the real answer. expect( await domainPointsAtServer("example.com", "203.0.113.5", { resolve: answers([]), }), - ).toBe(true); + ).toBe("points-here"); }); it("compares a server known by a name against what the name resolves to", async () => { @@ -221,7 +242,7 @@ describe("domainPointsAtServer", () => { await domainPointsAtServer("coolify.example.com", "box.example.com", { resolve: byName, }), - ).toBe(false); + ).toBe("points-elsewhere"); }); it("accepts a domain that resolves to the same place as the named server", async () => { @@ -229,12 +250,13 @@ describe("domainPointsAtServer", () => { await domainPointsAtServer("coolify.example.com", "box.example.com", { resolve: answers(["203.0.113.5"]), }), - ).toBe(true); + ).toBe("points-here"); }); - it("says nothing when the server's own name does not resolve", async () => { - // Not knowing where the server is is not the same as knowing the domain - // is wrong, and refusing here would block a setup over a private name. + it("does not object when the server's own name does not resolve", async () => { + // A different case from the domain's own lookup failing: there is nothing + // to compare against rather than no answer about where the domain points, + // and refusing here would block a setup over a private name. const nothingForTheServer = async (target: string) => ({ addresses: target === "box.internal" ? [] : ["198.51.100.9"], failed: target === "box.internal", @@ -244,7 +266,7 @@ describe("domainPointsAtServer", () => { await domainPointsAtServer("coolify.example.com", "box.internal", { resolve: nothingForTheServer, }), - ).toBe(true); + ).toBe("points-here"); }); }); @@ -269,6 +291,42 @@ describe("tryEnableHttps", () => { expect(asked.filter((n) => n === "box.example.com")).toHaveLength(1); }); + it("will not take a custom domain on trust when DNS could not be checked", async () => { + // The certificate poll settles for any address answering with a trusted + // certificate, and resolves the name through the system rather than the + // resolver asked here. A domain still pointing at the machine the user is + // moving off would pass it, and its address would become the instance the + // API token is sent to on every deploy. + const { session } = fakeSession(); + const result = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "coolify.example.com", + resolve: async () => ({ addresses: [], failed: true }), + // Would say yes, which is the point: it is never asked. + check: async () => true, + }); + + expect(result.secure).toBe(false); + expect(result.instanceUrl).toBe("http://203.0.113.5:8000"); + expect(result.reason).toMatch(/could not look up where/i); + }); + + it("still accepts a custom domain whose name simply has no records yet", async () => { + // The resolver answered. A name minutes old has nothing to say and the + // certificate wait is the real test, which is not the same as Dyad never + // having got an answer at all. + const { session } = fakeSession(); + const result = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "coolify.example.com", + resolve: async () => ({ addresses: [], failed: false }), + check: async () => true, + }); + + expect(result.secure).toBe(true); + expect(result.instanceUrl).toBe("https://coolify.example.com"); + }); + it("says what it is doing while it takes the domain back off", async () => { // The only stretch with nothing behind it on screen. On a slow server a // silent wait here reads as a hang. diff --git a/src/coolify_setup/https_setup.ts b/src/coolify_setup/https_setup.ts index b13d44ef24..01ce81674e 100644 --- a/src/coolify_setup/https_setup.ts +++ b/src/coolify_setup/https_setup.ts @@ -60,7 +60,18 @@ export function certificateDomainFor( customDomain?: string | null, ): string | null { const custom = customDomain?.trim(); - if (custom) return custom.replace(/^https?:\/\//, "").replace(/\/+$/, ""); + if (custom) { + const bareCustom = custom.replace(/^https?:\/\//, "").replace(/\/+$/, ""); + // The same reasoning as the derived names below, which a domain given by + // hand needs just as much: nothing public can validate a name only this + // machine answers to, and asking anyway spends the whole certificate wait + // on an answer that cannot arrive. + if (isLoopbackAddress(bareCustom) || /\.local$/i.test(bareCustom)) { + return null; + } + if (isIP(bareCustom) === 4 && isNonRoutableAddress(bareCustom)) return null; + return bareCustom; + } const bare = host.trim(); if (isIP(bare) === 4) { @@ -209,7 +220,7 @@ export async function domainPointsAtServer( resolve = resolveBoth, hostAddresses, }: { resolve?: typeof resolveBoth; hostAddresses?: string[] } = {}, -): Promise { +): Promise<"points-here" | "points-elsewhere" | "unknown"> { // A server known by a name is resolved to the addresses it stands for, so // the domain is compared against the same thing either way. A name that // does not resolve leaves nothing to compare, which the verdict below reads @@ -219,15 +230,16 @@ export async function domainPointsAtServer( ? [host] : (hostAddresses ?? (await resolve(host)).addresses); const resolved = await resolve(domain); - // Only a domain that resolves somewhere else is an objection. A resolver we - // could not reach and a name with no records yet both come back with nothing - // to compare, which is not knowing rather than knowing it is wrong. - return ( - domainCheckVerdict({ - expectedIps, - actualIps: resolved.addresses, - }) !== "points-elsewhere" - ); + // A resolver that could not be reached is not a name with no records. Both + // arrive with nothing to compare, but only the second is the domain saying + // where it points — the first is Dyad not having asked successfully, which + // the caller has to be able to tell apart from an answer. + if (resolved.failed) return "unknown"; + const verdict = domainCheckVerdict({ + expectedIps, + actualIps: resolved.addresses, + }); + return verdict === "points-elsewhere" ? "points-elsewhere" : "points-here"; } export interface HttpsOutcome { @@ -308,18 +320,37 @@ export async function tryEnableHttps( // Only a domain of the user's own can point somewhere else. The derived // sslip.io name resolves to the address it was built from, by construction. - if ( - customDomain && - !(await domainPointsAtServer(domain, host, { resolve, hostAddresses })) - ) { - return { - instanceUrl: plainUrlFor(host), - secure: false, - reason: - `${domain} does not point at this server, so a certificate for it ` + - `would not describe this machine. Point it at ${host} and set the ` + - `domain in Coolify.`, - }; + if (customDomain) { + const points = await domainPointsAtServer(domain, host, { + resolve, + hostAddresses, + }); + if (points === "points-elsewhere") { + return { + instanceUrl: plainUrlFor(host), + secure: false, + reason: + `${domain} does not point at this server, so a certificate for it ` + + `would not describe this machine. Point it at ${host} and set the ` + + `domain in Coolify.`, + }; + } + // Not knowing is not permission. The certificate poll below settles for + // any address that answers with a certificate it trusts, and it resolves + // the name through the system rather than the resolver asked here — so a + // domain still pointing at a machine the user is moving off would be + // taken as proof, and its address stored as the instance to send the API + // token to on every deploy. + if (points === "unknown") { + return { + instanceUrl: plainUrlFor(host), + secure: false, + reason: + `Dyad could not look up where ${domain} points, so it cannot tell ` + + `whether a certificate for it would describe this machine. Check ` + + `the domain resolves to ${host} and try again.`, + }; + } } const url = httpsUrlFor(domain); From 55c2080ed9351ca1e41ce694da76ca32d0604b03 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Mon, 24 Aug 2026 12:52:47 -0500 Subject: [PATCH 28/91] fix(coolify): stop reporting a lost connection as an unsupported Coolify Reading the instance's version caught everything a cancel was not and answered null, which the caller reads as a version it cannot drive. So a dropped SSH link ended with the user being told their freshly installed Coolify was too old to set up automatically, about a question that never reached it. Transport failures are handed back now, the same line isAdminSeeded already draws: a bound Dyad imposed is worth another look, a lost link is not. The flow reports it in its own words rather than the transport's. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/api_token.test.ts | 37 +++++++++++++++++++++++++++++ src/coolify_setup/api_token.ts | 10 ++++++++ src/coolify_setup/setup_flow.ts | 12 +++++++--- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/coolify_setup/api_token.test.ts b/src/coolify_setup/api_token.test.ts index fb16180c6d..768ae1ac6d 100644 --- a/src/coolify_setup/api_token.test.ts +++ b/src/coolify_setup/api_token.test.ts @@ -8,6 +8,8 @@ import { supportsAutomaticToken, tryAutomaticAccess, } from "./api_token"; +import { DyadErrorKind } from "@/errors/dyad_error"; +import { SshError } from "@/ipc/utils/ssh_client"; import type { SshSession } from "@/ipc/utils/ssh_client"; /** Wraps a value the way a real tinker transcript carries it. */ @@ -77,6 +79,41 @@ describe("readCoolifyVersion", () => { const session = fakeSession(["Command not found"]); expect(await readCoolifyVersion(session)).toBeNull(); }); + + it("does not call a lost link an unreadable version", async () => { + // Answering null here sends the caller down the path that tells the user + // their freshly installed Coolify is too old to drive, for a question + // that never reached it. + const session = { + run: vi.fn(async () => { + throw new SshError( + "connection-lost", + "connection lost", + DyadErrorKind.External, + ); + }), + end: vi.fn(), + } as unknown as SshSession; + + await expect(readCoolifyVersion(session)).rejects.toBeInstanceOf(SshError); + }); + + it("still answers null when a bound Dyad set is the one that was hit", async () => { + // The instance is reachable and simply did not answer in time, which is + // the case this was written for. + const session = { + run: vi.fn(async () => { + throw new SshError( + "command-timeout", + "timed out", + DyadErrorKind.External, + ); + }), + end: vi.fn(), + } as unknown as SshSession; + + expect(await readCoolifyVersion(session)).toBeNull(); + }); }); describe("enableApi", () => { diff --git a/src/coolify_setup/api_token.ts b/src/coolify_setup/api_token.ts index bc3719b153..b31b7fd334 100644 --- a/src/coolify_setup/api_token.ts +++ b/src/coolify_setup/api_token.ts @@ -1,5 +1,6 @@ import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { COOLIFY_SCOPES_PHP_ARRAY } from "@/shared/coolify_scopes"; +import { SshError } from "@/ipc/utils/ssh_client"; import type { SshSession } from "@/ipc/utils/ssh_client"; import { runTinker } from "./tinker"; @@ -87,6 +88,15 @@ export async function readCoolifyVersion( // driven. Swallowing it here would report a version problem for something // they did on purpose, and carry on setting the server up. if ((error as { kind?: string }).kind === "user_cancelled") throw error; + // A link that has died is not an instance whose version cannot be read. + // Returning null here says the version is the problem, and the caller + // tells the user their freshly installed Coolify is too old to drive — + // for a question that never reached it. The same line isAdminSeeded + // draws: a bound Dyad imposed is worth another look, a lost connection + // is not. + if (error instanceof SshError && error.failure !== "command-timeout") { + throw error; + } // An instance too old or too different to answer is one to set up by hand. return null; } diff --git a/src/coolify_setup/setup_flow.ts b/src/coolify_setup/setup_flow.ts index c5700af366..2c7b2d5d51 100644 --- a/src/coolify_setup/setup_flow.ts +++ b/src/coolify_setup/setup_flow.ts @@ -12,6 +12,7 @@ import { waitForAdminSeeded, waitForDashboard, } from "./install"; +import { SshError } from "@/ipc/utils/ssh_client"; import { tryAutomaticAccess } from "./api_token"; import { plainUrlFor, tryEnableHttps } from "./https_setup"; import type { HttpsOutcome } from "./https_setup"; @@ -266,10 +267,15 @@ export async function runServerSetup({ // throws: losing a working server because the last step failed would be // the worse outcome by far. if ((error as { kind?: string }).kind === "user_cancelled") throw error; + // A lost link is reported as one. Handing back the transport's own + // words would tell the user about a socket when what they need to know + // is that the server stopped answering and the rest is theirs to do. result.tokenUnavailableReason = - error instanceof Error - ? error.message - : "Coolify's API could not be opened automatically."; + error instanceof SshError + ? "Coolify did not answer while Dyad was opening its API." + : error instanceof Error + ? error.message + : "Coolify's API could not be opened automatically."; } report("done"); From 0a2be445faede69052d5f97b6d091dc8b5a984b4 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Mon, 24 Aug 2026 12:52:48 -0500 Subject: [PATCH 29/91] fix(coolify): give each credential block its own field ids Both blocks carry an address, and the id was built from the label alone, so coolify-field-address named two things at once whenever the instance and the server turned out to be different machines. Prefixed while both are on screen, and left alone when only one is, which is every other case. Also drops a line on instanceUrl saying it survives a token clear, which stopped being true when signing out started forgetting the instance. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyCredentials.test.tsx | 5 +++ src/components/CoolifyCredentials.tsx | 44 +++++++++++++++++++--- src/ipc/types/coolify.ts | 2 +- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/components/CoolifyCredentials.test.tsx b/src/components/CoolifyCredentials.test.tsx index eeb17f1140..ddb40cb2a1 100644 --- a/src/components/CoolifyCredentials.test.tsx +++ b/src/components/CoolifyCredentials.test.tsx @@ -158,6 +158,11 @@ describe("two servers that are not the same server", () => { }); await renderAndSettle(); + // One id each, so a lookup for the address reaches one thing rather than + // two — both blocks carry one. + expect(screen.getByTestId("coolify-field-server-address")).toBeTruthy(); + expect(screen.getByTestId("coolify-field-instance-address")).toBeTruthy(); + const forServer = screen.getByTestId("coolify-credentials-server"); const forInstance = screen.getByTestId("coolify-credentials-instance"); expect(forServer.textContent).toContain("http://203.0.113.5:8000"); diff --git a/src/components/CoolifyCredentials.tsx b/src/components/CoolifyCredentials.tsx index 26eea7bbbc..2df48df44d 100644 --- a/src/components/CoolifyCredentials.tsx +++ b/src/components/CoolifyCredentials.tsx @@ -23,16 +23,26 @@ function Field({ label, value, secret, + idPrefix, }: { label: string; value: string; secret?: boolean; + /** + * Told apart from the same label in the other block, and only when both + * are on screen. Both carry an address, so an id naming just the label + * would be two things at once — which any lookup for it reaches + * ambiguously. One block alone has nothing to be confused with. + */ + idPrefix?: string; }) { const [shown, setShown] = useState(false); const [copied, setCopied] = useState(false); const resetTimer = useRef>(undefined); useEffect(() => () => clearTimeout(resetTimer.current), []); - const id = label.toLowerCase().replace(/\s+/g, "-"); + const id = `${idPrefix ? `${idPrefix}-` : ""}${label + .toLowerCase() + .replace(/\s+/g, "-")}`; return (
{label} @@ -157,10 +167,23 @@ export function CoolifyCredentials({ The server Dyad set up
)} - - + + {server.password && ( - + )}
)} @@ -174,9 +197,18 @@ export function CoolifyCredentials({ The Coolify Dyad is connected to
)} - + {instance.apiToken && ( - + )}
)} diff --git a/src/ipc/types/coolify.ts b/src/ipc/types/coolify.ts index c7925780e8..df2a56cd01 100644 --- a/src/ipc/types/coolify.ts +++ b/src/ipc/types/coolify.ts @@ -89,7 +89,7 @@ export const CoolifyStatusSchema = z.object({ * tell that the token changed without ever being told what it is. */ tokenId: z.string().nullable(), - /** Remembered across a token clear, so re-entering a token does not retype it. */ + /** The Coolify Dyad is connected to, or null once it has been forgotten. */ instanceUrl: z.string().nullable(), /** * The address of a server Dyad set up and holds an admin account for, or From 2160a90b1063e1ee5f5b68926c0968f7d2a30c4e Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Mon, 24 Aug 2026 13:49:29 -0500 Subject: [PATCH 30/91] fix(coolify): tell apart the answers a domain lookup cannot give MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-way was still two-way underneath. domainCheckVerdict has its own "unknown", and folding anything that was not "points-elsewhere" into "points-here" swallowed it — so records that cannot be compared, an IPv4 server against a domain carrying only AAAA, read as pointing here. That is the case the certificate poll then takes the old machine's certificate for, which is what the guard exists to stop. Nothing to compare against is a different thing and still passes: that is the server's own name not resolving, and refusing there would block a setup over a private name. A bare address typed into the domain field now becomes the name the derived path would have used, rather than being asked about as though an authority would certify it — the same wait, spent for nothing. And a refusal names the domain that was refused instead of the address, which was fine. Also fixes a SshFailure that does not exist in a test added a commit ago, which broke `npm run ts` on this branch, and covers the flow's report of a dead link, which nothing held. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/api_token.test.ts | 6 ++-- src/coolify_setup/https_setup.test.ts | 20 +++++++++++++ src/coolify_setup/https_setup.ts | 27 +++++++++++++++-- src/coolify_setup/setup_flow.test.ts | 43 +++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/coolify_setup/api_token.test.ts b/src/coolify_setup/api_token.test.ts index 768ae1ac6d..3d68b84236 100644 --- a/src/coolify_setup/api_token.test.ts +++ b/src/coolify_setup/api_token.test.ts @@ -87,8 +87,10 @@ describe("readCoolifyVersion", () => { const session = { run: vi.fn(async () => { throw new SshError( - "connection-lost", - "connection lost", + // The connection stopped answering, which is the case this tells + // apart from a bound Dyad set on one command. + "timeout", + "the connection stopped answering", DyadErrorKind.External, ); }), diff --git a/src/coolify_setup/https_setup.test.ts b/src/coolify_setup/https_setup.test.ts index 6f5022d71c..1b819bfee0 100644 --- a/src/coolify_setup/https_setup.test.ts +++ b/src/coolify_setup/https_setup.test.ts @@ -174,6 +174,14 @@ describe("certificateDomainFor, for a domain given by hand", () => { expect(certificateDomainFor("203.0.113.5", "192.168.1.10")).toBeNull(); }); + it("turns a bare address into the name the derived path would use", async () => { + // No authority certifies a bare address, so asking spends the whole wait + // on a refusal — while the same address has a spelling that is a name. + expect(certificateDomainFor("203.0.113.5", "203.0.113.5")).toBe( + "203.0.113.5.sslip.io", + ); + }); + it("keeps a name that could be validated", async () => { expect(certificateDomainFor("203.0.113.5", "coolify.example.com")).toBe( "coolify.example.com", @@ -218,6 +226,18 @@ describe("domainPointsAtServer", () => { ).toBe("unknown"); }); + it("says it does not know when the records cannot be compared", async () => { + // An IPv4 server and a domain carrying only an AAAA record. There is no + // overlap to find by construction, so this is no more an answer about + // where the domain points than a resolver that never replied — and the + // certificate poll after it would take the old machine for proof. + expect( + await domainPointsAtServer("coolify.example.com", "203.0.113.5", { + resolve: answers(["2001:db8::9"]), + }), + ).toBe("unknown"); + }); + it("does not object when the domain has no records yet", async () => { // The resolver answered; the name simply has nothing yet. It may be // minutes old, and the certificate wait is the real answer. diff --git a/src/coolify_setup/https_setup.ts b/src/coolify_setup/https_setup.ts index 01ce81674e..c1adce3557 100644 --- a/src/coolify_setup/https_setup.ts +++ b/src/coolify_setup/https_setup.ts @@ -69,7 +69,15 @@ export function certificateDomainFor( if (isLoopbackAddress(bareCustom) || /\.local$/i.test(bareCustom)) { return null; } - if (isIP(bareCustom) === 4 && isNonRoutableAddress(bareCustom)) return null; + if (isIP(bareCustom) === 4) { + if (isNonRoutableAddress(bareCustom)) return null; + // A bare address is not a name any authority will certify, so asking + // for one spends the whole wait on a refusal. The derived spelling of + // the same address is a name, and is what the address would have been + // turned into had it been left out of the domain field. + return `${bareCustom}.sslip.io`; + } + if (isIP(bareCustom) === 6) return null; return bareCustom; } @@ -239,7 +247,15 @@ export async function domainPointsAtServer( expectedIps, actualIps: resolved.addresses, }); - return verdict === "points-elsewhere" ? "points-elsewhere" : "points-here"; + if (verdict === "points-elsewhere") return "points-elsewhere"; + // Two different things arrive as "unknown". With no expected addresses it + // is the server's own name that did not resolve, which is deliberately not + // an objection — refusing there would block a setup over a private name. + // With addresses on both sides it is records that cannot be compared, + // because they are in different families, and that is no more an answer + // about where the domain points than a resolver that never replied. + if (verdict === "unknown" && expectedIps.length > 0) return "unknown"; + return "points-here"; } export interface HttpsOutcome { @@ -300,7 +316,12 @@ export async function tryEnableHttps( return { instanceUrl: plainUrlFor(host), secure: false, - reason: "This address cannot be given a certificate.", + // Says which of the two was refused. Blaming the address for a domain + // the user typed sends them to check the thing that was fine. + reason: customDomain?.trim() + ? `${customDomain.trim()} cannot be given a certificate, because ` + + `nothing on the public internet can reach it to check.` + : "This address cannot be given a certificate.", }; } diff --git a/src/coolify_setup/setup_flow.test.ts b/src/coolify_setup/setup_flow.test.ts index dd6913e767..18d5fa7739 100644 --- a/src/coolify_setup/setup_flow.test.ts +++ b/src/coolify_setup/setup_flow.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { runServerSetup, type SetupStep } from "./setup_flow"; import { waitForAdminSeeded } from "./install"; import { tryEnableHttps } from "./https_setup"; +import { DyadErrorKind } from "@/errors/dyad_error"; +import { SshError } from "@/ipc/utils/ssh_client"; import type { SshSession } from "@/ipc/utils/ssh_client"; const REAL_TOKEN = "1|EcaUxT43T5fgdLJmnYj0702tEUC6viy5jEhO3Ujk2298db95"; @@ -457,6 +459,47 @@ describe("runServerSetup", () => { expect(bad.session.end).toHaveBeenCalled(); }); + it("says the link died rather than blaming the version for it", async () => { + // Answering null for a dead connection sends the user to the screen that + // tells them their freshly installed Coolify is too old to drive — for a + // question that never reached it. The install still stands either way. + const server = fakeServer(); + server.session.run = vi.fn( + async (command: string, options?: { input?: string }) => { + const script = options?.input ?? ""; + if (script.includes("constants.coolify.version")) { + throw new SshError( + "timeout", + "the connection stopped answering", + DyadErrorKind.External, + ); + } + if (command.includes("MemTotal")) { + return { + code: 0, + stdout: "os=ubuntu\nmem=1967\ndir=no\ncontainer=\nbusy=no", + stderr: "", + }; + } + if (script.includes("->exists()")) { + return { code: 0, stdout: transcript("yes"), stderr: "" }; + } + if (script.includes("setupDynamicProxyConfiguration")) { + return { code: 0, stdout: transcript("applied"), stderr: "" }; + } + return { code: 0, stdout: "", stderr: "" }; + }, + ) as unknown as SshSession["run"]; + + const result = await run(server).promise; + + expect(result.token).toBeNull(); + expect(result.tokenUnavailableReason).toBe( + "Coolify did not answer while Dyad was opening its API.", + ); + expect(result.credentials.password).toBeTruthy(); + }); + it("passes cancellation through rather than reporting it as a token problem", async () => { // A cancelled setup is the user's decision, not an instance that could not // be driven — reporting it as the latter would claim a server exists. From c5178e00998443fc76863a48e6727af62af86574 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Mon, 24 Aug 2026 14:08:54 -0500 Subject: [PATCH 31/91] fix(coolify): give each refusal the remedy that fits it A lookup that never answered and records that cannot be compared were both reported as Dyad being unable to look up where the domain points, and both told the user to check it resolves to the server's address. For the second that is untrue and the advice is wrong: the lookup worked, and a domain carrying only IPv6 records may be pointing at the very machine given here by its IPv4 address. Each says what happened and what would settle it. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/https_setup.test.ts | 29 +++++++++++++++++++++++++-- src/coolify_setup/https_setup.ts | 26 ++++++++++++++++++++---- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/coolify_setup/https_setup.test.ts b/src/coolify_setup/https_setup.test.ts index 1b819bfee0..11ed14d072 100644 --- a/src/coolify_setup/https_setup.test.ts +++ b/src/coolify_setup/https_setup.test.ts @@ -223,7 +223,7 @@ describe("domainPointsAtServer", () => { await domainPointsAtServer("example.com", "203.0.113.5", { resolve: answers([], true), }), - ).toBe("unknown"); + ).toBe("no-answer"); }); it("says it does not know when the records cannot be compared", async () => { @@ -235,7 +235,7 @@ describe("domainPointsAtServer", () => { await domainPointsAtServer("coolify.example.com", "203.0.113.5", { resolve: answers(["2001:db8::9"]), }), - ).toBe("unknown"); + ).toBe("different-families"); }); it("does not object when the domain has no records yet", async () => { @@ -331,6 +331,31 @@ describe("tryEnableHttps", () => { expect(result.reason).toMatch(/could not look up where/i); }); + it("says which of the two it could not settle", async () => { + // Two refusals with two different remedies. Telling someone whose domain + // resolved fine to go and check that it resolves sends them after the + // thing that was working. + const { session } = fakeSession(); + const noAnswer = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "coolify.example.com", + resolve: async () => ({ addresses: [], failed: true }), + check: async () => true, + }); + const crossFamily = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "coolify.example.com", + resolve: async () => ({ addresses: ["2001:db8::9"], failed: false }), + check: async () => true, + }); + + expect(noAnswer.secure).toBe(false); + expect(noAnswer.reason).toMatch(/could not look up where/i); + expect(crossFamily.secure).toBe(false); + expect(crossFamily.reason).toMatch(/only IPv6 records/i); + expect(crossFamily.reason).not.toMatch(/could not look up/i); + }); + it("still accepts a custom domain whose name simply has no records yet", async () => { // The resolver answered. A name minutes old has nothing to say and the // certificate wait is the real test, which is not the same as Dyad never diff --git a/src/coolify_setup/https_setup.ts b/src/coolify_setup/https_setup.ts index c1adce3557..f10470ebf5 100644 --- a/src/coolify_setup/https_setup.ts +++ b/src/coolify_setup/https_setup.ts @@ -228,7 +228,9 @@ export async function domainPointsAtServer( resolve = resolveBoth, hostAddresses, }: { resolve?: typeof resolveBoth; hostAddresses?: string[] } = {}, -): Promise<"points-here" | "points-elsewhere" | "unknown"> { +): Promise< + "points-here" | "points-elsewhere" | "no-answer" | "different-families" +> { // A server known by a name is resolved to the addresses it stands for, so // the domain is compared against the same thing either way. A name that // does not resolve leaves nothing to compare, which the verdict below reads @@ -242,7 +244,7 @@ export async function domainPointsAtServer( // arrive with nothing to compare, but only the second is the domain saying // where it points — the first is Dyad not having asked successfully, which // the caller has to be able to tell apart from an answer. - if (resolved.failed) return "unknown"; + if (resolved.failed) return "no-answer"; const verdict = domainCheckVerdict({ expectedIps, actualIps: resolved.addresses, @@ -254,7 +256,9 @@ export async function domainPointsAtServer( // With addresses on both sides it is records that cannot be compared, // because they are in different families, and that is no more an answer // about where the domain points than a resolver that never replied. - if (verdict === "unknown" && expectedIps.length > 0) return "unknown"; + if (verdict === "unknown" && expectedIps.length > 0) { + return "different-families"; + } return "points-here"; } @@ -362,7 +366,7 @@ export async function tryEnableHttps( // domain still pointing at a machine the user is moving off would be // taken as proof, and its address stored as the instance to send the API // token to on every deploy. - if (points === "unknown") { + if (points === "no-answer") { return { instanceUrl: plainUrlFor(host), secure: false, @@ -372,6 +376,20 @@ export async function tryEnableHttps( `the domain resolves to ${host} and try again.`, }; } + // Looked it up and got an answer, in a family the server's address says + // nothing about. Telling the user to check the domain resolves would send + // them after something that may be perfectly correct. + if (points === "different-families") { + return { + instanceUrl: plainUrlFor(host), + secure: false, + reason: + `${domain} has only IPv6 records and this server was given as ` + + `${host}, so Dyad cannot tell whether they are the same machine. ` + + `Give the server's address in the same family, or set the domain ` + + `in Coolify yourself.`, + }; + } } const url = httpsUrlFor(domain); From 9a5efeb0b1b4843b0b7b7c2a207b29c0f09a2e1d Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Mon, 24 Aug 2026 14:29:25 -0500 Subject: [PATCH 32/91] fix(coolify): stop the family refusal claiming what it did not establish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It named the domain as the side holding IPv6 records. Either side can be, and when the server is the one reached by a v6-only name the sentence is false in both halves and its remedy — give the server's address in the same family — is what the user already did. It also read an answer as a complete one. resolveBoth reports failure only when both families come back empty, so a v4 lookup that never answered beside a v6 lookup that did arrives as an answer, and the domain may hold records for the family Dyad never saw. Neither side is named now, and the sentence says what happened: nothing to compare across. And a name Dyad derived from this server is no longer asked about. An address typed into the domain field becomes that server's own sslip.io spelling, which resolves to it by construction, so a flaky resolver was turning a setup that works into a refusal whose advice was to fix a name that is already right. An address typed there that is some other machine still resolves elsewhere, and is still refused. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/https_setup.test.ts | 68 ++++++++++++++++++++++++++- src/coolify_setup/https_setup.ts | 27 +++++++---- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/coolify_setup/https_setup.test.ts b/src/coolify_setup/https_setup.test.ts index 11ed14d072..a6f9fda7a4 100644 --- a/src/coolify_setup/https_setup.test.ts +++ b/src/coolify_setup/https_setup.test.ts @@ -331,6 +331,20 @@ describe("tryEnableHttps", () => { expect(result.reason).toMatch(/could not look up where/i); }); + it("names the domain it refused rather than the address", async () => { + // The address was fine; it was the domain the user typed that could not + // be certified, and blaming the address sends them to check that. + const { session } = fakeSession(); + const result = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "coolify.local", + check: async () => true, + }); + + expect(result.secure).toBe(false); + expect(result.reason).toMatch(/^coolify\.local cannot be given/i); + }); + it("says which of the two it could not settle", async () => { // Two refusals with two different remedies. Telling someone whose domain // resolved fine to go and check that it resolves sends them after the @@ -352,10 +366,62 @@ describe("tryEnableHttps", () => { expect(noAnswer.secure).toBe(false); expect(noAnswer.reason).toMatch(/could not look up where/i); expect(crossFamily.secure).toBe(false); - expect(crossFamily.reason).toMatch(/only IPv6 records/i); + expect(crossFamily.reason).toMatch(/different families/i); expect(crossFamily.reason).not.toMatch(/could not look up/i); }); + it("does not say which side holds which family", async () => { + // The server is the IPv6 side here. A message naming the domain as the + // one with IPv6 records would be false, and its remedy — give the + // server's address in the same family — is what the user already did. + const { session } = fakeSession(); + const result = await tryEnableHttps(session, "box.example.com", { + ...FAST, + customDomain: "coolify.example.com", + resolve: async (name: string) => ({ + addresses: + name === "box.example.com" ? ["2001:db8::1"] : ["203.0.113.5"], + failed: false, + }), + check: async () => true, + }); + + expect(result.secure).toBe(false); + expect(result.reason).not.toMatch(/only IPv6/i); + expect(result.reason).toMatch(/different families/i); + }); + + it("does not ask DNS about a name it derived from this server", async () => { + // The sslip.io spelling of the host resolves to the host by construction, + // so checking it can only fail for reasons that have nothing to do with + // where it points — and the advice would be to fix a name that is right. + const { session } = fakeSession(); + const result = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "203.0.113.5", + resolve: async () => ({ addresses: [], failed: true }), + check: async () => true, + }); + + expect(result.secure).toBe(true); + expect(result.instanceUrl).toBe("https://203.0.113.5.sslip.io"); + }); + + it("still checks an address typed there that is not this server", async () => { + // Derived the same way, but from somewhere else — so it resolves + // somewhere else by construction, which is exactly what to object to. + const { session } = fakeSession(); + const result = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "198.51.100.9", + resolve: async () => ({ addresses: ["198.51.100.9"], failed: false }), + check: async () => true, + }); + + expect(result.secure).toBe(false); + expect(result.reason).toMatch(/does not point at this server/i); + }); + it("still accepts a custom domain whose name simply has no records yet", async () => { // The resolver answered. A name minutes old has nothing to say and the // certificate wait is the real test, which is not the same as Dyad never diff --git a/src/coolify_setup/https_setup.ts b/src/coolify_setup/https_setup.ts index f10470ebf5..ba8c872561 100644 --- a/src/coolify_setup/https_setup.ts +++ b/src/coolify_setup/https_setup.ts @@ -343,9 +343,13 @@ export async function tryEnableHttps( }; } - // Only a domain of the user's own can point somewhere else. The derived - // sslip.io name resolves to the address it was built from, by construction. - if (customDomain) { + // Only a domain of the user's own can point somewhere else. A derived + // sslip.io name resolves to the address it was built from, by construction + // — including one derived from an address typed into the domain field, + // which is why this asks what the domain turned out to be rather than + // whether the field was filled. An address typed there that is not this + // server is still the user's own, and is still checked. + if (customDomain && domain !== `${host}.sslip.io`) { const points = await domainPointsAtServer(domain, host, { resolve, hostAddresses, @@ -376,18 +380,21 @@ export async function tryEnableHttps( `the domain resolves to ${host} and try again.`, }; } - // Looked it up and got an answer, in a family the server's address says - // nothing about. Telling the user to check the domain resolves would send - // them after something that may be perfectly correct. + // Addresses came back for both, with no family in common — so there is + // nothing to compare, and which side is which varies. Naming a family + // here, or saying the domain has only those records, would state as fact + // something never established: one family's lookup can fail while the + // other answers, and resolveBoth reports that as an answer. if (points === "different-families") { return { instanceUrl: plainUrlFor(host), secure: false, reason: - `${domain} has only IPv6 records and this server was given as ` + - `${host}, so Dyad cannot tell whether they are the same machine. ` + - `Give the server's address in the same family, or set the domain ` + - `in Coolify yourself.`, + `Dyad could not compare where ${domain} points with ${host}: the ` + + `addresses it has for them are in different families, so neither ` + + `says anything about the other. Give the server's address in the ` + + `same family as the domain's records, or set the domain in ` + + `Coolify yourself.`, }; } } From f179121a68e7acb1d6da5aabea49ef22427f6c3c Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Mon, 24 Aug 2026 17:15:54 -0500 Subject: [PATCH 33/91] fix(coolify): stop a server that will not answer reporting itself as a fault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit classify maps unreachable and timeout to External, which telemetry does not filter, so a mistyped address or a firewalled port 22 sent a PostHog $exception on every check — with whatever the user typed in the message. The same call over HTTP is already treated as the user's own network rather than a fault here; SSH was never wired into it. Only the two that say what went wrong. "unknown" is the bucket for a failure nothing here recognised, which is the kind worth hearing about. Co-Authored-By: Claude Opus 5 --- src/ipc/utils/telemetry.test.ts | 38 +++++++++++++++++++++++++++++++++ src/ipc/utils/telemetry.ts | 13 +++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/ipc/utils/telemetry.test.ts b/src/ipc/utils/telemetry.test.ts index 917db82dcc..57b94e0fb9 100644 --- a/src/ipc/utils/telemetry.test.ts +++ b/src/ipc/utils/telemetry.test.ts @@ -35,6 +35,44 @@ describe("shouldFilterTelemetryException", () => { ).toBe(true); }); + it("does not report a server that could not be reached over SSH", async () => { + // A mistyped address or a firewalled port 22 is the user's own network, + // and the message carries whatever they typed. + const { SshError } = await import("@/ipc/utils/ssh_client"); + expect( + shouldFilterTelemetryException( + new SshError( + "unreachable", + "Could not reach the server (ENOTFOUND).", + DyadErrorKind.External, + ), + ), + ).toBe(true); + expect( + shouldFilterTelemetryException( + new SshError( + "timeout", + "The server did not answer in time.", + DyadErrorKind.External, + ), + ), + ).toBe(true); + }); + + it("still reports an SSH failure nothing here recognised", async () => { + // The bucket for what was not classified is the one worth hearing about. + const { SshError } = await import("@/ipc/utils/ssh_client"); + expect( + shouldFilterTelemetryException( + new SshError( + "unknown", + "Could not connect over SSH: something new", + DyadErrorKind.External, + ), + ), + ).toBe(false); + }); + it("filters RateLimitError 429s from retryWithRateLimit", () => { const error = new Error("Rate limited (429): Too Many Requests"); error.name = "RateLimitError"; diff --git a/src/ipc/utils/telemetry.ts b/src/ipc/utils/telemetry.ts index f8428369b4..5189253f25 100644 --- a/src/ipc/utils/telemetry.ts +++ b/src/ipc/utils/telemetry.ts @@ -6,6 +6,7 @@ import { } from "@/errors/dyad_error"; import { isGenericFetchFailedError } from "@/lib/posthogTelemetry"; import { TelemetryEventPayload } from "@/ipc/types"; +import { SshError } from "@/ipc/utils/ssh_client"; import { COOLIFY_REQUEST_ERROR_NAME, COOLIFY_TRANSPORT_ERROR_NAME, @@ -127,6 +128,18 @@ export function shouldFilterTelemetryException(error: unknown): boolean { return true; } + // The same thing again over SSH rather than HTTP: an address that does not + // answer, or a port nobody is listening on, is the user's own network being + // reported as a fault here — and the message carries whatever they typed. + // Only the two that say what went wrong. "unknown" is the bucket for a + // failure nothing here recognised, which is what telemetry is for. + if ( + error instanceof SshError && + (error.failure === "unreachable" || error.failure === "timeout") + ) { + return true; + } + if (error instanceof DyadError) { return isDyadErrorKindFilteredFromTelemetry(error.kind); } From e2eadd9334a4408c33c5fc958375eb18ea4672a4 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Mon, 24 Aug 2026 17:20:04 -0500 Subject: [PATCH 34/91] fix(coolify): put the password down before the installer uses it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dyad invents the admin password rather than discovering it, so it is known a moment before the installer writes it into Coolify's own .env. Nothing was stored until the server reported the account back, minutes later — and anything that ended the process in between took the only copy with it, leaving a running Coolify nobody has the password for and a preflight that refuses to install over the container. It goes down before the run now. A run that ends without ever seeding an account puts back whatever stood there before, so a server that never got one leaves no password behind for it — and an account belonging to a server set up earlier, still the only copy of its own password, is not taken along with it. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/setup_flow.test.ts | 32 +++++++++++ src/coolify_setup/setup_flow.ts | 21 ++++++++ .../handlers/coolify_setup_handlers.test.ts | 54 ++++++++++++++++++- src/ipc/handlers/coolify_setup_handlers.ts | 47 ++++++++++++++++ 4 files changed, 152 insertions(+), 2 deletions(-) diff --git a/src/coolify_setup/setup_flow.test.ts b/src/coolify_setup/setup_flow.test.ts index 18d5fa7739..d37cfa71e3 100644 --- a/src/coolify_setup/setup_flow.test.ts +++ b/src/coolify_setup/setup_flow.test.ts @@ -459,6 +459,38 @@ describe("runServerSetup", () => { expect(bad.session.end).toHaveBeenCalled(); }); + it("hands over the password before the installer is asked to use it", async () => { + // The installer writes it into the server's .env partway through a run of + // minutes. Anything that ends the process in between takes the only copy + // with it, and preflight then refuses to install over the container. + const server = fakeServer(); + const seen: Array<{ password: string; at: number }> = []; + let installs = 0; + const original = server.session.run; + server.session.run = vi.fn( + async (command: string, options?: { input?: string }) => { + if (command.includes("bash -s")) installs += 1; + return (original as unknown as typeof server.session.run)( + command, + options, + ); + }, + ) as unknown as SshSession["run"]; + + await run(server, { + onCredentialsBuilt: ({ + credentials, + }: { + credentials: { password: string }; + }) => seen.push({ password: credentials.password, at: installs }), + }).promise; + + expect(seen).toHaveLength(1); + expect(seen[0].password).toBeTruthy(); + // Before the installer had run, not after. + expect(seen[0].at).toBe(0); + }); + it("says the link died rather than blaming the version for it", async () => { // Answering null for a dead connection sends the user to the screen that // tells them their freshly installed Coolify is too old to drive — for a diff --git a/src/coolify_setup/setup_flow.ts b/src/coolify_setup/setup_flow.ts index 2c7b2d5d51..7a085d7c69 100644 --- a/src/coolify_setup/setup_flow.ts +++ b/src/coolify_setup/setup_flow.ts @@ -102,6 +102,19 @@ export interface SetupOptions { credentials: AdminCredentials; dashboardUrl: string; }) => void; + /** + * The password Dyad is about to give the server, before it is given. + * + * Dyad invents this rather than discovering it, so it is knowable a moment + * earlier than the account is — and the installer writes it into Coolify's + * own .env partway through a run that takes minutes. Quitting in between + * would otherwise leave a server nobody has the password for, which + * preflight then refuses to install over. + */ + onCredentialsBuilt?: (account: { + credentials: AdminCredentials; + dashboardUrl: string; + }) => void; } export async function runServerSetup({ @@ -112,6 +125,7 @@ export async function runServerSetup({ signal, connect, onAccountKnown, + onCredentialsBuilt, recoveryProbeTimeoutMs = RECOVERY_PROBE_TIMEOUT_MS, waitForDashboardImpl = waitForDashboard, waitForAdminSeededImpl = waitForAdminSeeded, @@ -135,6 +149,13 @@ export async function runServerSetup({ } const credentials = buildAdminCredentials(adminEmail); + // Before the installer, not after it. What it is about to write into the + // server's .env is already decided here, and the run that writes it is + // where a crash costs the only copy. + onCredentialsBuilt?.({ + credentials, + dashboardUrl: plainUrlFor(target.host), + }); report("installing"); try { diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts index 585c07a780..82d558afa7 100644 --- a/src/ipc/handlers/coolify_setup_handlers.test.ts +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -102,6 +102,17 @@ vi.mock("@/coolify_setup/setup_flow", () => ({ runServerSetup: vi.fn(async (options: Record) => { h.runCalls += 1; h.lastSetupOptions = options; + // The real flow hands over the password before the installer runs, since + // it invented it rather than discovering it. + ( + options.onCredentialsBuilt as (a: { + credentials: { email: string; password: string }; + dashboardUrl: string; + }) => void + )({ + credentials: { email: "me@gmail.com", password: "Abc123@xyz" }, + dashboardUrl: "http://203.0.113.5:8000", + }); // The real flow reports the account the moment it exists, before the // steps that can still fail. if (h.reportsAccount) { @@ -515,12 +526,51 @@ describe("run", () => { expect(saved.coolify.admin?.instanceUrl).toBe("http://203.0.113.5:8000"); }); - it("writes nothing when the failure came before any account", async () => { + it("leaves no account behind for a server that never got one", async () => { + // The password goes down before the installer runs, so a run that ends + // without ever seeding an account has to take it back off — it opens + // nothing, and leaving it would stand in the way of installing again. h.reportsAccount = false; h.setupError = new Error("This server cannot be set up automatically."); await checkThenRun().catch(() => {}); - expect(h.written).toHaveLength(0); + const saved = h.written.at(-1) as { coolify: { admin?: unknown } }; + expect(saved.coolify.admin).toBeUndefined(); + }); + + it("has the password down before the installer is finished with it", async () => { + // The installer writes it into Coolify's own .env partway through a run + // that takes minutes. Quitting in between is what loses the only copy. + h.reportsAccount = false; + h.setupError = new Error("boom"); + await checkThenRun().catch(() => {}); + + const early = h.written[0] as { + coolify: { admin?: { password?: { value: string } } }; + }; + expect(early.coolify.admin?.password?.value).toBeTruthy(); + }); + + it("puts back an earlier server's account when this one never appeared", async () => { + // A failure here can follow a server set up before it, whose password is + // still the only copy of its own. Clearing outright would take that too. + h.settings = { + coolify: { + admin: { + email: "me@gmail.com", + password: { value: "TheEarlierOne" }, + instanceUrl: "http://198.51.100.9:8000", + }, + }, + } as Record; + h.reportsAccount = false; + h.setupError = new Error("boom"); + await checkThenRun().catch(() => {}); + + const saved = h.written.at(-1) as { + coolify: { admin?: { password?: { value: string } } }; + }; + expect(saved.coolify.admin?.password?.value).toBe("TheEarlierOne"); }); it("refuses a second setup on a different machine", async () => { diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index e3837340cd..b041b6f653 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -10,6 +10,7 @@ import { import type { SetupServer, SetupSnapshot } from "../types/coolify_setup"; import { safeSend } from "../utils/safe_sender"; import { readSettings, writeSettings } from "@/main/settings"; +import type { Coolify } from "@/lib/schemas"; import { SshError, connectSsh, @@ -93,6 +94,10 @@ function setupController(): CoolifySetupController { * starts to matter, which turns a keychain that was briefly busy into * nothing at all. */ + /** Whether the server ever reported an account, so a run that ended + before one existed can put back what it wrote on the way in. */ + let accountConfirmed = false; + let adminBeforeRun: Coolify["admin"]; let unsavedAccount: { credentials: { email: string; password: string }; dashboardUrl: string; @@ -110,6 +115,31 @@ function setupController(): CoolifySetupController { connect: (t, verify, signal): Promise => connectSsh(t, verify, { signal }), onProgress: ({ step, output }) => hooks.onProgress(step, output), + // Written before the installer runs, because the password it is about + // to put in the server's .env is already decided — and a run that ends + // without reaching the code below is exactly how the only copy of it + // gets lost. Put back on the way out if no account ever appeared, so a + // server that never got one does not leave a password behind for it. + onCredentialsBuilt: ({ credentials, dashboardUrl }) => { + try { + const current = readSettings().coolify; + adminBeforeRun = current?.admin; + writeSettings({ + coolify: { + ...current, + admin: { + email: credentials.email, + password: { value: credentials.password }, + instanceUrl: dashboardUrl, + }, + }, + }); + } catch (error) { + // The run is worth more than this record. Failing here only means + // the account has to be caught by the write below instead. + logger.error("Could not store the admin account early", error); + } + }, // Written the moment the account exists rather than at the end. A // server whose dashboard never answers still has this account on it, // and Dyad is the only thing that knows the password it invented. @@ -126,6 +156,7 @@ function setupController(): CoolifySetupController { }, }); unsavedAccount = null; + accountConfirmed = true; } catch (error) { // The account exists on the server whatever happened here, and a // second attempt is refused because Coolify is now installed. The @@ -157,6 +188,22 @@ function setupController(): CoolifySetupController { } catch (retryError) { logger.error("Could not store the admin account", retryError); } + } else if (!accountConfirmed) { + // Nothing was ever seeded, so the password written on the way in + // opens nothing. Put back whatever stood before rather than + // clearing outright: a failure here can follow a server that was + // set up earlier, and that one's account is still the only copy + // of its own password. + try { + writeSettings({ + coolify: { ...readSettings().coolify, admin: adminBeforeRun }, + }); + } catch (restoreError) { + logger.error( + "Could not put back the admin account", + restoreError, + ); + } } // A key that does not match is not the user declining, and reporting // it as one would file it as a cancellation and say nothing. From 0d895f40bf384ad98e228d4e9b7042228e7aedcf Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Mon, 24 Aug 2026 17:21:21 -0500 Subject: [PATCH 35/91] fix(coolify): stop accepting a domain there was nothing to compare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A server reached by a name plain DNS cannot see — an /etc/hosts entry, a search-domain name — left nothing on our side to hold the domain against, and that fell through as though the domain had been checked. It had not been checked at all, which is the whole of what the function is for, and the certificate poll after it settles for any address answering with a certificate it trusts. The earlier reasoning was that refusing blocks a setup over a private name. It does, and that is the smaller loss: such a setup keeps a working plain-HTTP address and can have the domain set in Coolify by hand, where the alternative is the API token going to whatever the domain points at. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/https_setup.test.ts | 32 ++++++++++++++++++++++----- src/coolify_setup/https_setup.ts | 16 ++++++++------ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/coolify_setup/https_setup.test.ts b/src/coolify_setup/https_setup.test.ts index a6f9fda7a4..6ee5c79828 100644 --- a/src/coolify_setup/https_setup.test.ts +++ b/src/coolify_setup/https_setup.test.ts @@ -273,10 +273,11 @@ describe("domainPointsAtServer", () => { ).toBe("points-here"); }); - it("does not object when the server's own name does not resolve", async () => { - // A different case from the domain's own lookup failing: there is nothing - // to compare against rather than no answer about where the domain points, - // and refusing here would block a setup over a private name. + it("says it does not know when the server's own name does not resolve", async () => { + // A name only this machine or this network answers to: connectSsh reaches + // it, plain DNS cannot see it, and there is nothing to hold the domain + // against. Accepting compared the user's domain with nothing at all — + // and the certificate poll afterwards takes any trusted answer for proof. const nothingForTheServer = async (target: string) => ({ addresses: target === "box.internal" ? [] : ["198.51.100.9"], failed: target === "box.internal", @@ -286,7 +287,7 @@ describe("domainPointsAtServer", () => { await domainPointsAtServer("coolify.example.com", "box.internal", { resolve: nothingForTheServer, }), - ).toBe("points-here"); + ).toBe("no-answer"); }); }); @@ -370,6 +371,27 @@ describe("tryEnableHttps", () => { expect(crossFamily.reason).not.toMatch(/could not look up/i); }); + it("refuses a custom domain when the server's name says nothing", async () => { + // The server answers to a name plain DNS cannot see. There is nothing to + // hold the domain against, and the poll below settles for any address + // serving a trusted certificate — so a domain pointing at the machine + // being moved off would be taken for proof. + const { session } = fakeSession(); + const result = await tryEnableHttps(session, "box.internal", { + ...FAST, + customDomain: "coolify.example.com", + resolve: async (name: string) => ({ + addresses: name === "box.internal" ? [] : ["198.51.100.9"], + failed: name === "box.internal", + }), + check: async () => true, + }); + + expect(result.secure).toBe(false); + expect(result.instanceUrl).toBe("http://box.internal:8000"); + expect(result.reason).toMatch(/could not look up where/i); + }); + it("does not say which side holds which family", async () => { // The server is the IPv6 side here. A message naming the domain as the // one with IPv6 records would be false, and its remedy — give the diff --git a/src/coolify_setup/https_setup.ts b/src/coolify_setup/https_setup.ts index ba8c872561..2f41b77836 100644 --- a/src/coolify_setup/https_setup.ts +++ b/src/coolify_setup/https_setup.ts @@ -250,14 +250,16 @@ export async function domainPointsAtServer( actualIps: resolved.addresses, }); if (verdict === "points-elsewhere") return "points-elsewhere"; - // Two different things arrive as "unknown". With no expected addresses it - // is the server's own name that did not resolve, which is deliberately not - // an objection — refusing there would block a setup over a private name. + // Two different things arrive as "unknown", and neither is an answer. + // // With addresses on both sides it is records that cannot be compared, - // because they are in different families, and that is no more an answer - // about where the domain points than a resolver that never replied. - if (verdict === "unknown" && expectedIps.length > 0) { - return "different-families"; + // because they are in different families. With none on ours it is the + // server's own name that did not resolve — a name only this machine or + // this network answers to, which plain DNS cannot see and connectSsh + // reaches anyway. Accepting there compared a user's domain against + // nothing at all, which is the whole of what this function is for. + if (verdict === "unknown") { + return expectedIps.length > 0 ? "different-families" : "no-answer"; } return "points-here"; } From 591f38a5d87b81b2addba2108202fd67f7e4e3ec Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Mon, 24 Aug 2026 17:27:28 -0500 Subject: [PATCH 36/91] feat(coolify): ask before keeping a token for an unencrypted address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving a token by hand refuses an address that is not encrypted unless the user says otherwise. Installing a server did not: the token is written when the run ends, before the finished screen has said anything, so the warning there described something already decided. The screen asks now, unticked, and Continue takes the token and the address back off unless it was agreed to. What is left is the state a run whose token could not be minted already produces — the account, the address on screen, and the token form a paste away — so nothing new had to be invented for the answer to mean something. Only those two fields go. The admin account is the way into a server that is running either way, and holding it sends nothing anywhere; the token is what would have crossed the network in the clear on every deploy. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyServerSetup.test.tsx | 51 +++++++++++++++++++ src/components/CoolifyServerSetup.tsx | 38 +++++++++++++- .../handlers/coolify_setup_handlers.test.ts | 32 ++++++++++++ src/ipc/handlers/coolify_setup_handlers.ts | 11 ++++ src/ipc/types/coolify_setup.ts | 14 +++++ 5 files changed, 144 insertions(+), 2 deletions(-) diff --git a/src/components/CoolifyServerSetup.test.tsx b/src/components/CoolifyServerSetup.test.tsx index 8d1012ba49..eadaf264c3 100644 --- a/src/components/CoolifyServerSetup.test.tsx +++ b/src/components/CoolifyServerSetup.test.tsx @@ -20,6 +20,7 @@ const h = vi.hoisted(() => ({ getServerKey: vi.fn(), snapshot: vi.fn(), dismiss: vi.fn(), + declineInsecureToken: vi.fn(), changedListeners: [] as Array<(state: unknown) => void>, inspect: vi.fn(), run: vi.fn(), @@ -54,6 +55,7 @@ vi.mock("@/ipc/types", () => ({ cancel: h.cancel, snapshot: h.snapshot, dismiss: h.dismiss, + declineInsecureToken: h.declineInsecureToken, }, events: { coolifySetup: { @@ -92,6 +94,7 @@ beforeEach(() => { h.changedListeners.length = 0; h.snapshot.mockResolvedValue(IDLE); h.dismiss.mockResolvedValue(undefined); + h.declineInsecureToken.mockResolvedValue(undefined); h.getServerKey.mockResolvedValue({ publicKey: PUBLIC_KEY }); h.cancel.mockResolvedValue(undefined); h.inspect.mockResolvedValue({ @@ -781,6 +784,54 @@ describe("when it finishes", () => { result: { ...DONE_RESULT, ...over }, }); + it("does not keep a token for an unencrypted address unless asked to", async () => { + // The token is written when the run ends, before anyone has read the + // warning. Continuing past it without a word is not agreement. + h.snapshot.mockResolvedValue( + doneState({ secure: false, insecureReason: "No certificate arrived." }), + ); + const user = userEvent.setup(); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-continue")).toBeTruthy(), + ); + await user.click(screen.getByTestId("coolify-setup-continue")); + + expect(h.declineInsecureToken).toHaveBeenCalled(); + }); + + it("keeps it when the address is agreed to", async () => { + h.snapshot.mockResolvedValue( + doneState({ secure: false, insecureReason: "No certificate arrived." }), + ); + const user = userEvent.setup(); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-accept-insecure")).toBeTruthy(), + ); + await user.click(screen.getByTestId("coolify-setup-accept-insecure")); + await user.click(screen.getByTestId("coolify-setup-continue")); + + expect(h.declineInsecureToken).not.toHaveBeenCalled(); + }); + + it("asks nothing when the address is encrypted", async () => { + // Nothing crosses the network in the clear, so there is no decision. + h.snapshot.mockResolvedValue(doneState()); + const user = userEvent.setup(); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-continue")).toBeTruthy(), + ); + expect(screen.queryByTestId("coolify-setup-accept-insecure")).toBeNull(); + await user.click(screen.getByTestId("coolify-setup-continue")); + + expect(h.declineInsecureToken).not.toHaveBeenCalled(); + }); + it("shows the details, since this is the moment they are needed", async () => { h.snapshot.mockResolvedValue(doneState({ tokenStored: false })); renderPanel(); diff --git a/src/components/CoolifyServerSetup.tsx b/src/components/CoolifyServerSetup.tsx index df67c72a3a..731f7c357c 100644 --- a/src/components/CoolifyServerSetup.tsx +++ b/src/components/CoolifyServerSetup.tsx @@ -2,6 +2,7 @@ import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Copy, Check, ServerCog } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ipc } from "@/ipc/types"; @@ -90,6 +91,9 @@ export function CoolifyServerSetup({ const [host, setHost] = useState(""); const [adminEmail, setAdminEmail] = useState(""); const [customDomain, setCustomDomain] = useState(""); + // Unticked every time the screen appears: it is about the address this run + // ended on, not a preference that outlives it. + const [acceptedInsecureToken, setAcceptedInsecureToken] = useState(false); // Kept with the address it was asked about. The answer arrives after a // round trip, and by then the address in the field may be a different @@ -163,7 +167,15 @@ export function CoolifyServerSetup({ }); /** Puts the finished screen away and lets the panel behind catch up. */ - const leaveResult = async (instanceUrl?: string) => { + const leaveResult = async ( + instanceUrl?: string, + { declineToken = false }: { declineToken?: boolean } = {}, + ) => { + // Before the queries are refreshed, so the panel behind never sees the + // token that is about to be taken back off. + if (declineToken) { + await ipc.coolifySetup.declineInsecureToken().catch(showError); + } // Refreshed before the screen is put away. Dismissing first hands the // panel back to a connector that still believes there is no token, so the // empty install form flashes up before the right screen arrives. @@ -233,6 +245,23 @@ export function CoolifyServerSetup({ crosses your network unencrypted every time it deploys. Adding a domain that points at this server fixes it.

+ {/* A decision rather than a notice, and only where there is one to + make: a token was created, and keeping it is what puts it on + the network. Unticked to start, so continuing without reading + this leaves Dyad unconnected rather than connected over a + address nobody agreed to. */} + {result.tokenStored && ( + + )}
)} {result.tokenStored ? ( @@ -258,7 +287,12 @@ export function CoolifyServerSetup({ )} {" "} - on a server you already have. + {status.serverUrl ? "Back to the installer" : "Set one up"} + + {status.serverUrl ? "." : " on a server you already have."}

)} diff --git a/src/hooks/useCoolifySetupSnapshot.test.tsx b/src/hooks/useCoolifySetupSnapshot.test.tsx new file mode 100644 index 0000000000..99553f2585 --- /dev/null +++ b/src/hooks/useCoolifySetupSnapshot.test.tsx @@ -0,0 +1,140 @@ +import { + QueryClient, + QueryClientProvider, + useQuery, +} from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { queryKeys } from "@/lib/queryKeys"; +import type { CoolifySetupState } from "@/coolify_setup/state"; + +const listeners = vi.hoisted( + () => ({ current: [] }) as { current: ((state: unknown) => void)[] }, +); +const snapshot = vi.hoisted(() => vi.fn()); +vi.mock("@/ipc/types", () => ({ + ipc: { + coolifySetup: { snapshot }, + events: { + coolifySetup: { + onChanged: (fn: (state: unknown) => void) => { + listeners.current.push(fn); + return () => { + listeners.current = listeners.current.filter((x) => x !== fn); + }; + }, + }, + }, + }, +})); + +const { useCoolifySetupSnapshot } = await import("./useCoolifySetupSnapshot"); + +const RUNNING: CoolifySetupState = { + type: "running", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + step: "installing", + log: "", + stopping: false, +}; + +function push(state: unknown) { + act(() => { + for (const fn of listeners.current) fn(state); + }); +} + +function makeWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const Wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + return { client, Wrapper }; +} + +/** + * How many times the status query has been read. + * + * A run writes the account in the main process, so the status this panel + * already holds is stale from that moment — and staleTime keeps it. Counting + * reads is the only way to tell a refresh from a value that merely looks + * right because the test seeded it that way. + */ +function trackStatus() { + const calls = { count: 0 }; + const useStatus = () => + useQuery({ + queryKey: queryKeys.coolify.status({ appId: 1 }), + queryFn: () => { + calls.count += 1; + return Promise.resolve({ serverUrl: "http://203.0.113.5:8000" }); + }, + // As the panel holds it: read once, then kept until something says + // otherwise. Without this the refetch below would prove nothing. + staleTime: 60_000, + }); + return { calls, useStatus }; +} + +beforeEach(() => { + listeners.current = []; + snapshot.mockReset(); + snapshot.mockResolvedValue({ type: "idle" }); +}); + +describe("when a run settles", () => { + it("refreshes what the rest of the panel knows about this Coolify", async () => { + // The account is written partway through a run, in the main process. The + // only refresh was on the way out of the finished screen, which a failure + // never reaches, so the panel went on believing there was no server — + // and hid the sign-out that the refusal to install again asks for. + const { Wrapper } = makeWrapper(); + const { calls, useStatus } = trackStatus(); + renderHook( + () => { + useCoolifySetupSnapshot(); + useStatus(); + }, + { wrapper: Wrapper }, + ); + await waitFor(() => expect(calls.count).toBe(1)); + + push(RUNNING); + // Nothing is written until a run settles, so nothing is stale yet. + expect(calls.count).toBe(1); + + push({ + type: "failed", + host: "203.0.113.5", + message: "boom", + cancelled: false, + }); + await waitFor(() => expect(calls.count).toBe(2)); + }); + + it("refreshes it after one that finished, too", async () => { + const { Wrapper } = makeWrapper(); + const { calls, useStatus } = trackStatus(); + renderHook( + () => { + useCoolifySetupSnapshot(); + useStatus(); + }, + { wrapper: Wrapper }, + ); + await waitFor(() => expect(calls.count).toBe(1)); + + push({ type: "done", host: "203.0.113.5", result: { dashboardUrl: "x" } }); + + await waitFor(() => expect(calls.count).toBe(2)); + }); +}); diff --git a/src/hooks/useCoolifySetupSnapshot.ts b/src/hooks/useCoolifySetupSnapshot.ts index 265b260bd0..1fd3420281 100644 --- a/src/hooks/useCoolifySetupSnapshot.ts +++ b/src/hooks/useCoolifySetupSnapshot.ts @@ -39,6 +39,23 @@ export function useCoolifySetupSnapshot() { return ipc.events.coolifySetup.onChanged((state) => { eventCount += 1; queryClient.setQueryData(queryKeys.coolify.setup, state); + // A run writes the account and the token in the main process, so what + // the rest of the panel believes about this Coolify is out of date the + // moment one settles. Only the way out of the finished screen refreshed + // it, which a failure never reaches — leaving the panel offering to + // install over a server whose password it is already holding. + if (state.type === "done" || state.type === "failed") { + // Everything about this Coolify except the snapshot itself, which + // was just handed to us. Refetching that would ask the main process + // to say again what it has already said, and a read still in flight + // from before is exactly how a finished run gets put back on a step + // it has left. + void queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === queryKeys.coolify.all[0] && + query.queryKey[1] !== queryKeys.coolify.setup[1], + }); + } }); }, [queryClient]); From d57c9acfefbd1a2df39262abefdc845b3f005c8e Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Wed, 26 Aug 2026 15:45:42 -0500 Subject: [PATCH 47/91] fix(coolify): do not read "cannot tell" as "nothing was installed" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.sh writes the admin password into Coolify's own .env partway through, so a failure after that leaves an account only Dyad knows the password for. The probe that decides whether to keep it answered the same way to "there is no Coolify here" and to "docker is wedged, I cannot say" — and a probe that timed out did not answer at all. Both now keep the record. Holding a password that opens nothing is something the user can sign out of; dropping the only copy of one that does is not recoverable, because preflight refuses to install again once the container exists. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/install.ts | 17 +++++++++++++++- src/coolify_setup/setup_flow.test.ts | 29 +++++++++++++++++++++++++++- src/coolify_setup/setup_flow.ts | 15 ++++++++++++-- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/coolify_setup/install.ts b/src/coolify_setup/install.ts index 4fa5608889..d4267e6f74 100644 --- a/src/coolify_setup/install.ts +++ b/src/coolify_setup/install.ts @@ -26,6 +26,16 @@ export interface Preflight { /** Present when ready is false, phrased for the user. */ reason?: string; alreadyInstalled: boolean; + /** + * Whether the probe actually settled the Coolify question. + * + * False means the evidence was unusable, not that the server is empty — + * docker wedged, or nothing read back at all. The two are the same + * `alreadyInstalled: false` and must not be treated alike: one of them is + * what stands between a user and installing over an instance that is + * already there, or dropping the only copy of a live admin password. + */ + installedKnown: boolean; memoryMb: number | null; } @@ -93,6 +103,7 @@ export async function preflight( return { ready: false, alreadyInstalled: false, + installedKnown: false, memoryMb: null, reason: "Dyad could not read anything back from this server. It answered the " + @@ -107,6 +118,7 @@ export async function preflight( return { ready: false, alreadyInstalled: false, + installedKnown: false, memoryMb: null, reason: "Docker is installed on this server but not responding, so Dyad " + @@ -124,6 +136,7 @@ export async function preflight( return { ready: false, alreadyInstalled, + installedKnown: true, memoryMb, reason: "This server is still finishing its own first-boot setup, which holds " + @@ -135,6 +148,7 @@ export async function preflight( return { ready: false, alreadyInstalled, + installedKnown: true, memoryMb, reason: "This server already has Coolify on it. Connect to it with an API token " + @@ -145,13 +159,14 @@ export async function preflight( return { ready: false, alreadyInstalled, + installedKnown: true, memoryMb, reason: `This server has ${memoryMb}MB of memory and Coolify needs about 2GB. ` + `Installing would finish and then fail to run.`, }; } - return { ready: true, alreadyInstalled, memoryMb }; + return { ready: true, alreadyInstalled, installedKnown: true, memoryMb }; } /** diff --git a/src/coolify_setup/setup_flow.test.ts b/src/coolify_setup/setup_flow.test.ts index ea60117cd0..2582e44880 100644 --- a/src/coolify_setup/setup_flow.test.ts +++ b/src/coolify_setup/setup_flow.test.ts @@ -281,9 +281,36 @@ describe("runServerSetup", () => { return original(command, options); }) as unknown as SshSession["run"]; + const seen: string[] = []; + await expect( + run(server, { + recoveryProbeTimeoutMs: 20, + onAccountKnown: ({ credentials }) => seen.push(credentials.password), + }).promise, + ).rejects.toThrow(/Installing Coolify failed/); + // A question that never came back is not an answer of "nothing was + // installed". install.sh may already have written this password into + // Coolify's .env, and dropping the only copy of it cannot be undone from + // here — preflight refuses to install over the container again. + expect(seen).toHaveLength(1); + }); + + it("hands the account over when the probe cannot tell either way", async () => { + // Docker is there but will not answer, so what it said about Coolify is + // not evidence. preflight reports that as the same "no Coolify here" as + // an empty server, and only one of those means the password is dead. + const server = fakeServer({ + installExit: 1, + probeAfterInstall: "os=ubuntu\nmem=1967\ndockerok=no\nbusy=no", + }); + const seen: string[] = []; await expect( - run(server, { recoveryProbeTimeoutMs: 20 }).promise, + run(server, { + onAccountKnown: ({ credentials }) => seen.push(credentials.password), + }).promise, ).rejects.toThrow(/Installing Coolify failed/); + + expect(seen).toHaveLength(1); }); it("hands the account over when a failed install still left Coolify there", async () => { diff --git a/src/coolify_setup/setup_flow.ts b/src/coolify_setup/setup_flow.ts index 867579022e..6380dfa6ea 100644 --- a/src/coolify_setup/setup_flow.ts +++ b/src/coolify_setup/setup_flow.ts @@ -180,14 +180,25 @@ export async function runServerSetup({ ); }), ]); - if (after.alreadyInstalled) { + // Or could not tell. "Docker is wedged, I cannot say" comes back + // from preflight as the same `alreadyInstalled: false` as an empty + // server, and only one of those means the password opens nothing. + if (after.alreadyInstalled || !after.installedKnown) { onAccountKnown?.({ credentials, dashboardUrl: plainUrlFor(target.host), }); } } catch { - // Nothing to add: the installer's own error is the one that matters. + // The probe never answered — it timed out, or the connection went + // with it. That is not an answer of "nothing was installed", and + // treating it as one takes the account off. Keeping a password that + // opens nothing is a nuisance the user can sign out of; dropping the + // only copy of one that does is not recoverable from here. + onAccountKnown?.({ + credentials, + dashboardUrl: plainUrlFor(target.host), + }); } finally { clearTimeout(timer); } From d58d5dc0b0788a8e5835bad47e737d4213f1e753 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Wed, 26 Aug 2026 15:45:51 -0500 Subject: [PATCH 48/91] fix(coolify): say what failed before saying what it means The sign-out dialog put "Signing out forgets it anyway" above the panel that reports the read it refers to, so the user met the consequence before the cause. tokenStored's comment described a nullable field on a boolean, and named the one reason of several it can be false. Co-Authored-By: Claude Opus 5 --- src/components/CoolifySignOutDialog.test.tsx | 8 ++++++++ src/components/CoolifySignOutDialog.tsx | 17 +++++++++-------- src/ipc/types/coolify_setup.ts | 8 +++++++- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/components/CoolifySignOutDialog.test.tsx b/src/components/CoolifySignOutDialog.test.tsx index 9e25d4bb7c..38b0a456d5 100644 --- a/src/components/CoolifySignOutDialog.test.tsx +++ b/src/components/CoolifySignOutDialog.test.tsx @@ -124,6 +124,14 @@ describe("nothing to look at yet", () => { expect( screen.queryAllByText(/could not read what it has stored/i), ).toHaveLength(1); + // And said after it. "Anyway" contrasts with a failure, so meeting it + // first leaves the user contrasting with nothing. + const cause = screen.getByText(/could not read what it has stored/i); + const addendum = screen.getByTestId("coolify-sign-out-unreadable"); + expect( + cause.compareDocumentPosition(addendum) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); }); it("keeps the details on screen when only a later read failed", async () => { diff --git a/src/components/CoolifySignOutDialog.tsx b/src/components/CoolifySignOutDialog.tsx index fbdc64baad..46ae5b90c4 100644 --- a/src/components/CoolifySignOutDialog.tsx +++ b/src/components/CoolifySignOutDialog.tsx @@ -87,14 +87,6 @@ export function CoolifySignOutDialog({ Looking up what Dyad has stored… )} - {readFailed && ( -
- Signing out forgets it anyway. -
- )} {passwordIsLocked && (
+ {readFailed && ( +
+ Signing out forgets it anyway. +
+ )} +
)} From 481ef6d6a688503bfb354d887693f7aa2b80e76a Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Thu, 27 Aug 2026 14:09:07 -0500 Subject: [PATCH 52/91] fix(coolify): ask whether the API was opened, not whether a token was kept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dyad opens Coolify's API and then mints a token against it. Keying the "enable the API" guidance on whether a token was kept got the common case right and the others wrong: a mint that fails on its own — an account with no team, a link that drops — leaves the API on and no token, and the panel sent the user to switch on something already switched on. A token minted but not saved on this computer did the same. The flow knew this and the screen did not, so it is on the result now. Reported once the server has confirmed it rather than when the attempt starts, so a failed opening is not announced as a success. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyServerSetup.test.tsx | 36 +++++++++++++++++++--- src/components/CoolifyServerSetup.tsx | 6 ++-- src/coolify_setup/api_token.ts | 9 +++++- src/coolify_setup/capabilities.test.ts | 1 + src/coolify_setup/controller.test.ts | 1 + src/coolify_setup/setup_flow.test.ts | 29 +++++++++++++++++ src/coolify_setup/setup_flow.ts | 12 ++++++++ src/coolify_setup/state.ts | 2 ++ src/coolify_setup/transition.test.ts | 1 + src/ipc/handlers/coolify_setup_handlers.ts | 1 + src/ipc/types/coolify_setup.ts | 8 +++++ 11 files changed, 98 insertions(+), 8 deletions(-) diff --git a/src/components/CoolifyServerSetup.test.tsx b/src/components/CoolifyServerSetup.test.tsx index f5372527c4..a5cdccf31d 100644 --- a/src/components/CoolifyServerSetup.test.tsx +++ b/src/components/CoolifyServerSetup.test.tsx @@ -769,6 +769,8 @@ describe("when it finishes", () => { adminEmail: "me@gmail.com", adminPassword: "Abc123@xyz", tokenStored: true, + // A token comes from a mint, and Dyad enables the API to reach one. + apiEnabled: true, tokenUnavailableReason: null, version: "4.3.2", }; @@ -912,16 +914,42 @@ describe("when it finishes", () => { it("says what is left to do when only the token step failed", async () => { h.snapshot.mockResolvedValue( - doneState({ tokenStored: false, tokenUnavailableReason: "too old" }), + doneState({ + tokenStored: false, + apiEnabled: false, + tokenUnavailableReason: "too old", + }), ); renderPanel(); await waitFor(() => expect(screen.getByTestId("coolify-setup-manual-token")).toBeTruthy(), ); - expect( - screen.getByTestId("coolify-setup-manual-token").textContent, - ).toContain("too old"); + const panel = screen.getByTestId("coolify-setup-manual-token"); + expect(panel.textContent).toContain("too old"); + // Nothing was switched on, so switching it on is still to do. + expect(panel.textContent).toContain("enable the API"); + }); + + it("does not ask for the API step when the mint was what failed", async () => { + // Dyad turns the API on and then mints, so an account with no team, or a + // link that drops, leaves the API on and no token. Saying to go and + // enable it sends the user after something already done. + h.snapshot.mockResolvedValue( + doneState({ + tokenStored: false, + apiEnabled: true, + tokenUnavailableReason: "This Coolify account has no team yet.", + }), + ); + renderPanel(); + + const panel = await waitFor(() => + screen.getByTestId("coolify-setup-manual-token"), + ); + expect(panel.textContent).toContain("has no team yet"); + expect(panel.textContent).toContain("Security → API Tokens"); + expect(panel.textContent).not.toContain("enable the API"); }); it("puts the screen away and refreshes when the user moves on", async () => { diff --git a/src/components/CoolifyServerSetup.tsx b/src/components/CoolifyServerSetup.tsx index eb5067f984..0b9c229d95 100644 --- a/src/components/CoolifyServerSetup.tsx +++ b/src/components/CoolifyServerSetup.tsx @@ -296,9 +296,9 @@ export function CoolifyServerSetup({ : (result.tokenUnavailableReason ?? "Dyad could not create an API token automatically.")}{" "} Open {result.dashboardUrl}, sign in with the details above,{" "} - {/* Dyad turns the API on to mint a token, so a token it made - and will drop leaves that part already done. */} - {!result.tokenStored && + {/* Dyad turns the API on before it mints, so this stays done + even when the mint is what failed. */} + {!result.apiEnabled && "enable the API under Settings → Advanced, then "} create a token under Security → API Tokens and paste it in on the next screen. diff --git a/src/coolify_setup/api_token.ts b/src/coolify_setup/api_token.ts index b31b7fd334..25744439bb 100644 --- a/src/coolify_setup/api_token.ts +++ b/src/coolify_setup/api_token.ts @@ -231,11 +231,18 @@ export interface AutomaticAccess { export async function tryAutomaticAccess( session: SshSession, adminEmail: string, - { signal }: { signal?: AbortSignal } = {}, + { + signal, + onApiEnabled, + }: { signal?: AbortSignal; onApiEnabled?: () => void } = {}, ): Promise { const version = await readCoolifyVersion(session, { signal }); if (!supportsAutomaticToken(version)) return null; await enableApi(session, { signal }); + // Said here rather than inferred from the token below, because minting is + // its own step and can fail on its own — an account with no team, a link + // that drops — long after this one has taken effect on the server. + onApiEnabled?.(); const token = await mintApiToken(session, adminEmail, { signal }); return { token, version }; } diff --git a/src/coolify_setup/capabilities.test.ts b/src/coolify_setup/capabilities.test.ts index 0a1c2570f5..91ef778d2b 100644 --- a/src/coolify_setup/capabilities.test.ts +++ b/src/coolify_setup/capabilities.test.ts @@ -33,6 +33,7 @@ const RESULT: SetupResult = { adminEmail: "me@gmail.com", adminPassword: "Abc123@xyz", tokenStored: true, + apiEnabled: true, tokenUnavailableReason: null, version: "4.3.2", }; diff --git a/src/coolify_setup/controller.test.ts b/src/coolify_setup/controller.test.ts index 48a06f9d1b..5dce9e251c 100644 --- a/src/coolify_setup/controller.test.ts +++ b/src/coolify_setup/controller.test.ts @@ -17,6 +17,7 @@ const RESULT: SetupResult = { adminEmail: "me@gmail.com", adminPassword: "Abc123@xyz", tokenStored: true, + apiEnabled: true, tokenUnavailableReason: null, version: "4.3.2", }; diff --git a/src/coolify_setup/setup_flow.test.ts b/src/coolify_setup/setup_flow.test.ts index 2582e44880..2a300e5124 100644 --- a/src/coolify_setup/setup_flow.test.ts +++ b/src/coolify_setup/setup_flow.test.ts @@ -227,6 +227,32 @@ describe("runServerSetup", () => { expect(result.tokenUnavailableReason).toBeTruthy(); expect(result.dashboardUrl).toBe("https://203.0.113.5.sslip.io"); expect(result.credentials.password).toBeTruthy(); + // Too old to set up automatically, so nothing was switched on and + // switching it on is still the user's to do. + expect(result.apiEnabled).toBe(false); + }); + + it("does not claim the API was opened when opening it is what failed", async () => { + // Reported once the server has confirmed it, not when the attempt + // starts: saying it is on when it is not sends the user past the one + // step they still have to do by hand. + const server = fakeServer({ apiEnabled: "still-disabled" }); + const result = await run(server).promise; + + expect(result.token).toBeNull(); + expect(result.apiEnabled).toBe(false); + }); + + it("remembers the API was opened even when the token step then failed", async () => { + // Opening the API and minting a token are two steps in that order, so a + // mint that fails leaves the first done. Reporting otherwise sends the + // user to turn on something that is already on. + const server = fakeServer({ token: "" }); + const result = await run(server).promise; + + expect(result.token).toBeNull(); + expect(result.tokenUnavailableReason).toBeTruthy(); + expect(result.apiEnabled).toBe(true); }); it("keeps the install when HTTPS cannot even be attempted", async () => { @@ -567,6 +593,9 @@ describe("runServerSetup", () => { "Coolify did not answer while Dyad was opening its API.", ); expect(result.credentials.password).toBeTruthy(); + // The step that would have opened it is the one that failed, so it is + // still the user's to do. Reported after it settles, not when it starts. + expect(result.apiEnabled).toBe(false); }); it("passes cancellation through rather than reporting it as a token problem", async () => { diff --git a/src/coolify_setup/setup_flow.ts b/src/coolify_setup/setup_flow.ts index 6380dfa6ea..94abc6ce20 100644 --- a/src/coolify_setup/setup_flow.ts +++ b/src/coolify_setup/setup_flow.ts @@ -56,6 +56,14 @@ export interface SetupResult { * asks for a token by hand rather than throwing away a working install. */ token: string | null; + /** + * Whether Coolify's API was switched on, which outlives a failed mint. + * + * Not the same question as whether a token came back: Dyad turns the API on + * first, so telling the user to go and enable it is wrong from that point + * onward whatever happens next. + */ + apiEnabled: boolean; version: string | null; /** Present when token is null, phrased for the user. */ tokenUnavailableReason?: string; @@ -285,11 +293,15 @@ export async function runServerSetup({ insecureReason: https.reason, credentials, token: null, + apiEnabled: false, version: null, }; try { const access = await tryAutomaticAccess(session, credentials.email, { signal, + onApiEnabled: () => { + result.apiEnabled = true; + }, }); if (access) { result.token = access.token; diff --git a/src/coolify_setup/state.ts b/src/coolify_setup/state.ts index d367063c57..e0bd1bdc94 100644 --- a/src/coolify_setup/state.ts +++ b/src/coolify_setup/state.ts @@ -37,6 +37,8 @@ export interface SetupResult { adminEmail: string; adminPassword: string; tokenStored: boolean; + /** Whether Coolify's API was switched on, which outlives a failed mint. */ + apiEnabled: boolean; tokenUnavailableReason: string | null; version: string | null; } diff --git a/src/coolify_setup/transition.test.ts b/src/coolify_setup/transition.test.ts index 75f9856ced..cb4c21f28b 100644 --- a/src/coolify_setup/transition.test.ts +++ b/src/coolify_setup/transition.test.ts @@ -33,6 +33,7 @@ const RESULT: SetupResult = { adminEmail: "me@gmail.com", adminPassword: "Abc123@xyz", tokenStored: true, + apiEnabled: true, tokenUnavailableReason: null, version: "4.3.2", }; diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index ff195c8299..f12d095afc 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -326,6 +326,7 @@ function setupController(): CoolifySetupController { adminEmail: result.credentials.email, adminPassword: result.credentials.password, tokenStored: stored && Boolean(result.token), + apiEnabled: result.apiEnabled, tokenUnavailableReason: stored ? (result.tokenUnavailableReason ?? null) : "Dyad could not save these details on this computer. Copy the " + diff --git a/src/ipc/types/coolify_setup.ts b/src/ipc/types/coolify_setup.ts index c942700b71..526702ccf6 100644 --- a/src/ipc/types/coolify_setup.ts +++ b/src/ipc/types/coolify_setup.ts @@ -107,6 +107,14 @@ export const SetupResultSchema = z.object({ * could not be opened. */ tokenStored: z.boolean(), + /** + * Whether Coolify's API was switched on before anything went wrong. + * + * Separate from tokenStored because Dyad enables the API first and mints + * afterwards: a mint that fails leaves the API on, so the guidance to go + * and enable it is wrong even though no token came back. + */ + apiEnabled: z.boolean(), tokenUnavailableReason: z.string().nullable(), version: z.string().nullable(), }); From 2290357d52fbb96ef157ece0b3bfb306134ef22c Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Thu, 27 Aug 2026 14:40:08 -0500 Subject: [PATCH 53/91] test(coolify): give the fixture a shape the app can produce This one is typed loosely enough that the new field slipped past the compiler, leaving a done result the handler could no longer return. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyConnector.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/CoolifyConnector.test.tsx b/src/components/CoolifyConnector.test.tsx index 89cbb32403..f0ef2b982d 100644 --- a/src/components/CoolifyConnector.test.tsx +++ b/src/components/CoolifyConnector.test.tsx @@ -538,6 +538,7 @@ describe("what the installer leaves on screen", () => { adminEmail: "me@gmail.com", adminPassword: "Abc123@xyz", tokenStored: true, + apiEnabled: true, tokenUnavailableReason: null, version: "4.3.2", }, From c4f1fe876716713c4a1709fe8c16b0aae8f4b26f Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Thu, 27 Aug 2026 14:55:25 -0500 Subject: [PATCH 54/91] test(coolify): hold the answer to the shape the channel promises Two more fixtures still described a done result the flow no longer returns. These cases mock the wrapper that parses the answer on the way out, so nothing here noticed; checking one answer against the contract catches both the mapping and the fixture drifting from it. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyServerSetup.test.tsx | 1 + src/ipc/handlers/coolify_setup_handlers.test.ts | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/components/CoolifyServerSetup.test.tsx b/src/components/CoolifyServerSetup.test.tsx index a5cdccf31d..dc8943206f 100644 --- a/src/components/CoolifyServerSetup.test.tsx +++ b/src/components/CoolifyServerSetup.test.tsx @@ -445,6 +445,7 @@ describe("catching up with a run this window did not start", () => { adminEmail: "me@gmail.com", adminPassword: "Abc123@xyz", tokenStored: true, + apiEnabled: true, tokenUnavailableReason: null, version: "4.3.2", }, diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts index 32bc60629d..1c7a51fd42 100644 --- a/src/ipc/handlers/coolify_setup_handlers.test.ts +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -2,7 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; // The mocked class, so the handler recognises what it is handed. import { SshError } from "../utils/ssh_client"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; -import { SETUP_MACHINE_REPORTED } from "@/ipc/types/coolify_setup"; +import { + SETUP_MACHINE_REPORTED, + SetupResultSchema, +} from "@/ipc/types/coolify_setup"; const h = vi.hoisted(() => ({ settings: {} as Record, @@ -185,6 +188,8 @@ const RESULT = { password: "Abc123@xyz", }, token: "1|abc", + // A token comes from a mint, and Dyad opens the API to reach one. + apiEnabled: true, version: "4.3.2", }; @@ -501,6 +506,16 @@ describe("run", () => { expect(result.tokenStored).toBe(true); }); + it("answers in the shape the channel says it will", async () => { + // These cases mock the wrapper that parses this on the way out, so + // nothing else here would notice the answer drifting from the contract, + // or the fixture above drifting from what the flow now returns. + const result = await checkThenRun(); + expect(SetupResultSchema.safeParse(result)).toMatchObject({ + success: true, + }); + }); + it("keeps the install when no token could be created", async () => { h.setupResult = { ...RESULT, From cc41f5e62e060e24159c16044bcaf5bc8a1e3245 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Thu, 27 Aug 2026 14:55:25 -0500 Subject: [PATCH 55/91] fix(coolify): blame the step that actually stopped answering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lost link was always reported as the API step failing. Once the API is open that is the token step instead, and the guidance beside it no longer mentions enabling anything — so the stated cause contradicted its own remedy. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/setup_flow.test.ts | 31 ++++++++++++++++++++++++++++ src/coolify_setup/setup_flow.ts | 8 ++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/coolify_setup/setup_flow.test.ts b/src/coolify_setup/setup_flow.test.ts index 2a300e5124..bb4797ff5b 100644 --- a/src/coolify_setup/setup_flow.test.ts +++ b/src/coolify_setup/setup_flow.test.ts @@ -243,6 +243,37 @@ describe("runServerSetup", () => { expect(result.apiEnabled).toBe(false); }); + it("names the token step when the link dies making one", async () => { + // The API is already on by then, and the panel no longer tells the user + // to enable it — so blaming the API step would state a cause its own + // remedy contradicts. + const server = fakeServer(); + const original = server.session.run as unknown as ( + command: string, + options?: { input?: string }, + ) => Promise; + server.session.run = (async ( + command: string, + options?: { input?: string }, + ) => { + if ((options?.input ?? "").includes("createToken")) { + throw new SshError( + "timeout", + "the connection stopped answering", + DyadErrorKind.External, + ); + } + return original(command, options); + }) as unknown as SshSession["run"]; + + const result = await run(server).promise; + + expect(result.apiEnabled).toBe(true); + expect(result.tokenUnavailableReason).toBe( + "Coolify stopped answering while Dyad was making a token.", + ); + }); + it("remembers the API was opened even when the token step then failed", async () => { // Opening the API and minting a token are two steps in that order, so a // mint that fails leaves the first done. Reporting otherwise sends the diff --git a/src/coolify_setup/setup_flow.ts b/src/coolify_setup/setup_flow.ts index 94abc6ce20..c7ca5ecbdf 100644 --- a/src/coolify_setup/setup_flow.ts +++ b/src/coolify_setup/setup_flow.ts @@ -320,7 +320,13 @@ export async function runServerSetup({ // is that the server stopped answering and the rest is theirs to do. result.tokenUnavailableReason = error instanceof SshError - ? "Coolify did not answer while Dyad was opening its API." + ? // Which step the link died on decides what to say. The API is + // opened first, so once that has taken effect the loss belongs to + // the token step — and naming the API would sit over guidance + // that rightly no longer mentions it. + result.apiEnabled + ? "Coolify stopped answering while Dyad was making a token." + : "Coolify did not answer while Dyad was opening its API." : error instanceof Error ? error.message : "Coolify's API could not be opened automatically."; From fd9c26f354fbf1ca5298f9d77af32e982eebe8a8 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Thu, 27 Aug 2026 15:58:10 -0500 Subject: [PATCH 56/91] fix(coolify): point at the password, not away from it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the one path where this screen holds the only copy — the install stood, the write did not — it asked for the password "below" while the card carrying it is above, and the sentence right after it says "the details above". Co-Authored-By: Claude Opus 5 --- src/components/CoolifyServerSetup.test.tsx | 25 ++++++++++++++++++++++ src/ipc/handlers/coolify_setup_handlers.ts | 8 +++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/components/CoolifyServerSetup.test.tsx b/src/components/CoolifyServerSetup.test.tsx index dc8943206f..168875287d 100644 --- a/src/components/CoolifyServerSetup.test.tsx +++ b/src/components/CoolifyServerSetup.test.tsx @@ -932,6 +932,31 @@ describe("when it finishes", () => { expect(panel.textContent).toContain("enable the API"); }); + it("points at the password it is asking to be copied", async () => { + // The one path where this screen holds the only copy: the install stood + // and the write did not. Sending the user the wrong way past it is how + // that copy gets lost. + h.snapshot.mockResolvedValue( + doneState({ + tokenStored: false, + apiEnabled: true, + tokenUnavailableReason: + "Dyad could not save these details on this computer. Copy the " + + "password above before leaving this screen.", + }), + ); + renderPanel(); + + const done = await waitFor(() => screen.getByTestId("coolify-setup-done")); + const text = done.textContent ?? ""; + expect(text).toContain("Copy the password above"); + // Above means above: the card carrying it is rendered over this panel. + expect(text.indexOf("Abc123@xyz")).toBeGreaterThan(-1); + expect(text.indexOf("Abc123@xyz")).toBeLessThan( + text.indexOf("Copy the password above"), + ); + }); + it("does not ask for the API step when the mint was what failed", async () => { // Dyad turns the API on and then mints, so an account with no team, or a // link that drops, leaves the API on and no token. Saying to go and diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index f12d095afc..cb6b5a294c 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -329,8 +329,12 @@ function setupController(): CoolifySetupController { apiEnabled: result.apiEnabled, tokenUnavailableReason: stored ? (result.tokenUnavailableReason ?? null) - : "Dyad could not save these details on this computer. Copy the " + - "password below before leaving this screen.", + : // Above, not below: the credentials card sits over this + // panel, and the sentence the screen appends after this one + // already says "the details above". On this path the screen + // is the only copy of that password. + "Dyad could not save these details on this computer. Copy the " + + "password above before leaving this screen.", version: result.version, }; }); From 5a939e7ab6f4ff7914c6fe768689209072d09a45 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Thu, 27 Aug 2026 15:58:10 -0500 Subject: [PATCH 57/91] fix(coolify): say when a password is held but unreadable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readSettings drops a password it cannot decrypt and keeps the account, so the row simply did not appear — which reads as a server that never had one, and from there the obvious next move is the sign-out that discards it for good. Only the sign-out dialog said otherwise. The panel states it now, on both layouts, and the dialog adds what signing out does to it rather than saying the same thing twice. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyCredentials.test.tsx | 43 ++++++++++++++++++++ src/components/CoolifyCredentials.tsx | 32 ++++++++++++++- src/components/CoolifySignOutDialog.test.tsx | 15 +++++-- src/components/CoolifySignOutDialog.tsx | 8 ++-- 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/src/components/CoolifyCredentials.test.tsx b/src/components/CoolifyCredentials.test.tsx index 851755bb65..487f6e7c1a 100644 --- a/src/components/CoolifyCredentials.test.tsx +++ b/src/components/CoolifyCredentials.test.tsx @@ -159,6 +159,49 @@ describe("naming the section", () => { }); }); +describe("a password Dyad holds but cannot read", () => { + it("says so rather than showing a server that never had one", async () => { + // readSettings drops a password it cannot decrypt and keeps the account. + // Left as a missing row, that reads as there never having been one — and + // the obvious next move from there is the sign-out that discards it. + h.revealCredentials.mockResolvedValue({ + instance: null, + server: { + url: "http://203.0.113.5:8000", + email: "me@gmail.com", + password: null, + }, + }); + render(); + + await settle(); + expect( + screen.getByTestId("coolify-credentials-locked-password").textContent, + ).toContain("cannot read it on this machine"); + expect(screen.queryByTestId("coolify-field-server-password")).toBeNull(); + }); + + it("says it on the merged panel too", async () => { + // One address for both, so the fields are merged — the same gap, on the + // layout the connected view uses. + h.revealCredentials.mockResolvedValue({ + instance: { url: "http://203.0.113.5:8000", apiToken: "1|abc" }, + server: { + url: "http://203.0.113.5:8000", + email: "me@gmail.com", + password: null, + }, + }); + render(); + + await settle(); + expect( + screen.getByTestId("coolify-credentials-locked-password"), + ).toBeTruthy(); + expect(screen.queryByTestId("coolify-field-password")).toBeNull(); + }); +}); + describe("two servers that are not the same server", () => { it("keeps each address with what it opens", async () => { // Installed a server whose token could not be minted, then connected to a diff --git a/src/components/CoolifyCredentials.tsx b/src/components/CoolifyCredentials.tsx index 2df48df44d..c4a7cf018d 100644 --- a/src/components/CoolifyCredentials.tsx +++ b/src/components/CoolifyCredentials.tsx @@ -93,6 +93,26 @@ function Field({ ); } +/** + * Said rather than left blank. + * + * readSettings drops a password it cannot decrypt and keeps the account, so + * the absence is Dyad holding one it cannot open — not a server that never + * had one. Left to the row simply not appearing, the two look identical, and + * only one of them makes signing out a safe thing to do next. + */ +function LockedPassword() { + return ( +

+ Dyad is holding an admin password for this server but cannot read it on + this machine. +

+ ); +} + export function CoolifyCredentials({ showTitle, }: { showTitle?: boolean } = {}) { @@ -147,12 +167,18 @@ export function CoolifyCredentials({
Your Coolify server
)} + {/* A password Dyad holds but cannot decrypt comes back absent, which + renders as a server that never had one — and the way that reads, + the obvious next move is the sign-out that discards it for good. + The dialog says this; nowhere else did. */} {isOneServer ? ( <> - {server.password && ( + {server.password ? ( + ) : ( + )} {instance.apiToken && ( @@ -177,13 +203,15 @@ export function CoolifyCredentials({ value={server.email} idPrefix={showsBoth ? "server" : undefined} /> - {server.password && ( + {server.password ? ( + ) : ( + )} )} diff --git a/src/components/CoolifySignOutDialog.test.tsx b/src/components/CoolifySignOutDialog.test.tsx index 38b0a456d5..537d101f01 100644 --- a/src/components/CoolifySignOutDialog.test.tsx +++ b/src/components/CoolifySignOutDialog.test.tsx @@ -171,11 +171,18 @@ describe("nothing to look at yet", () => { }); open(); - await waitFor(() => - expect( - screen.getByTestId("coolify-sign-out-locked-password"), - ).toBeTruthy(), + const addendum = await waitFor(() => + screen.getByTestId("coolify-sign-out-locked-password"), ); + // Said once. The panel below states what Dyad is holding; this only adds + // what signing out does to it, so both saying it reads as a stutter. + expect(screen.queryAllByText(/holding an admin password/i)).toHaveLength(1); + // And said after it, for the same reason the read failure is. + const cause = screen.getByTestId("coolify-credentials-locked-password"); + expect( + cause.compareDocumentPosition(addendum) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); }); }); diff --git a/src/components/CoolifySignOutDialog.tsx b/src/components/CoolifySignOutDialog.tsx index 46ae5b90c4..e49e225990 100644 --- a/src/components/CoolifySignOutDialog.tsx +++ b/src/components/CoolifySignOutDialog.tsx @@ -87,18 +87,18 @@ export function CoolifySignOutDialog({ Looking up what Dyad has stored… )} + + + {passwordIsLocked && (
- Dyad is holding an admin password for this server but cannot read it - on this machine, so it cannot show it to you before it goes. + It goes when you sign out, unread.
)} - - {readFailed && (
Date: Thu, 27 Aug 2026 21:51:58 -0500 Subject: [PATCH 58/91] docs(coolify): the panel says this now Co-Authored-By: Claude Opus 5 --- src/components/CoolifySignOutDialog.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/CoolifySignOutDialog.tsx b/src/components/CoolifySignOutDialog.tsx index e49e225990..49093bf8e6 100644 --- a/src/components/CoolifySignOutDialog.tsx +++ b/src/components/CoolifySignOutDialog.tsx @@ -61,8 +61,9 @@ export function CoolifySignOutDialog({ // hand. A failed refetch over details it can still show is not one, and the // line below would then hang off nothing. const readFailed = isError && !credentials; - // Held but unreadable, which the panel below cannot show because there is - // no value to put on screen. Saying so beats a silently missing row. + // Held but unreadable. The panel below names it; this adds only what + // signing out does to it, so the two read as one thought rather than as + // the same sentence twice. const passwordIsLocked = credentials?.server !== null && credentials?.server !== undefined && From 599ae98f31450d6764dda13b251c29a7ebda407b Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Thu, 27 Aug 2026 22:02:58 -0500 Subject: [PATCH 59/91] test(coolify): check the sentence the handler writes, not the one the test did The case for pointing at the password supplied its own copy of that message and then read it back, so the wording it was written to hold could go back to sending the user the wrong way with everything green. The row asserted gone in the split-record case is only ever named that way when two records are on screen, so it was gone either way. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyCredentials.test.tsx | 2 +- src/ipc/handlers/coolify_setup_handlers.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/CoolifyCredentials.test.tsx b/src/components/CoolifyCredentials.test.tsx index 487f6e7c1a..8a60a7a6ad 100644 --- a/src/components/CoolifyCredentials.test.tsx +++ b/src/components/CoolifyCredentials.test.tsx @@ -178,7 +178,7 @@ describe("a password Dyad holds but cannot read", () => { expect( screen.getByTestId("coolify-credentials-locked-password").textContent, ).toContain("cannot read it on this machine"); - expect(screen.queryByTestId("coolify-field-server-password")).toBeNull(); + expect(screen.queryByTestId("coolify-field-password")).toBeNull(); }); it("says it on the merged panel too", async () => { diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts index 1c7a51fd42..487a6efee3 100644 --- a/src/ipc/handlers/coolify_setup_handlers.test.ts +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -312,6 +312,14 @@ describe("run", () => { tokenStored: false, tokenUnavailableReason: expect.stringContaining("could not save"), }); + + // Where the password actually is. The screen puts the card above this + // message, and on this path it is the only copy — so the direction is + // the part that matters, and it is written here rather than there. + const { tokenUnavailableReason } = (await checkThenRun()) as { + tokenUnavailableReason: string; + }; + expect(tokenUnavailableReason).toContain("password above"); }); it("refuses a server it has not looked at", async () => { From 0a24525084a5a2202507362794eb59d4c60c767e Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 12:08:15 -0500 Subject: [PATCH 60/91] fix(coolify): keep ssh2 off the app's startup path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telemetry is pulled in by main.ts and by every typed handler, and it value-imported the SSH error class — so the packaged main process required ssh2, and its optional native probe, on the boot of every app whether or not anyone opened the Coolify panel. A packaging miss became a process that does not start rather than one feature that does not work. The failure union and a name-based predicate move to a module that imports nothing, and the client keeps the only declaration of both. Co-Authored-By: Claude Opus 5 --- src/ipc/utils/ssh_client.ts | 18 ++------------- src/ipc/utils/telemetry.ts | 8 +++---- src/shared/ssh_failure.test.ts | 38 +++++++++++++++++++++++++++++++ src/shared/ssh_failure.ts | 41 ++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 21 deletions(-) create mode 100644 src/shared/ssh_failure.test.ts create mode 100644 src/shared/ssh_failure.ts diff --git a/src/ipc/utils/ssh_client.ts b/src/ipc/utils/ssh_client.ts index 9fd67aa8b8..6e96a6237f 100644 --- a/src/ipc/utils/ssh_client.ts +++ b/src/ipc/utils/ssh_client.ts @@ -1,4 +1,5 @@ import { Client, type ClientChannel, type ConnectConfig } from "ssh2"; +import type { SshFailure } from "@/shared/ssh_failure"; import { createHash } from "crypto"; import log from "electron-log"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; @@ -32,22 +33,7 @@ export interface SshTarget { * unreachable host asks them to check the address. Reading English out of a * message to make that choice breaks the first time the wording moves. */ -export type SshFailure = - | "auth-rejected" - | "host-key-rejected" - | "unreachable" - /** The connection stopped answering: nothing on it will work again. */ - | "timeout" - /** - * We gave up on one command, having asked it to be quick. - * - * Distinct from "timeout" because the connection is still good and the - * question can be asked again — a caller that polls must be able to tell - * "this attempt was slow" from "this link is dead", or one slow answer ends - * a wait that had minutes left in it. - */ - | "command-timeout" - | "unknown"; +export type { SshFailure }; export class SshError extends DyadError { constructor( diff --git a/src/ipc/utils/telemetry.ts b/src/ipc/utils/telemetry.ts index 5189253f25..54e00e71aa 100644 --- a/src/ipc/utils/telemetry.ts +++ b/src/ipc/utils/telemetry.ts @@ -6,7 +6,7 @@ import { } from "@/errors/dyad_error"; import { isGenericFetchFailedError } from "@/lib/posthogTelemetry"; import { TelemetryEventPayload } from "@/ipc/types"; -import { SshError } from "@/ipc/utils/ssh_client"; +import { sshFailureOf } from "@/shared/ssh_failure"; import { COOLIFY_REQUEST_ERROR_NAME, COOLIFY_TRANSPORT_ERROR_NAME, @@ -133,10 +133,8 @@ export function shouldFilterTelemetryException(error: unknown): boolean { // reported as a fault here — and the message carries whatever they typed. // Only the two that say what went wrong. "unknown" is the bucket for a // failure nothing here recognised, which is what telemetry is for. - if ( - error instanceof SshError && - (error.failure === "unreachable" || error.failure === "timeout") - ) { + const sshFailure = sshFailureOf(error); + if (sshFailure === "unreachable" || sshFailure === "timeout") { return true; } diff --git a/src/shared/ssh_failure.test.ts b/src/shared/ssh_failure.test.ts new file mode 100644 index 0000000000..5ab32b56d9 --- /dev/null +++ b/src/shared/ssh_failure.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { sshFailureOf } from "./ssh_failure"; + +describe("reading a failure off an error", () => { + it("agrees with the error the client actually throws", async () => { + // Matched by name so that asking costs no load-time dependency, which + // means the name is a contract between two files that do not import one + // another. Renaming it there would quietly stop every check here. + const { SshError } = await import("@/ipc/utils/ssh_client"); + const { DyadErrorKind } = await import("@/errors/dyad_error"); + + expect( + sshFailureOf( + new SshError("unreachable", "nothing answered", DyadErrorKind.External), + ), + ).toBe("unreachable"); + expect( + sshFailureOf( + new SshError("command-timeout", "too slow", DyadErrorKind.External), + ), + ).toBe("command-timeout"); + }); + + it("says nothing about errors that are not the client's", () => { + expect(sshFailureOf(new Error("plain"))).toBeNull(); + expect(sshFailureOf(null)).toBeNull(); + expect(sshFailureOf(undefined)).toBeNull(); + expect( + sshFailureOf({ name: "SshError", failure: "unreachable" }), + ).toBeNull(); + }); + + it("does not answer for one of ours carrying no failure", () => { + // A shape that passes the name check but has nothing to read. + const odd = Object.assign(new Error("odd"), { name: "SshError" }); + expect(sshFailureOf(odd)).toBeNull(); + }); +}); diff --git a/src/shared/ssh_failure.ts b/src/shared/ssh_failure.ts new file mode 100644 index 0000000000..0db9beea04 --- /dev/null +++ b/src/shared/ssh_failure.ts @@ -0,0 +1,41 @@ +/** + * What went wrong on an SSH attempt, and how to ask about it from anywhere. + * + * Separate from the client because the client value-imports `ssh2`, and the + * main process reaches this from its startup path: telemetry is pulled in by + * `main.ts` and by every typed handler, so importing the error class there + * would put `ssh2` and its optional native probe on the boot of every app, + * including for people who never open the Coolify panel — and would turn a + * packaging miss into a process that does not start rather than one feature + * that does not work. + */ +export type SshFailure = + | "auth-rejected" + | "host-key-rejected" + | "unreachable" + /** The connection stopped answering: nothing on it will work again. */ + | "timeout" + /** + * We gave up on one command, having asked it to be quick. + * + * Distinct from "timeout" because the connection is still good and the + * question can be asked again — a caller that polls must be able to tell + * "this attempt was slow" from "this link is dead", or one slow answer ends + * a wait that had minutes left in it. + */ + | "command-timeout" + /** Nothing here recognised it. */ + | "unknown"; + +/** + * The failure an error carries, or null if it is not one of ours. + * + * Matched on the name rather than with `instanceof` so that asking the + * question costs nothing at load time. `SshError` sets its own `name`, and + * the failure is one of the values above. + */ +export function sshFailureOf(error: unknown): SshFailure | null { + if (!(error instanceof Error) || error.name !== "SshError") return null; + const { failure } = error as Error & { failure?: unknown }; + return typeof failure === "string" ? (failure as SshFailure) : null; +} From 4a448bfead3330a0003254c094db81ad9ceac33c Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 12:08:15 -0500 Subject: [PATCH 61/91] fix(coolify): say when the temporary domain will not come back off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the revert fails, Coolify is left answering at a name it has no certificate for — the state the block around it exists to avoid — while the run reports and stores the plain-HTTP address. The last thing said was that the domain was being removed, which reads as it having worked. It is still swallowed, because the install stands and this is the way out of a failure rather than the failure. It is no longer silent. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/https_setup.test.ts | 38 +++++++++++++++++++++++++++ src/coolify_setup/https_setup.ts | 28 ++++++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/coolify_setup/https_setup.test.ts b/src/coolify_setup/https_setup.test.ts index f94e1888c8..8a516dce83 100644 --- a/src/coolify_setup/https_setup.test.ts +++ b/src/coolify_setup/https_setup.test.ts @@ -491,6 +491,44 @@ describe("tryEnableHttps", () => { expect(said.join("")).toMatch(/Removing the temporary domain/); }); + it("says so when the domain will not come back off", async () => { + // The state the revert exists to avoid, arrived at anyway. Left silent, + // the last thing said was that the domain was being removed — which + // reads as it having worked, over a Coolify still set to answer at a + // name it has no certificate for. + const said: string[] = []; + const { session } = fakeSession(); + const answering = session.run as unknown as ( + command: string, + options?: { input?: string }, + ) => Promise; + let domainWrites = 0; + session.run = (async (command: string, options?: { input?: string }) => { + // The first write puts the domain on; the second takes it back off, + // and that is the one this is about. + if ((options?.input ?? "").includes("fqdn")) { + domainWrites += 1; + if (domainWrites > 1) throw new Error("tinker is wedged"); + } + return answering(command, options); + }) as unknown as SshSession["run"]; + + const result = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + check: async () => false, + onProgress: (message) => said.push(message), + }); + + expect(said.join("")).toMatch(/Could not remove the temporary domain/); + // And carried where the panel will show it, not only in the log. + expect(result.reason).toMatch(/may still be configured for/); + expect(result.reason).toMatch(/clear the instance domain/); + // The install still stands: this is the way out of a failure, not a new + // one to throw. + expect(result.secure).toBe(false); + expect(result.instanceUrl).toBe("http://203.0.113.5:8000"); + }); + it("gives the revert the full budget when nobody is waiting on a cancel", async () => { // The short bound exists so "Stopping…" cannot hang. On the ordinary // no-certificate path there is no cancel, and the domain still has to diff --git a/src/coolify_setup/https_setup.ts b/src/coolify_setup/https_setup.ts index 78c1a840be..5e3e1c4ba9 100644 --- a/src/coolify_setup/https_setup.ts +++ b/src/coolify_setup/https_setup.ts @@ -1,3 +1,4 @@ +import log from "electron-log"; import { isIP } from "node:net"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { sleep } from "./sleep"; @@ -276,6 +277,8 @@ export async function domainPointsAtServer( return "points-here"; } +const logger = log.scope("coolify_https_setup"); + export interface HttpsOutcome { /** What Dyad should store and talk to. */ instanceUrl: string; @@ -436,6 +439,7 @@ export async function tryEnableHttps( // applying it — leaves Coolify answering at a name that serves nothing, so // the domain comes back off before anyone is told what happened. let keepDomain = false; + let settled: HttpsOutcome | null = null; try { await applyInstanceDomain(session, domain, { signal }); @@ -453,7 +457,9 @@ export async function tryEnableHttps( } onProgress?.("No certificate arrived; leaving Coolify on plain HTTP.\n"); - return { + // Held rather than returned outright so the revert below can add to it. + // The caller has this object, not a copy of it. + settled = { instanceUrl: plainUrlFor(host), secure: false, reason: !domain.endsWith(".sslip.io") @@ -462,6 +468,7 @@ export async function tryEnableHttps( `provides these names shares one certificate allowance between ` + `everyone using it, and it can run out.`, }; + return settled; } finally { // Without the signal, which by this point may be the reason we are here. // Bounded by the tinker call's own timeout, so a wedged server cannot @@ -478,7 +485,24 @@ export async function tryEnableHttps( timeoutMs: signal?.aborted ? CANCELLED_REVERT_TIMEOUT_MS : APPLY_DOMAIN_TIMEOUT_MS, - }).catch(() => {}); + }).catch((error: unknown) => { + // The state the comment above calls the one to avoid, arrived at + // anyway. Swallowed, because the install itself stands and this is + // the way out of a failure rather than the failure — but not + // silently: the last thing the log said was that the domain was + // being removed, which reads as it having worked. + logger.error(`Could not remove the temporary domain ${domain}`, error); + onProgress?.( + `Could not remove the temporary domain ${domain}. Coolify may ` + + `still be set to answer at it.\n`, + ); + if (settled) { + settled.reason = + `${settled.reason ?? ""} Coolify may still be configured for ` + + `${domain}; clear the instance domain in its settings if the ` + + `dashboard does not answer at ${plainUrlFor(host)}.`.trimStart(); + } + }); } } } From c16e127e340866f03b14f41d9766189781009fea Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 12:08:15 -0500 Subject: [PATCH 62/91] fix(coolify): do not call a slow answer an old Coolify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tinker call over its 30s bound, or a container still starting and sending no markers back, both came back as "no version" — which the caller reports as a Coolify too old to set up automatically. On a 2GB server right after an install that is the ordinary case, and the user is told something false about a server they installed minutes ago, with nothing to act on because preflight refuses to install again. Null now means the instance answered and what it said was not a version. Not getting an answer says so instead. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/api_token.test.ts | 24 ++++++++++++++++---- src/coolify_setup/api_token.ts | 25 ++++++++++++--------- src/coolify_setup/setup_flow.test.ts | 33 ++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/src/coolify_setup/api_token.test.ts b/src/coolify_setup/api_token.test.ts index 3d68b84236..743654b1fd 100644 --- a/src/coolify_setup/api_token.test.ts +++ b/src/coolify_setup/api_token.test.ts @@ -100,9 +100,10 @@ describe("readCoolifyVersion", () => { await expect(readCoolifyVersion(session)).rejects.toBeInstanceOf(SshError); }); - it("still answers null when a bound Dyad set is the one that was hit", async () => { - // The instance is reachable and simply did not answer in time, which is - // the case this was written for. + it("says a slow answer was slow rather than calling the version old", async () => { + // The instance is reachable and simply did not answer in time, which on + // a small server right after an install is ordinary. Answered as null, + // the user is told the Coolify they just installed is too old to drive. const session = { run: vi.fn(async () => { throw new SshError( @@ -114,7 +115,22 @@ describe("readCoolifyVersion", () => { end: vi.fn(), } as unknown as SshSession; - expect(await readCoolifyVersion(session)).toBeNull(); + await expect(readCoolifyVersion(session)).rejects.toThrow( + /did not answer in time/, + ); + }); + + it("does not call a container still starting an unreadable version", async () => { + // No markers back means the script never ran — a container still coming + // up. Its own message says to wait, which is the useful thing to say. + const session = { + run: vi.fn(async () => ({ code: 0, stdout: "", stderr: "" })), + end: vi.fn(), + } as unknown as SshSession; + + await expect(readCoolifyVersion(session)).rejects.toThrow( + /may still be starting/, + ); }); }); diff --git a/src/coolify_setup/api_token.ts b/src/coolify_setup/api_token.ts index 25744439bb..e7aa6c7bad 100644 --- a/src/coolify_setup/api_token.ts +++ b/src/coolify_setup/api_token.ts @@ -88,17 +88,22 @@ export async function readCoolifyVersion( // driven. Swallowing it here would report a version problem for something // they did on purpose, and carry on setting the server up. if ((error as { kind?: string }).kind === "user_cancelled") throw error; - // A link that has died is not an instance whose version cannot be read. - // Returning null here says the version is the problem, and the caller - // tells the user their freshly installed Coolify is too old to drive — - // for a question that never reached it. The same line isAdminSeeded - // draws: a bound Dyad imposed is worth another look, a lost connection - // is not. - if (error instanceof SshError && error.failure !== "command-timeout") { - throw error; + // Null means the instance answered and what it said was not a version. + // Anything else is the question not getting through, and answering null + // for that tells the user their freshly installed Coolify is too old to + // drive — for something it was never asked. + if (error instanceof SshError && error.failure === "command-timeout") { + // Reachable and simply slow, which on a small server right after an + // install is ordinary. Worth saying as itself: the version is not the + // problem, and there is nothing to fix by finding a newer Coolify. + throw new DyadError( + "Coolify did not answer in time when Dyad asked which version it " + + "is. It may still be starting up — open it and connect with a " + + "token once it does.", + DyadErrorKind.External, + ); } - // An instance too old or too different to answer is one to set up by hand. - return null; + throw error; } } diff --git a/src/coolify_setup/setup_flow.test.ts b/src/coolify_setup/setup_flow.test.ts index bb4797ff5b..65aaa7775d 100644 --- a/src/coolify_setup/setup_flow.test.ts +++ b/src/coolify_setup/setup_flow.test.ts @@ -232,6 +232,39 @@ describe("runServerSetup", () => { expect(result.apiEnabled).toBe(false); }); + it("does not tell the user a fresh install is too old when it was only slow", async () => { + // A tinker one-liner over the 30s bound on a 2GB box right after an + // install is ordinary. Reported as an unsupported version, the user is + // told something false about a Coolify they installed minutes ago, and + // preflight refuses to install again — so there is nothing to act on. + const server = fakeServer(); + const answering = server.session.run as unknown as ( + command: string, + options?: { input?: string }, + ) => Promise; + server.session.run = (async ( + command: string, + options?: { input?: string }, + ) => { + if ((options?.input ?? "").includes("constants.coolify.version")) { + throw new SshError( + "command-timeout", + "timed out", + DyadErrorKind.External, + ); + } + return answering(command, options); + }) as unknown as SshSession["run"]; + + const result = await run(server).promise; + + expect(result.token).toBeNull(); + expect(result.tokenUnavailableReason).toMatch(/did not answer in time/); + expect(result.tokenUnavailableReason).not.toMatch(/version of Coolify/); + // The install still stands, and the address is still usable. + expect(result.credentials.password).toBeTruthy(); + }); + it("does not claim the API was opened when opening it is what failed", async () => { // Reported once the server has confirmed it, not when the attempt // starts: saying it is on when it is not sends the user past the one From a3a65b578e8bcc7386458e0dff334286ef3add26 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 12:08:15 -0500 Subject: [PATCH 63/91] feat(coolify): show that Install was pressed The connect behind it can take seconds with nothing else on screen moving, and a disabled button on its own reads as the press having been refused. Check server already says so for the same wait. Also written down: why the refresh here takes the whole family while the snapshot hook's excludes one key, so the two are not tidied into agreement later. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyServerSetup.test.tsx | 21 +++++++++++++++++++++ src/components/CoolifyServerSetup.tsx | 10 ++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/components/CoolifyServerSetup.test.tsx b/src/components/CoolifyServerSetup.test.tsx index 168875287d..8130ca5344 100644 --- a/src/components/CoolifyServerSetup.test.tsx +++ b/src/components/CoolifyServerSetup.test.tsx @@ -565,6 +565,27 @@ describe("pressing Install", () => { ); }); + it("says the press landed while the first connect is still going", async () => { + // Disabled on its own reads as the button having refused. The connect + // behind it can take seconds with nothing else on screen moving, and + // Check server already says so for the same wait. + h.run.mockReturnValue(new Promise(() => {})); + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await checkServer(user); + await user.click(screen.getByTestId("coolify-setup-install")); + + await waitFor(() => + expect( + screen + .getByTestId("coolify-setup-install") + .querySelector(".animate-spin"), + ).toBeTruthy(), + ); + }); + it("does not start when the key could not be read", async () => { // The key is what the server trusts; without it the install cannot work, // and the panel already says so. diff --git a/src/components/CoolifyServerSetup.tsx b/src/components/CoolifyServerSetup.tsx index 0b9c229d95..3135f261e7 100644 --- a/src/components/CoolifyServerSetup.tsx +++ b/src/components/CoolifyServerSetup.tsx @@ -187,6 +187,12 @@ export function CoolifyServerSetup({ // Refreshed before the screen is put away. Dismissing first hands the // panel back to a connector that still believes there is no token, so the // empty install form flashes up before the right screen arrives. + // + // All of it, including the setup key, unlike the narrower predicate in + // useCoolifySetupSnapshot — deliberately, not by oversight. There the + // snapshot has just been pushed and re-reading it is how a finished run + // gets put back on a step it has left; here the screen is going away and + // the connector behind it needs everything current. Keep them apart. await queryClient.invalidateQueries({ queryKey: queryKeys.coolify.all }); // Told where to go before the screen is cleared. Dismissing first puts the // machine back to idle while the panel above still believes there is @@ -586,6 +592,10 @@ export function CoolifyServerSetup({ onClick={() => run.mutate()} data-testid="coolify-setup-install" > + {/* The gap between pressing this and the machine's first broadcast + is a slow SSH connect, and nothing else on screen moves for it. + Check server already says so; this said nothing. */} + {run.isPending && } Install Coolify
From 2a9a77db59130d6c90e6e54885459bd3153a8512 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 12:28:50 -0500 Subject: [PATCH 64/91] fix(coolify): load ssh2 when connecting, not when the app starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit took the SSH client off telemetry's import graph and claimed the startup path with it, which was wrong: the setup handlers are registered during boot and value-import the client themselves, so ssh2 and its optional native probe still loaded for everyone. Moved to where it belongs — the one place that needs the value is the connect itself. The packaged main bundle now has no require of ssh2 at all, only an import inside that function. Co-Authored-By: Claude Opus 5 --- src/ipc/utils/ssh_client.ts | 9 ++++++++- src/shared/ssh_failure.ts | 14 +++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/ipc/utils/ssh_client.ts b/src/ipc/utils/ssh_client.ts index 6e96a6237f..7a5426ecaf 100644 --- a/src/ipc/utils/ssh_client.ts +++ b/src/ipc/utils/ssh_client.ts @@ -1,4 +1,4 @@ -import { Client, type ClientChannel, type ConnectConfig } from "ssh2"; +import type { ClientChannel, ConnectConfig } from "ssh2"; import type { SshFailure } from "@/shared/ssh_failure"; import { createHash } from "crypto"; import log from "electron-log"; @@ -186,6 +186,13 @@ export async function connectSsh( verifyHostKey: HostKeyVerifier, { signal }: { signal?: AbortSignal } = {}, ): Promise { + // Loaded when someone actually connects, not when this module is imported. + // The setup handlers are registered during boot, so a value import here put + // ssh2 — and the optional native probe it runs on load — on the startup of + // every app, whether or not anyone ever set a server up. That also turned a + // packaging miss into a process that does not start, rather than one + // feature that does not work. + const { Client } = await import("ssh2"); const conn = new Client(); /** * Why the connection died, for commands that were in flight when it did. diff --git a/src/shared/ssh_failure.ts b/src/shared/ssh_failure.ts index 0db9beea04..e294cedf73 100644 --- a/src/shared/ssh_failure.ts +++ b/src/shared/ssh_failure.ts @@ -1,13 +1,13 @@ /** * What went wrong on an SSH attempt, and how to ask about it from anywhere. * - * Separate from the client because the client value-imports `ssh2`, and the - * main process reaches this from its startup path: telemetry is pulled in by - * `main.ts` and by every typed handler, so importing the error class there - * would put `ssh2` and its optional native probe on the boot of every app, - * including for people who never open the Coolify panel — and would turn a - * packaging miss into a process that does not start rather than one feature - * that does not work. + * Separate from the client so that asking costs nothing: telemetry is reached + * from `main.ts` and from every typed handler, and it only ever needs to know + * which failure an error carries. The client itself is a large module whose + * one job is to talk to a server, and nothing on the startup path has any use + * for that. `connectSsh` loads `ssh2` when it actually connects, which is what + * keeps it off the boot; this keeps the question askable without reaching for + * the client at all. */ export type SshFailure = | "auth-rejected" From e349b0eb7be86b86b87dc3a9593538c94513b9c1 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 12:28:50 -0500 Subject: [PATCH 65/91] fix(coolify): trim the sentence, not its last clause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Member access binds tighter than +, so the trim applied only to the final template literal and never to the join it was written for. The password-stays-with-its-server case looked for the value in the wrong block's text, which a masked field never shows — so it held wherever the row was put. It asks for the field now. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyCredentials.test.tsx | 6 +++++- src/coolify_setup/https_setup.ts | 7 +++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/CoolifyCredentials.test.tsx b/src/components/CoolifyCredentials.test.tsx index 8a60a7a6ad..402a0668d6 100644 --- a/src/components/CoolifyCredentials.test.tsx +++ b/src/components/CoolifyCredentials.test.tsx @@ -231,7 +231,11 @@ describe("two servers that are not the same server", () => { expect(forServer.textContent).not.toContain("someone-elses"); expect(forInstance.textContent).toContain("someone-elses.example.com"); // The password belongs to the machine Dyad built, and stays with it. - expect(forInstance.textContent).not.toContain("Abc123@xyz"); + // Asserted on the field rather than on the text: a secret renders as + // bullets until it is revealed, so looking for the value itself passes + // wherever the row is put. + expect(screen.getByTestId("coolify-field-server-password")).toBeTruthy(); + expect(screen.queryByTestId("coolify-field-instance-password")).toBeNull(); }); it("shows one block when both describe the same address", async () => { diff --git a/src/coolify_setup/https_setup.ts b/src/coolify_setup/https_setup.ts index 5e3e1c4ba9..3deb1a2666 100644 --- a/src/coolify_setup/https_setup.ts +++ b/src/coolify_setup/https_setup.ts @@ -497,10 +497,13 @@ export async function tryEnableHttps( `still be set to answer at it.\n`, ); if (settled) { - settled.reason = + // Wrapped, so the trim applies to the joined sentence rather than + // binding to the last piece of it. + settled.reason = ( `${settled.reason ?? ""} Coolify may still be configured for ` + `${domain}; clear the instance domain in its settings if the ` + - `dashboard does not answer at ${plainUrlFor(host)}.`.trimStart(); + `dashboard does not answer at ${plainUrlFor(host)}.` + ).trimStart(); } }); } From 0ca1f957b7557ebd030607a65ca6b52fece4e066 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 13:29:50 -0500 Subject: [PATCH 66/91] fix(coolify): tell other windows when a held token is kept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepting one writes the instance and the token, which is the same write save-token makes and declares invalidation for. Without it, a second window that watched the install finish goes on offering to set a server up — and pressing Install there is refused for holding an account it has not heard about. Co-Authored-By: Claude Opus 5 --- src/ipc/types/coolify_setup.ts | 9 +++++++ .../renderer_query_invalidation.test.ts | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/ipc/types/coolify_setup.ts b/src/ipc/types/coolify_setup.ts index 526702ccf6..49c53db03c 100644 --- a/src/ipc/types/coolify_setup.ts +++ b/src/ipc/types/coolify_setup.ts @@ -186,6 +186,8 @@ export const SetupSnapshotSchema = z.discriminatedUnion("type", [ message: z.string(), log: z.string(), cancelled: z.boolean(), + /** Left behind on the server, and still the user's to undo. */ + warning: z.string().optional(), }), ]); @@ -275,6 +277,13 @@ export const coolifySetupContracts = { channel: "coolify-setup:accept-insecure-token", input: z.void(), output: z.void(), + // The same write `coolify:save-token` makes, so the same reach: a window + // that watched this install finish is otherwise still offering to set a + // server up, and pressing it there is refused for holding an account. + invalidates: () => [{ family: "apps" }, { family: "coolify" }], + // The finished screen refreshes on its own way out, in the order it needs + // — the panel behind it must not be handed back before the token lands. + originHandles: () => [{ family: "coolify" }], }), /** The user has read the finished screen; put the panel back to the form. */ diff --git a/src/window_infrastructure/renderer_query_invalidation.test.ts b/src/window_infrastructure/renderer_query_invalidation.test.ts index 84b535a224..2e92cfe962 100644 --- a/src/window_infrastructure/renderer_query_invalidation.test.ts +++ b/src/window_infrastructure/renderer_query_invalidation.test.ts @@ -226,6 +226,30 @@ describe("Coolify contracts and the window that acted", () => { // that started it. }); + it("tells other windows when an unencrypted token is kept", () => { + // The same write saveToken makes: it is what turns every app connected. + // Without it a second window that watched the install finish goes on + // offering to set a server up, and pressing Install there is refused for + // holding an account it does not know about. + const contract = coolifySetupContracts.acceptInsecureToken as { + originHandles?: (input: unknown) => Array<{ family: string }>; + invalidates?: (input: unknown) => Array<{ family: string }>; + }; + const claims = (contract.originHandles?.(undefined) ?? []).map( + (s) => s.family, + ); + const publishes = (contract.invalidates?.(undefined) ?? []).map( + (s) => s.family, + ); + + expect(publishes).toContain("apps"); + expect(publishes).toContain("coolify"); + // As with run: the finished screen refreshes coolify on its own way out, + // in the order it needs, so it is not handed back mid-write. + expect(claims).toContain("coolify"); + expect(claims).not.toContain("apps"); + }); + it("publishes project creation so other windows see the new project", () => { const { publishes } = handled("createProject", { name: "x" }); expect(publishes).toContain("coolify"); From aa71944ab6c0f916c7140e453ae0c3cb6662be80 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 13:29:50 -0500 Subject: [PATCH 67/91] fix(coolify): say what a stopped run left behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel stays quiet about a cancelled run, because stopping was the user's decision rather than a fault. A temporary domain that would not come back off is not part of that decision: it leaves Coolify answering at a name it has no certificate for, and cancelling is one of the ways into the revert that failed. A failed run now carries what it could not undo separately from why it ended, and the panel shows that either way. Also pinned: installing is refused while a new check is in flight. The verdict is cleared before asking, so there is no fingerprint on screen to have agreed to — nothing held that. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyServerSetup.test.tsx | 57 ++++++++++++++++++++++ src/components/CoolifyServerSetup.tsx | 12 +++++ src/coolify_setup/controller.ts | 5 ++ src/coolify_setup/https_setup.test.ts | 38 +++++++++++++++ src/coolify_setup/https_setup.ts | 15 ++++++ src/coolify_setup/state.ts | 10 ++++ 6 files changed, 137 insertions(+) diff --git a/src/components/CoolifyServerSetup.test.tsx b/src/components/CoolifyServerSetup.test.tsx index 8130ca5344..b47013ee28 100644 --- a/src/components/CoolifyServerSetup.test.tsx +++ b/src/components/CoolifyServerSetup.test.tsx @@ -565,6 +565,63 @@ describe("pressing Install", () => { ); }); + it("says what is left to undo even when the run was cancelled", async () => { + // Stopping was the user's decision, so the panel stays quiet about the + // ending — but a domain the run put on and could not take back off is + // theirs to clear, and saying nothing leaves a server answering at a + // name it has no certificate for. + h.snapshot.mockResolvedValue({ + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Cancelled.", + log: "", + cancelled: true, + warning: "Coolify may still be configured for 203.0.113.5.sslip.io.", + }); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-warning").textContent).toContain( + "may still be configured", + ), + ); + // Still quiet about the cancellation itself. + expect(screen.queryByTestId("coolify-setup-failure")).toBeNull(); + }); + + it("will not install against a verdict a new check has replaced", async () => { + // Checking again is how the user reacts to changing the address or + // suspecting the machine moved. Until the new answer lands there is no + // fingerprint on screen to have agreed to, and installing would hand + // root and a fresh password to whatever now answers. + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await checkServer(user); + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(false); + + // A second check that has not answered yet. + h.inspect.mockReturnValue(new Promise(() => {})); + await user.click(screen.getByTestId("coolify-setup-inspect")); + + await waitFor(() => + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(true), + ); + expect(screen.queryByTestId("coolify-setup-inspection")).toBeNull(); + }); + it("says the press landed while the first connect is still going", async () => { // Disabled on its own reads as the button having refused. The connect // behind it can take seconds with nothing else on screen moving, and diff --git a/src/components/CoolifyServerSetup.tsx b/src/components/CoolifyServerSetup.tsx index 3135f261e7..060be19747 100644 --- a/src/components/CoolifyServerSetup.tsx +++ b/src/components/CoolifyServerSetup.tsx @@ -481,6 +481,18 @@ export function CoolifyServerSetup({ {/* One block, so Dismiss sits beside the message rather than inside the log — a connection or preflight refusal carries no output, and would otherwise have nothing to clear it. */} + {/* Shown whether or not the run was cancelled. The block below stays + quiet about a cancel, because stopping was the user's decision and + not a fault — but something the run changed and could not change + back is theirs to undo either way. */} + {setup.type === "failed" && setup.warning && ( +

+ {setup.warning} +

+ )} {setup.type === "failed" && !setup.cancelled && (
diff --git a/src/coolify_setup/controller.ts b/src/coolify_setup/controller.ts index 44b895847b..3b6ab60cea 100644 --- a/src/coolify_setup/controller.ts +++ b/src/coolify_setup/controller.ts @@ -194,6 +194,11 @@ export class CoolifySetupController { invocationRef, message: error instanceof Error ? error.message : String(error), cancelled, + // Set by whatever could not put the server back as it found it. + warning: + error instanceof Error + ? (error as Error & { warning?: string }).warning + : undefined, }); throw error; }) diff --git a/src/coolify_setup/https_setup.test.ts b/src/coolify_setup/https_setup.test.ts index 8a516dce83..e7a307a0e1 100644 --- a/src/coolify_setup/https_setup.test.ts +++ b/src/coolify_setup/https_setup.test.ts @@ -529,6 +529,44 @@ describe("tryEnableHttps", () => { expect(result.instanceUrl).toBe("http://203.0.113.5:8000"); }); + it("puts what is left to undo on a cancellation as it leaves", async () => { + // The panel says nothing about a cancelled run, so a domain that would + // not come back off would otherwise be reported only into a log the user + // is never shown. + const { session } = fakeSession(); + const answering = session.run as unknown as ( + command: string, + options?: { input?: string }, + ) => Promise; + const controller = new AbortController(); + let domainWrites = 0; + session.run = (async (command: string, options?: { input?: string }) => { + if ((options?.input ?? "").includes("fqdn")) { + domainWrites += 1; + // The domain goes on, then the user cancels, then taking it back off + // is what fails. + if (domainWrites === 1) { + const answer = await answering(command, options); + controller.abort(); + return answer; + } + throw new Error("tinker is wedged"); + } + return answering(command, options); + }) as unknown as SshSession["run"]; + + const error = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + check: async () => false, + signal: controller.signal, + }).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(Error); + expect((error as Error & { warning?: string }).warning).toMatch( + /may still be configured for/, + ); + }); + it("gives the revert the full budget when nobody is waiting on a cancel", async () => { // The short bound exists so "Stopping…" cannot hang. On the ordinary // no-certificate path there is no cancel, and the domain still has to diff --git a/src/coolify_setup/https_setup.ts b/src/coolify_setup/https_setup.ts index 3deb1a2666..52294a914c 100644 --- a/src/coolify_setup/https_setup.ts +++ b/src/coolify_setup/https_setup.ts @@ -440,6 +440,9 @@ export async function tryEnableHttps( // the domain comes back off before anyone is told what happened. let keepDomain = false; let settled: HttpsOutcome | null = null; + // Held so the revert below can add to whatever is on its way out. A cancel + // leaves by throwing, and its message is all the panel keeps. + let thrown: unknown; try { await applyInstanceDomain(session, domain, { signal }); @@ -469,6 +472,9 @@ export async function tryEnableHttps( `everyone using it, and it can run out.`, }; return settled; + } catch (error) { + thrown = error; + throw error; } finally { // Without the signal, which by this point may be the reason we are here. // Bounded by the tinker call's own timeout, so a wedged server cannot @@ -496,6 +502,15 @@ export async function tryEnableHttps( `Could not remove the temporary domain ${domain}. Coolify may ` + `still be set to answer at it.\n`, ); + // A cancel leaves this way, and the panel shows nothing for a + // cancelled run — so without this the one thing the user has to act + // on would be said only into a log nobody is shown. + if (thrown instanceof Error) { + (thrown as Error & { warning?: string }).warning = + `Coolify may still be configured for ${domain}; clear the ` + + `instance domain in its settings if the dashboard does not ` + + `answer at ${plainUrlFor(host)}.`; + } if (settled) { // Wrapped, so the trim applies to the joined sentence rather than // binding to the last piece of it. diff --git a/src/coolify_setup/state.ts b/src/coolify_setup/state.ts index e0bd1bdc94..57748bcc09 100644 --- a/src/coolify_setup/state.ts +++ b/src/coolify_setup/state.ts @@ -100,6 +100,14 @@ export interface CoolifySetupFailed { log: string; /** Cancelling is the user's decision, not a fault of the install. */ cancelled: boolean; + /** + * Something the run changed and could not change back. + * + * Kept apart from `message`, which says why the run ended: this is what is + * left for the user to do about it, and it outlives a cancel — where the + * panel says nothing about the ending itself. + */ + warning?: string; } export type CoolifySetupState = @@ -148,6 +156,8 @@ export type CoolifySetupEvent = invocationRef: CoolifySetupInvocationRef; message: string; cancelled: boolean; + /** Something the run changed and could not change back. */ + warning?: string; } /** The user has read the terminal screen and moved on. */ | { type: "dismissed" }; From 71993a6844ac4ec7454de3cfab2f37f5cad4bf6c Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 13:29:50 -0500 Subject: [PATCH 68/91] fix(coolify): confirm the API flag was written, not just assigned Tinker carries on after a statement throws, so echoing the property set a line earlier answered "enabled" for a save that never happened. The screen then leaves out the one step the user still has to do by hand, which is the worse way for this to be wrong. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/api_token.test.ts | 7 +++++++ src/coolify_setup/api_token.ts | 8 ++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/coolify_setup/api_token.test.ts b/src/coolify_setup/api_token.test.ts index 743654b1fd..5ed0cbd94c 100644 --- a/src/coolify_setup/api_token.test.ts +++ b/src/coolify_setup/api_token.test.ts @@ -139,6 +139,13 @@ describe("enableApi", () => { const session = fakeSession(["enabled"]); await expect(enableApi(session)).resolves.toBeUndefined(); expect(session.scripts[0]).toContain("is_api_enabled = true"); + // Read back from the database rather than off the property just set: + // tinker carries on after a statement throws, so a save that never + // happened would otherwise still answer "enabled". + expect(session.scripts[0]).toContain("$ok = $s->save()"); + expect(session.scripts[0]).toContain( + "$ok && \\App\\Models\\InstanceSettings::get()->is_api_enabled", + ); }); it("fails when the setting did not take", async () => { diff --git a/src/coolify_setup/api_token.ts b/src/coolify_setup/api_token.ts index e7aa6c7bad..67373532da 100644 --- a/src/coolify_setup/api_token.ts +++ b/src/coolify_setup/api_token.ts @@ -132,8 +132,12 @@ export async function enableApi( [ `$s = \\App\\Models\\InstanceSettings::get();`, `$s->is_api_enabled = true;`, - `$s->save();`, - `echo $s->is_api_enabled ? 'enabled' : 'still-disabled';`, + `$ok = $s->save();`, + // Read back rather than read off what was just assigned. Tinker keeps + // going after a statement throws, so echoing the property would say + // "enabled" for a save that never happened — and the screen would then + // leave out the one step the user still had to do by hand. + `echo $ok && \\App\\Models\\InstanceSettings::get()->is_api_enabled ? 'enabled' : 'still-disabled';`, ].join("\n"), { signal, timeoutMs: TINKER_TIMEOUT_MS }, ); From 7cc32c5fb613aa70f9e5b50518cb200c8b032f57 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 13:50:58 -0500 Subject: [PATCH 69/91] fix(coolify): make the leftover-domain warning actually reach the user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The state it travelled on was built field by field, and the field added for it was not among them — so it never left the machine, and the panel that rendered it could not have shown anything. Typechecking passed because the field is optional, and the test hand-built the state it asserted on, so both ends were pinned and the middle was not. Carried now, and pinned by a controller test that starts from a failing run rather than from a state written by hand. Moved to the connector as well. A warning only ever arrives with a cancel, and a cancelled run hands the screen back to the card rather than to the installer, so the panel holding it was never on screen. It carries its own way out, because the panel's Dismiss went with it. An HTTPS failure that is not a cancel goes on to succeed, so its own note now travels on the finished screen's reason instead. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyConnector.test.tsx | 64 +++++++++++++++++++++- src/components/CoolifyConnector.tsx | 35 ++++++++++++ src/components/CoolifyServerSetup.test.tsx | 29 ---------- src/components/CoolifyServerSetup.tsx | 12 ---- src/coolify_setup/controller.test.ts | 35 ++++++++++++ src/coolify_setup/setup_flow.test.ts | 18 ++++++ src/coolify_setup/setup_flow.ts | 14 +++-- src/coolify_setup/transition.test.ts | 19 +++++++ src/coolify_setup/transition.ts | 3 + 9 files changed, 183 insertions(+), 46 deletions(-) diff --git a/src/components/CoolifyConnector.test.tsx b/src/components/CoolifyConnector.test.tsx index f0ef2b982d..1c6d726f36 100644 --- a/src/components/CoolifyConnector.test.tsx +++ b/src/components/CoolifyConnector.test.tsx @@ -110,10 +110,14 @@ vi.mock("@/hooks/useLoadApp", () => ({ useLoadApp: () => ({ app: loadedApp.value, loading: false }), })); const setup = vi.hoisted(() => ({ state: { type: "idle" } as unknown })); +const dismissMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock("@/ipc/types", () => ({ ipc: { system: { openExternalUrl: vi.fn() }, - coolifySetup: { snapshot: () => Promise.resolve(setup.state) }, + coolifySetup: { + snapshot: () => Promise.resolve(setup.state), + dismiss: dismissMock, + }, events: { coolifySetup: { onChanged: () => () => {} } }, }, })); @@ -124,6 +128,7 @@ const { CoolifyConnector: Panel } = await import("./CoolifyConnector"); // each case has to say what it is, or it inherits the last one's. beforeEach(() => { setup.state = { type: "idle" }; + dismissMock.mockClear(); }); function CoolifyConnector(props: { appId: number | null }) { @@ -399,6 +404,63 @@ describe("a server Dyad set up but has no token for", () => { }).toEqual({ failureVisible: true, refusalCard: false, signOut: true }); }); + it("says what a cancelled run left on the server", async () => { + // A cancel hands the screen back to the card below rather than to the + // installer, so the panel that would otherwise carry this is not on + // screen at all. The domain is still pointing at the server either way. + deploy.value = SERVER_NO_TOKEN; + setup.state = { + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Cancelled.", + log: "", + cancelled: true, + warning: "Coolify may still be configured for 203.0.113.5.sslip.io.", + }; + render(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-warning").textContent).toContain( + "may still be configured", + ), + ); + // The card a cancel lands on, not the installer panel. + expect(screen.getByTestId("coolify-already-has-server")).toBeTruthy(); + expect(screen.queryByTestId("coolify-server-setup-stub")).toBeNull(); + }); + + it("gives a cancelled run's warning a way off the screen", async () => { + // Nothing else dismisses a cancelled run — the panel's own Dismiss went + // with the panel — so without this it would sit there for good. + deploy.value = SERVER_NO_TOKEN; + setup.state = { + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Cancelled.", + log: "", + cancelled: true, + warning: "Coolify may still be configured for 203.0.113.5.sslip.io.", + }; + const user = userEvent.setup(); + render(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-dismiss-warning")).toBeTruthy(), + ); + await user.click(screen.getByTestId("coolify-setup-dismiss-warning")); + expect(dismissMock).toHaveBeenCalled(); + }); + it("has nothing to sign out of when the run never got that far", async () => { // A failure before the account was written leaves Dyad holding nothing, // so the way to forget it is an offer to forget what does not exist. diff --git a/src/components/CoolifyConnector.tsx b/src/components/CoolifyConnector.tsx index 356811c132..46212054ab 100644 --- a/src/components/CoolifyConnector.tsx +++ b/src/components/CoolifyConnector.tsx @@ -182,6 +182,39 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { const isReportingFailure = setupState.type === "failed" && !setupState.cancelled; + /** + * What a run changed on the server and could not change back. + * + * Here rather than in the installer panel: this only ever arrives with a + * cancel, and a cancelled run hands the screen back to the card below + * instead of to that panel — so said there, it would never be seen. It + * carries its own way out because the panel's Dismiss goes with it, and + * until something dismisses, the machine stays on the cancelled run. + */ + const leftBehind = + setupState.type === "failed" && setupState.warning ? ( +
+

+ {setupState.warning} +

+ +
+ ) : null; + const serverSetup = ( { @@ -401,6 +434,7 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { if (status.serverUrl && !isReportingFailure) { return (
+ {leftBehind}
+ {leftBehind} {serverSetup} {/* Installing again is refused while Dyad still holds an admin password, and that refusal says to sign out first. Behind the diff --git a/src/components/CoolifyServerSetup.test.tsx b/src/components/CoolifyServerSetup.test.tsx index b47013ee28..6e2fe13f9e 100644 --- a/src/components/CoolifyServerSetup.test.tsx +++ b/src/components/CoolifyServerSetup.test.tsx @@ -565,35 +565,6 @@ describe("pressing Install", () => { ); }); - it("says what is left to undo even when the run was cancelled", async () => { - // Stopping was the user's decision, so the panel stays quiet about the - // ending — but a domain the run put on and could not take back off is - // theirs to clear, and saying nothing leaves a server answering at a - // name it has no certificate for. - h.snapshot.mockResolvedValue({ - type: "failed", - host: "203.0.113.5", - invocationRef: { - kind: "coolify-setup", - entityKey: "203.0.113.5", - operationId: "op-1", - }, - message: "Cancelled.", - log: "", - cancelled: true, - warning: "Coolify may still be configured for 203.0.113.5.sslip.io.", - }); - renderPanel(); - - await waitFor(() => - expect(screen.getByTestId("coolify-setup-warning").textContent).toContain( - "may still be configured", - ), - ); - // Still quiet about the cancellation itself. - expect(screen.queryByTestId("coolify-setup-failure")).toBeNull(); - }); - it("will not install against a verdict a new check has replaced", async () => { // Checking again is how the user reacts to changing the address or // suspecting the machine moved. Until the new answer lands there is no diff --git a/src/components/CoolifyServerSetup.tsx b/src/components/CoolifyServerSetup.tsx index 060be19747..3135f261e7 100644 --- a/src/components/CoolifyServerSetup.tsx +++ b/src/components/CoolifyServerSetup.tsx @@ -481,18 +481,6 @@ export function CoolifyServerSetup({ {/* One block, so Dismiss sits beside the message rather than inside the log — a connection or preflight refusal carries no output, and would otherwise have nothing to clear it. */} - {/* Shown whether or not the run was cancelled. The block below stays - quiet about a cancel, because stopping was the user's decision and - not a fault — but something the run changed and could not change - back is theirs to undo either way. */} - {setup.type === "failed" && setup.warning && ( -

- {setup.warning} -

- )} {setup.type === "failed" && !setup.cancelled && (
diff --git a/src/coolify_setup/controller.test.ts b/src/coolify_setup/controller.test.ts index 5dce9e251c..133ed1b4a9 100644 --- a/src/coolify_setup/controller.test.ts +++ b/src/coolify_setup/controller.test.ts @@ -222,6 +222,41 @@ describe("answers from a run nobody is watching any more", () => { }); }); +describe("what a run could not put back", () => { + it("reaches the state a panel reads, not just the error", async () => { + // The whole hop: an error carrying it, the dispatch that reads it off, + // and the transition that has to carry it onto the state. Asserting on + // either end alone leaves the middle free to drop it, which is what + // happened. + const { controller } = harness(async () => { + throw Object.assign( + new DyadError("Cancelled.", DyadErrorKind.UserCancelled), + { warning: "Coolify may still be configured for x.sslip.io." }, + ); + }); + + await controller.start(TARGET).result.catch(() => {}); + + expect(controller.getState()).toMatchObject({ + type: "failed", + cancelled: true, + warning: "Coolify may still be configured for x.sslip.io.", + }); + }); + + it("says nothing when a run had nothing to put back", async () => { + const { controller } = harness(async () => { + throw new DyadError("boom", DyadErrorKind.External); + }); + + await controller.start(TARGET).result.catch(() => {}); + + expect( + (controller.getState() as { warning?: string }).warning, + ).toBeUndefined(); + }); +}); + describe("telling anyone who is listening", () => { it("reports each change once, so windows can follow along", async () => { const { controller, states } = harness(async (_t, hooks) => { diff --git a/src/coolify_setup/setup_flow.test.ts b/src/coolify_setup/setup_flow.test.ts index 65aaa7775d..1b8f0095d2 100644 --- a/src/coolify_setup/setup_flow.test.ts +++ b/src/coolify_setup/setup_flow.test.ts @@ -335,6 +335,24 @@ describe("runServerSetup", () => { expect(result.credentials.password).toBeTruthy(); }); + it("carries what a failed HTTPS attempt left behind onto the screen", async () => { + // The run goes on and succeeds from here, so the failed state that would + // otherwise carry this is never reached — the finished screen is the only + // place left to say a domain is still pointing at the server. + const server = fakeServer(); + const result = await run(server, { + tryEnableHttpsImpl: async () => { + throw Object.assign(new Error("proxy would not restart"), { + warning: "Coolify may still be configured for x.sslip.io.", + }); + }, + }).promise; + + expect(result.secure).toBe(false); + expect(result.insecureReason).toContain("proxy would not restart"); + expect(result.insecureReason).toContain("may still be configured"); + }); + it("still stops when the user cancels during HTTPS", async () => { // Cancelling is the user asking for the work to stop, which is not the // same as a step that could not be done. diff --git a/src/coolify_setup/setup_flow.ts b/src/coolify_setup/setup_flow.ts index c7ca5ecbdf..81c2e9210b 100644 --- a/src/coolify_setup/setup_flow.ts +++ b/src/coolify_setup/setup_flow.ts @@ -275,13 +275,19 @@ export async function runServerSetup({ // throw one away. A domain left set with no certificate still leaves // port 8000 serving. if ((error as { kind?: string }).kind === "user_cancelled") throw error; + // Whatever it could not put back comes with it. The run goes on and + // succeeds from here, so this is the only place that note can still + // reach the finished screen — the failed state it would otherwise + // travel on is never reached. + const leftBehind = (error as Error & { warning?: string }).warning; + const said = + error instanceof Error + ? error.message + : "HTTPS could not be set up on this server."; https = { instanceUrl: plainUrlFor(target.host), secure: false, - reason: - error instanceof Error - ? error.message - : "HTTPS could not be set up on this server.", + reason: leftBehind ? `${said} ${leftBehind}` : said, }; } onAccountKnown?.({ credentials, dashboardUrl: https.instanceUrl }); diff --git a/src/coolify_setup/transition.test.ts b/src/coolify_setup/transition.test.ts index cb4c21f28b..c25ba4043f 100644 --- a/src/coolify_setup/transition.test.ts +++ b/src/coolify_setup/transition.test.ts @@ -274,6 +274,25 @@ describe("answers from a run that is no longer the one in hand", () => { ).toBe(state); }); + it("keeps what the run could not put back", () => { + // Separate from the message, which says why it ended. A cancel says + // nothing about the ending, so this is the only thing the panel has to + // show for a domain that would not come back off. + expect( + next(running(), { + type: "failed", + invocationRef: REF, + message: "Cancelled.", + cancelled: true, + warning: "Coolify may still be configured for x.sslip.io.", + }), + ).toMatchObject({ + type: "failed", + cancelled: true, + warning: "Coolify may still be configured for x.sslip.io.", + }); + }); + it("ignores a result that arrives after the screen was dismissed", () => { // Exactly the shape that put a finished install over a panel the user had // already moved on from. diff --git a/src/coolify_setup/transition.ts b/src/coolify_setup/transition.ts index 3b67b57721..2581ed8f7f 100644 --- a/src/coolify_setup/transition.ts +++ b/src/coolify_setup/transition.ts @@ -132,6 +132,9 @@ export function coolifySetupTransition( message: event.message, log: running.log, cancelled: event.cancelled, + // Carried, not derived: what a run could not put back is not + // recoverable from why it ended, and a cancel says nothing else. + warning: event.warning, }); } From dc2c8130a095198f0cbdf5f97f3b4c68d9ab7635 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 14:05:24 -0500 Subject: [PATCH 70/91] fix(coolify): check the failure is one of ours before saying it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The name is all that got us that far, and anything can carry it — so casting whatever string came with it handed back a value the type says cannot exist, past callers that have covered every case there is. The members are one list now, since the check and the type drifting apart is the way this comes back. Co-Authored-By: Claude Opus 5 --- src/shared/ssh_failure.test.ts | 11 +++++++++++ src/shared/ssh_failure.ts | 31 +++++++++++++++++++++++-------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/shared/ssh_failure.test.ts b/src/shared/ssh_failure.test.ts index 5ab32b56d9..1a7e40914c 100644 --- a/src/shared/ssh_failure.test.ts +++ b/src/shared/ssh_failure.test.ts @@ -30,6 +30,17 @@ describe("reading a failure off an error", () => { ).toBeNull(); }); + it("does not hand back a failure that is not one of ours", () => { + // The name is all that got us this far, and anything can carry it. + // Answering with an unrecognised string would put a value past a caller + // that has covered every case the type admits. + const odd = Object.assign(new Error("odd"), { + name: "SshError", + failure: "made-up", + }); + expect(sshFailureOf(odd)).toBeNull(); + }); + it("does not answer for one of ours carrying no failure", () => { // A shape that passes the name check but has nothing to read. const odd = Object.assign(new Error("odd"), { name: "SshError" }); diff --git a/src/shared/ssh_failure.ts b/src/shared/ssh_failure.ts index e294cedf73..5ff1a902a0 100644 --- a/src/shared/ssh_failure.ts +++ b/src/shared/ssh_failure.ts @@ -9,12 +9,19 @@ * keeps it off the boot; this keeps the question askable without reaching for * the client at all. */ -export type SshFailure = - | "auth-rejected" - | "host-key-rejected" - | "unreachable" +/** + * Every failure the client reports, and the only source of the type. + * + * One list rather than a union beside a lookup: the accessor below has to + * decide whether a value is one of these at runtime, and a second copy of + * the members is a copy that can fall behind the first. + */ +export const SSH_FAILURES = [ + "auth-rejected", + "host-key-rejected", + "unreachable", /** The connection stopped answering: nothing on it will work again. */ - | "timeout" + "timeout", /** * We gave up on one command, having asked it to be quick. * @@ -23,9 +30,12 @@ export type SshFailure = * "this attempt was slow" from "this link is dead", or one slow answer ends * a wait that had minutes left in it. */ - | "command-timeout" + "command-timeout", /** Nothing here recognised it. */ - | "unknown"; + "unknown", +] as const; + +export type SshFailure = (typeof SSH_FAILURES)[number]; /** * The failure an error carries, or null if it is not one of ours. @@ -37,5 +47,10 @@ export type SshFailure = export function sshFailureOf(error: unknown): SshFailure | null { if (!(error instanceof Error) || error.name !== "SshError") return null; const { failure } = error as Error & { failure?: unknown }; - return typeof failure === "string" ? (failure as SshFailure) : null; + // Checked, not asserted. The name is all that got us here, and anything + // may carry it — handing back a value the type says cannot exist would + // put it past a caller that has covered every case there is. + return SSH_FAILURES.includes(failure as SshFailure) + ? (failure as SshFailure) + : null; } From 9edadde35945156cbe5adb19f1bebfe46b2b420b Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 15:05:41 -0500 Subject: [PATCH 71/91] fix(coolify): only say the domain was applied if it was Tinker runs the next line after a statement throws, so a marker of its own was printed whether or not the write and the proxy rebuild happened. Putting the domain on, Dyad could then store its token against an address Coolify never took; taking it back off, the false success hid the very warning that says a domain was left behind. One statement now, so a throw takes the marker with it. This was the only other place with a marker standing on its own. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/https_setup.test.ts | 27 +++++++++++++++++++++++++++ src/coolify_setup/https_setup.ts | 24 +++++++++++++----------- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/coolify_setup/https_setup.test.ts b/src/coolify_setup/https_setup.test.ts index e7a307a0e1..ec5c15b215 100644 --- a/src/coolify_setup/https_setup.test.ts +++ b/src/coolify_setup/https_setup.test.ts @@ -491,6 +491,33 @@ describe("tryEnableHttps", () => { expect(said.join("")).toMatch(/Removing the temporary domain/); }); + it("only says applied if the write and the proxy rebuild both ran", async () => { + // Tinker goes on to the next line when a statement throws, so a marker + // standing on its own would report a domain that was never set. One + // statement means a throw takes the marker with it. + const { session } = fakeSession(); + const scripts: string[] = []; + session.run = (async (_command: string, options?: { input?: string }) => { + scripts.push(options?.input ?? ""); + return { code: 0, stdout: transcript("applied"), stderr: "" }; + }) as unknown as SshSession["run"]; + + await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + check: async () => true, + }); + + const applying = scripts.find((script) => script.includes("fqdn")) ?? ""; + // The save, the proxy rebuild and the marker are one statement, so the + // marker cannot be reached without them. + expect(applying).toContain("return 'applied'"); + expect(applying).not.toMatch(/echo 'applied'/); + const oneLine = applying.split("\n").find((line) => line.includes("fqdn"))!; + expect(oneLine).toContain("save()"); + expect(oneLine).toContain("setupDynamicProxyConfiguration()"); + expect(oneLine).toContain("return 'applied'"); + }); + it("says so when the domain will not come back off", async () => { // The state the revert exists to avoid, arrived at anyway. Left silent, // the last thing said was that the domain was being removed — which diff --git a/src/coolify_setup/https_setup.ts b/src/coolify_setup/https_setup.ts index 52294a914c..54a57c4d77 100644 --- a/src/coolify_setup/https_setup.ts +++ b/src/coolify_setup/https_setup.ts @@ -157,17 +157,19 @@ export async function applyInstanceDomain( } const answer = await runTinker( session, - [ - `$s = \\App\\Models\\InstanceSettings::get();`, - domain === null - ? `$s->fqdn = null;` - : `$s->fqdn = 'https://' . getenv('DYAD_INSTANCE_DOMAIN');`, - `$s->save();`, - // Server 0 is the machine Coolify runs on, which is the one serving the - // dashboard this domain points at. - `\\App\\Models\\Server::find(0)->setupDynamicProxyConfiguration();`, - `echo 'applied';`, - ].join("\n"), + // One statement, so nothing after a throw runs. Tinker carries on to the + // next line when a statement fails, which for a marker on its own line + // means the write can fail and still report success — and this marker is + // what the caller reads to decide the domain went on, or came back off. + // Server 0 is the machine Coolify runs on, which is the one serving the + // dashboard this domain points at. + `echo (function () { $s = \\App\\Models\\InstanceSettings::get(); ` + + (domain === null + ? `$s->fqdn = null; ` + : `$s->fqdn = 'https://' . getenv('DYAD_INSTANCE_DOMAIN'); `) + + `$s->save(); ` + + `\\App\\Models\\Server::find(0)->setupDynamicProxyConfiguration(); ` + + `return 'applied'; })();`, { env: domain === null ? {} : { DYAD_INSTANCE_DOMAIN: domain }, signal, From 874c9ad5a930d15df1a84da95c4d1b0daffaad50 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 15:05:41 -0500 Subject: [PATCH 72/91] fix(coolify): do not call a failed negotiation a changed identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A key the verifier turns down is answered before this is reached, so everything arriving here is the two ends failing to agree — an older or more restricted sshd. Reported as a rejected host key, it became "this server is not the one Dyad looked at", telling someone their machine may have been swapped because their ciphers are old. It says what happened now, and quotes what the server said. Co-Authored-By: Claude Opus 5 --- src/ipc/utils/ssh_client.test.ts | 30 ++++++++++++++++++++++++++++++ src/ipc/utils/ssh_client.ts | 13 ++++++++++--- src/shared/ssh_failure.ts | 9 +++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/ipc/utils/ssh_client.test.ts b/src/ipc/utils/ssh_client.test.ts index 12b8f3b872..987d268e36 100644 --- a/src/ipc/utils/ssh_client.test.ts +++ b/src/ipc/utils/ssh_client.test.ts @@ -200,6 +200,15 @@ describe("classifying a failed connection", () => { failure: "unreachable", kind: "external", }, + { + name: "two ends that cannot agree on ciphers", + raw: { + level: "handshake", + message: "Handshake failed: no matching key exchange algorithm", + }, + failure: "handshake-failed", + kind: "external", + }, { name: "anything else", raw: { level: "client-socket", message: "kernel exploded" }, @@ -208,6 +217,27 @@ describe("classifying a failed connection", () => { }, ]; + it("does not call a failed negotiation a changed identity", async () => { + // The key being turned down is answered before this is reached, where + // the verifier said no. What is left is an old or hardened sshd with no + // algorithm in common — and telling that user their machine may have + // been swapped is a false alarm about the one thing this checks for. + h.nextFailure = Object.assign( + new Error("Handshake failed: no matching host key format"), + { level: "handshake" }, + ); + const error = (await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ).catch((e) => e)) as SshError; + + expect(error.failure).not.toBe("host-key-rejected"); + expect(error.message).not.toMatch(/different host key/); + expect(error.message).toMatch(/could not agree on how to connect/); + // What the server said, so the real cause is not lost. + expect(error.message).toMatch(/no matching host key format/); + }); + it.each(CASES)("reads $name as $failure", async ({ raw, failure, kind }) => { h.nextFailure = Object.assign(new Error(String(raw.message)), raw); const error = (await connectSsh( diff --git a/src/ipc/utils/ssh_client.ts b/src/ipc/utils/ssh_client.ts index 7a5426ecaf..fe375cace0 100644 --- a/src/ipc/utils/ssh_client.ts +++ b/src/ipc/utils/ssh_client.ts @@ -102,10 +102,17 @@ function classify( ); } if (level === "handshake") { + // Not the key being turned down: that is answered before this is asked, + // where the verifier said no. What is left is the two ends failing to + // agree — so this must not say the machine may have been swapped, which + // is what a user with an old sshd would otherwise be told. return new SshError( - "host-key-rejected", - "The server presented a different host key than the one expected.", - DyadErrorKind.Precondition, + "handshake-failed", + `Dyad and this server could not agree on how to connect${ + err.message ? `: ${err.message}` : "" + }. That usually means the server's SSH is older or more restricted ` + + `than Dyad's defaults.`, + DyadErrorKind.External, ); } if (level === "client-timeout") { diff --git a/src/shared/ssh_failure.ts b/src/shared/ssh_failure.ts index 5ff1a902a0..a7d7f35e01 100644 --- a/src/shared/ssh_failure.ts +++ b/src/shared/ssh_failure.ts @@ -31,6 +31,15 @@ export const SSH_FAILURES = [ * a wait that had minutes left in it. */ "command-timeout", + /** + * The handshake failed for a reason that is not the key being turned down. + * + * Key exchange or host-key algorithms with nothing in common, or a + * protocol error. Kept apart from "host-key-rejected" because that one + * says the server may not be the machine it was — which is alarming, and + * wrong for an old or hardened sshd that simply cannot agree on ciphers. + */ + "handshake-failed", /** Nothing here recognised it. */ "unknown", ] as const; From 8718a1bf47b65a36b61ee931064c80bac4c06d28 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 15:05:41 -0500 Subject: [PATCH 73/91] fix(coolify): stop offering an install that is always refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run that failed after the account was written leaves the installer up, and a fresh check re-enabled Install — for a press the handler can only answer with "sign out first". The panel had no way to know, so it now takes the held server and says so beside the way out of it. The leftover-domain warning also reaches the token form and the connected view: the run belongs to the machine, not to one window, and a window that had moved on still needs to know. Its two sentences are joined as two. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyConnector.test.tsx | 28 ++++++++++++++++++++++ src/components/CoolifyConnector.tsx | 5 ++++ src/components/CoolifyServerSetup.test.tsx | 27 +++++++++++++++++++-- src/components/CoolifyServerSetup.tsx | 24 ++++++++++++++++++- src/coolify_setup/setup_flow.test.ts | 3 +++ src/coolify_setup/setup_flow.ts | 6 ++++- 6 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/components/CoolifyConnector.test.tsx b/src/components/CoolifyConnector.test.tsx index 1c6d726f36..752c1657b4 100644 --- a/src/components/CoolifyConnector.test.tsx +++ b/src/components/CoolifyConnector.test.tsx @@ -434,6 +434,34 @@ describe("a server Dyad set up but has no token for", () => { expect(screen.queryByTestId("coolify-server-setup-stub")).toBeNull(); }); + it("says it on the token form too, where another window may be sitting", async () => { + // The run belongs to the machine, not to a window. One that had already + // moved on to entering a token still needs to know what was left behind. + deploy.value = NO_TOKEN; + setup.state = { + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Cancelled.", + log: "", + cancelled: true, + warning: "Coolify may still be configured for 203.0.113.5.sslip.io.", + }; + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "I already have Coolify installed" }), + ); + expect(screen.getByTestId("coolify-setup-warning").textContent).toContain( + "may still be configured", + ); + }); + it("gives a cancelled run's warning a way off the screen", async () => { // Nothing else dismisses a cancelled run — the panel's own Dismiss went // with the panel — so without this it would sit there for good. diff --git a/src/components/CoolifyConnector.tsx b/src/components/CoolifyConnector.tsx index 46212054ab..349490ca4c 100644 --- a/src/components/CoolifyConnector.tsx +++ b/src/components/CoolifyConnector.tsx @@ -217,6 +217,9 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { const serverSetup = ( { if (url) setInstanceUrl(url); setIsEnteringToken(true); @@ -484,6 +487,7 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { const isInsecure = hasUsableScheme && !isSecureInstanceUrl(trimmedUrl); return (
+ {leftBehind}

Deploy this app to a Coolify instance you run. In Coolify, enable the API under Settings → Advanced → API Access, then create a token under @@ -645,6 +649,7 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { ); return (

+ {leftBehind} {coolifySection}
diff --git a/src/components/CoolifyServerSetup.test.tsx b/src/components/CoolifyServerSetup.test.tsx index 6e2fe13f9e..f4264c53a0 100644 --- a/src/components/CoolifyServerSetup.test.tsx +++ b/src/components/CoolifyServerSetup.test.tsx @@ -70,7 +70,10 @@ vi.mock("@/ipc/types", () => ({ const { CoolifyServerSetup } = await import("./CoolifyServerSetup"); -function renderPanel(onUseExisting = vi.fn()) { +function renderPanel( + onUseExisting = vi.fn(), + props: { heldServerUrl?: string | null } = {}, +) { const client = new QueryClient({ defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, }); @@ -79,7 +82,7 @@ function renderPanel(onUseExisting = vi.fn()) { invalidate, ...render( - +
, @@ -565,6 +568,26 @@ describe("pressing Install", () => { ); }); + it("does not offer an install the handler will refuse", async () => { + // A run that failed after the account was written leaves this panel up, + // and a fresh check would otherwise re-enable Install — for a press that + // can only come back as "sign out first". + const user = userEvent.setup(); + renderPanel(vi.fn(), { heldServerUrl: "http://203.0.113.5:8000" }); + await user.type(screen.getByTestId("coolify-setup-host"), "198.51.100.9"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await checkServer(user); + + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(true); + // And says why, next to the way out of it. + expect( + screen.getByTestId("coolify-setup-holds-account").textContent, + ).toContain("Sign out of Coolify"); + }); + it("will not install against a verdict a new check has replaced", async () => { // Checking again is how the user reacts to changing the address or // suspecting the machine moved. Until the new answer lands there is no diff --git a/src/components/CoolifyServerSetup.tsx b/src/components/CoolifyServerSetup.tsx index 3135f261e7..dc5d544485 100644 --- a/src/components/CoolifyServerSetup.tsx +++ b/src/components/CoolifyServerSetup.tsx @@ -70,6 +70,7 @@ function CopyButton({ value, label }: { value: string; label: string }) { export function CoolifyServerSetup({ onUseExisting, + heldServerUrl, children, }: { /** @@ -80,6 +81,14 @@ export function CoolifyServerSetup({ * built — and leaving it blank means typing from memory. */ onUseExisting: (instanceUrl?: string) => void; + /** + * The server Dyad already holds an account for, if it holds one. + * + * Installing is refused outright while it does — Dyad has the only copy of + * that password — so without this the button is live for a press that can + * only come back as an error. + */ + heldServerUrl?: string | null; /** Sits under the form. Not under the run or the result, which have their own next step and nothing to add to. */ children?: ReactNode; @@ -587,7 +596,10 @@ export function CoolifyServerSetup({ // whatever answers the address with the admin password and a // token. It also catches an existing Coolify, too little memory // and a held package lock, which is a failed install either way. - inspectionForHost?.ready !== true + inspectionForHost?.ready !== true || + // Refused by the handler while Dyad holds an account, so offering + // it here only produces a toast. + Boolean(heldServerUrl) } onClick={() => run.mutate()} data-testid="coolify-setup-install" @@ -599,6 +611,16 @@ export function CoolifyServerSetup({ Install Coolify
+ {heldServerUrl && ( +

+ Dyad is holding the admin password for {heldServerUrl}, and it has the + only copy. Sign out of Coolify to set up another — that shows the + password one last time before forgetting it. +

+ )} {!inspectionForHost && host.trim() && (

Check the server first. Dyad shows you its fingerprint, and installs diff --git a/src/coolify_setup/setup_flow.test.ts b/src/coolify_setup/setup_flow.test.ts index 1b8f0095d2..f7224f05cc 100644 --- a/src/coolify_setup/setup_flow.test.ts +++ b/src/coolify_setup/setup_flow.test.ts @@ -351,6 +351,9 @@ describe("runServerSetup", () => { expect(result.secure).toBe(false); expect(result.insecureReason).toContain("proxy would not restart"); expect(result.insecureReason).toContain("may still be configured"); + // Two sentences. The library's message may or may not end in a stop of + // its own, and neither spelling should run the two together. + expect(result.insecureReason).toContain("restart. Coolify"); }); it("still stops when the user cancels during HTTPS", async () => { diff --git a/src/coolify_setup/setup_flow.ts b/src/coolify_setup/setup_flow.ts index 81c2e9210b..6256998df7 100644 --- a/src/coolify_setup/setup_flow.ts +++ b/src/coolify_setup/setup_flow.ts @@ -287,7 +287,11 @@ export async function runServerSetup({ https = { instanceUrl: plainUrlFor(target.host), secure: false, - reason: leftBehind ? `${said} ${leftBehind}` : said, + // Two sentences, not a run-on: the message is the library's or + // Coolify's and may or may not end in a stop of its own. + reason: leftBehind + ? `${said.replace(/\s*\.?\s*$/, "")}. ${leftBehind}` + : said, }; } onAccountKnown?.({ credentials, dashboardUrl: https.instanceUrl }); From 46ea4882f18b1206bf4bc2d2a3c6b829564f537e Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 15:32:42 -0500 Subject: [PATCH 74/91] fix(coolify): say it on every screen the run can be watched from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The warning reached three of the four screens a window can be on. The fourth is an app that already has somewhere to deploy, which is the easiest tab to be sitting on when a cancel lands — and the run belongs to the machine, not to one app. Two pieces of wiring could also be deleted without a test noticing: the warning on the connected view, and the held server handed to the installer. Both are held now. Two sentences that met badly: a message ending in its own colon left a stop stranded after it, and the handshake message repeated the library's "Handshake failed" preamble it had just paraphrased. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyConnector.test.tsx | 54 ++++++++++++++++++++++++ src/components/CoolifyConnector.tsx | 1 + src/coolify_setup/setup_flow.test.ts | 16 +++++++ src/coolify_setup/setup_flow.ts | 2 +- src/ipc/utils/ssh_client.test.ts | 2 + src/ipc/utils/ssh_client.ts | 6 ++- 6 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/components/CoolifyConnector.test.tsx b/src/components/CoolifyConnector.test.tsx index 752c1657b4..00f7bc7254 100644 --- a/src/components/CoolifyConnector.test.tsx +++ b/src/components/CoolifyConnector.test.tsx @@ -47,11 +47,17 @@ vi.mock("@/components/CoolifyServerSetup", () => ({ CoolifyServerSetup: ({ children, onUseExisting, + heldServerUrl, }: { children?: React.ReactNode; onUseExisting?: (url?: string) => void; + heldServerUrl?: string | null; }) => (

+ {/* Shown so the wiring is observable. The panel refuses to install + while this is set, and nothing else here would notice it being + dropped on the way in. */} + {heldServerUrl ?? ""} +

Could not read this app's Coolify setup

+

{getErrorMessage(statusError)}

+ +
); } @@ -437,7 +467,7 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { if (status.serverUrl && !isReportingFailure) { return (
- {leftBehind} + {terminalNotice}
- {leftBehind} + {terminalNotice} {serverSetup} {/* Installing again is refused while Dyad still holds an admin password, and that refusal says to sign out first. Behind the @@ -487,7 +517,7 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { const isInsecure = hasUsableScheme && !isSecureInstanceUrl(trimmedUrl); return (
- {leftBehind} + {terminalNotice}

Deploy this app to a Coolify instance you run. In Coolify, enable the API under Settings → Advanced → API Access, then create a token under @@ -649,7 +679,7 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { ); return (

- {leftBehind} + {terminalNotice} {coolifySection}
@@ -1006,7 +1036,7 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { return (
- {leftBehind} + {terminalNotice} {coolifySection}
From f77d9eb37b0c92a42c8bf03049981bcac81ae5ff Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 16:48:54 -0500 Subject: [PATCH 78/91] docs(coolify): the prompt is modelled, not required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fake emits it because a real transcript carries one — said as though the parser demanded it, a maintainer would decline to model the psysh that stops printing it, which is the case the parser was just widened for. The marker comment also named the wrong thing as what excludes the echo: it is the whole-line anchor, not the prompt. Co-Authored-By: Claude Opus 5 --- e2e-tests/helpers/fake_ssh_server.ts | 9 ++++++--- src/coolify_setup/tinker.ts | 4 +++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/e2e-tests/helpers/fake_ssh_server.ts b/e2e-tests/helpers/fake_ssh_server.ts index 01d269b809..c66a62f99f 100644 --- a/e2e-tests/helpers/fake_ssh_server.ts +++ b/e2e-tests/helpers/fake_ssh_server.ts @@ -74,9 +74,12 @@ interface FakeServerState extends FakeServerBehaviour { * * tinker echoes every line it is fed with a "> " prompt, and the output of a * statement lands on the line after the prompt that produced it — so the - * opening marker arrives with a prompt attached, which is exactly what - * distinguishes it from the echo of the line that printed it. Getting this - * wrong is not cosmetic: the parser matches on that prefix. + * opening marker arrives with a prompt attached. + * + * The prompt is emitted because a real transcript carries one, not because + * the parser demands it: that tolerates the prompt being absent or repeated, + * since which of those psysh prints is its own business. A fake that dropped + * it would simply stop modelling what a real one sends. */ function transcript(script: string, output: string): string { const echoed = script diff --git a/src/coolify_setup/tinker.ts b/src/coolify_setup/tinker.ts index 36ad88eb6d..225c30882b 100644 --- a/src/coolify_setup/tinker.ts +++ b/src/coolify_setup/tinker.ts @@ -17,7 +17,9 @@ import type { SshSession } from "@/ipc/utils/ssh_client"; * tinker echoes every line it is fed, prefixed `> `, and the first line of real * output lands on the same line as the last prompt. So the transcript contains * the marker twice: once in the echo of the line that prints it, once as the - * output itself. Anchoring on `> MARKER` alone would match the echo. + * output itself. Looking for the marker anywhere on a line would take the + * echo, so the match is anchored to a whole line — which the echo cannot be, + * because it carries the script around the marker. */ const START = "__DYAD_OUT_START__"; const END = "__DYAD_OUT_END__"; From 2b94c98164fcf86d9cb8ccab732f031d7072e219 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 19:50:53 -0500 Subject: [PATCH 79/91] fix(coolify): do not start an install it cannot record the password for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The record written on the way in was allowed to fail quietly, on the reasoning that the write after it would catch the account. It usually does — but if Dyad exits before then, Coolify has an admin account whose password nobody holds, and preflight refuses to install again, so there is no way back to it. Refused here and only here: nothing has reached the server yet, so this costs a retry. Past this point the account exists and ending the run would throw the password away, which is why the write after it still carries on. What the keychain said goes to the log rather than to a screen that is already carrying a password this run invented. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/setup_flow.test.ts | 28 ++++++- .../handlers/coolify_setup_handlers.test.ts | 80 ++++++++++++++----- src/ipc/handlers/coolify_setup_handlers.ts | 19 ++++- 3 files changed, 104 insertions(+), 23 deletions(-) diff --git a/src/coolify_setup/setup_flow.test.ts b/src/coolify_setup/setup_flow.test.ts index 8c322a8fff..48ba18ed25 100644 --- a/src/coolify_setup/setup_flow.test.ts +++ b/src/coolify_setup/setup_flow.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { runServerSetup, type SetupStep } from "./setup_flow"; import { waitForAdminSeeded } from "./install"; import { tryEnableHttps } from "./https_setup"; -import { DyadErrorKind } from "@/errors/dyad_error"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { SshError } from "@/ipc/utils/ssh_client"; import type { SshSession } from "@/ipc/utils/ssh_client"; @@ -655,6 +655,32 @@ describe("runServerSetup", () => { expect(seen[0].at).toBe(0); }); + it("does not install when the caller could not keep the credentials", async () => { + // The hook above runs before the installer for a reason, and the caller + // refuses there when it cannot record the password. That only costs a + // retry if nothing has been installed by then. + const server = fakeServer(); + let installs = 0; + const original = server.session.run; + server.session.run = ((command: string, options?: { input?: string }) => { + if (command.includes("bash -s")) installs += 1; + return (original as unknown as typeof server.session.run)( + command, + options, + ); + }) as unknown as SshSession["run"]; + + await expect( + run(server, { + onCredentialsBuilt: () => { + throw new DyadError("nowhere to keep it", DyadErrorKind.External); + }, + }).promise, + ).rejects.toThrow(/nowhere to keep it/); + + expect(installs).toBe(0); + }); + it("says the link died rather than blaming the version for it", async () => { // Answering null for a dead connection sends the user to the screen that // tells them their freshly installed Coolify is too old to drive — for a diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts index 487a6efee3..f6087d74ec 100644 --- a/src/ipc/handlers/coolify_setup_handlers.test.ts +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -21,6 +21,14 @@ const h = vi.hoisted(() => ({ writeThrows: false, /** How many writes fail before the store comes back. */ writeFailures: 0, + /** + * Writes that succeed before the failures above start. + * + * The record written on the way in is the first write, and failing it now + * refuses the run outright — so a case about the writes that come after the + * account exists has to let that one through. + */ + writeOkFirst: 0, reportsAccountTwice: false, failsBeforeCredentials: false, onRunStarted: null as null | (() => void), @@ -43,6 +51,12 @@ vi.mock("./base", () => ({ vi.mock("@/main/settings", () => ({ readSettings: () => h.settings, writeSettings: (value: Record) => { + if (h.writeOkFirst > 0) { + h.writeOkFirst -= 1; + h.written.push(value); + Object.assign(h.settings, value); + return; + } if (h.writeFailures > 0) { h.writeFailures -= 1; throw new Error("keychain is unavailable"); @@ -209,6 +223,7 @@ beforeEach(() => { h.verifiedAgainst.length = 0; h.writeThrows = false; h.writeFailures = 0; + h.writeOkFirst = 0; h.reportsAccountTwice = false; h.preflightThrows = false; h.preflightReady = true; @@ -299,27 +314,49 @@ describe("run", () => { await expect(checkThenRun()).rejects.toThrow(/identity has changed/); }); + it("does not start an install it cannot record the password for", async () => { + // Before the installer, so nothing has been done to the server and this + // costs a retry. Carrying on would put an account on a machine whose + // password Dyad never managed to keep — and preflight then refuses to + // install again, so there is no way back to it. + h.writeThrows = true; + + const error = (await checkThenRun().catch((e: unknown) => e)) as Error; + + expect(error.message).toMatch(/could not save the admin/); + // What the keychain said is logged, not handed to a screen that already + // carries a password this run invented. + expect(error.message).not.toMatch(/Abc123@xyz/); + expect(error.message).not.toMatch(/keychain is unavailable/); + // And nothing was left behind to hold up the next attempt. + expect( + (h.settings.coolify as { admin?: unknown } | undefined)?.admin, + ).toBeUndefined(); + }); + it("finishes when the account cannot be written down", async () => { // The account is on the server either way, and a retry is refused because // Coolify is installed now — so ending the run here would lose the only - // copy of a password Dyad invented. + // copy of a password Dyad invented. The record on the way in lands: that + // one failing refuses the run instead, before anything is installed. + h.writeOkFirst = 1; h.writeThrows = true; - await expect(checkThenRun()).resolves.toMatchObject({ - adminPassword: "Abc123@xyz", - // Nothing was written, so the next screen has no token to use — saying - // otherwise sends the user to a panel that cannot work. - tokenStored: false, - tokenUnavailableReason: expect.stringContaining("could not save"), - }); + const result = (await checkThenRun()) as { + adminPassword: string; + tokenStored: boolean; + tokenUnavailableReason: string; + }; + expect(result.adminPassword).toBe("Abc123@xyz"); + // Nothing was written, so the next screen has no token to use — saying + // otherwise sends the user to a panel that cannot work. + expect(result.tokenStored).toBe(false); // Where the password actually is. The screen puts the card above this // message, and on this path it is the only copy — so the direction is // the part that matters, and it is written here rather than there. - const { tokenUnavailableReason } = (await checkThenRun()) as { - tokenUnavailableReason: string; - }; - expect(tokenUnavailableReason).toContain("password above"); + expect(result.tokenUnavailableReason).toContain("could not save"); + expect(result.tokenUnavailableReason).toContain("password above"); }); it("refuses a server it has not looked at", async () => { @@ -414,10 +451,10 @@ describe("run", () => { it("stores the account on the way out when the first attempt failed", async () => { // Coolify has the account either way, and preflight refuses to install // over it — so a password stored nowhere is a server nobody can sign into. - // Two, not one: the record written on the way in is the first write, so - // failing only that leaves the account's own write to succeed and the - // retry below never runs. - h.writeFailures = 2; + // The record on the way in lands — failing that refuses the run — and + // the account's own write is the one that does not, so the retry runs. + h.writeOkFirst = 1; + h.writeFailures = 1; h.reportsAccount = true; h.setupError = new DyadError("exit 1", DyadErrorKind.External); @@ -433,9 +470,10 @@ describe("run", () => { // The account is reported twice — once when it exists, and again once // HTTPS has settled where it answers. A copy kept from the first would // write the earlier address back over the later one on the way out. - // Two writes fail: the one on the way in, and the first of the two - // accounts — so the copy left behind is the one holding the old address. - h.writeFailures = 2; + // The record on the way in lands, and the first of the two accounts does + // not — so the copy left behind is the one holding the old address. + h.writeOkFirst = 1; + h.writeFailures = 1; h.reportsAccount = true; h.reportsAccountTwice = true; h.setupError = new DyadError("exit 1", DyadErrorKind.External); @@ -452,7 +490,9 @@ describe("run", () => { it("reports what went wrong, not what the retry did", async () => { // A write that fails again must not become the failure the user is told - // about — the install is what they were watching. + // about — the install is what they were watching. The record on the way + // in lands, since failing that refuses the run before it starts. + h.writeOkFirst = 1; h.writeThrows = true; h.reportsAccount = true; h.setupError = new DyadError("exit 1", DyadErrorKind.External); diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index cb6b5a294c..dce5e35450 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -166,9 +166,24 @@ function setupController(): CoolifySetupController { instanceUrl: dashboardUrl, }; } catch (error) { - // The run is worth more than this record. Failing here only means - // the account has to be caught by the write below instead. + // Refused rather than carried, and only here: nothing has been + // done to the server yet, so this costs a retry. Past this point + // the account exists and ending the run would throw away the only + // copy of its password, which is why the write below carries on + // instead. + // + // The reason is not passed through. This run carries a password + // and the handler is marked not to be logged, so what went wrong + // goes to the log and the user gets words of ours. logger.error("Could not store the admin account early", error); + throw new DyadError( + "Dyad could not save the admin password on this computer, so " + + "it has not started the install — a server it cannot record " + + "the password for is one nobody can sign in to. Nothing was " + + "sent to the server. Try again once there is room on disk " + + "and the keychain is available.", + DyadErrorKind.External, + ); } }, // Written the moment the account exists rather than at the end. A From 0548ea867b71ba9ffd90f40ccdf4ba61a673a285 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 19:50:53 -0500 Subject: [PATCH 80/91] fix(coolify): read unreadable memory as unknown, not as enough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole-transcript guard fires only when nothing came back at all. A server that answers the question but not this part of it sends `mem=`, which is empty rather than missing — so the size check had nothing to compare and let it through as ready. On a machine under 2GB the install then finishes and Coolify does not run, and the container it left refuses a second attempt. Same shape as the probe that could not say whether Coolify was there: not being able to tell is its own answer. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/install.test.ts | 22 ++++++++++++++++++++++ src/coolify_setup/install.ts | 19 ++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/coolify_setup/install.test.ts b/src/coolify_setup/install.test.ts index c17c497cb1..433f46248c 100644 --- a/src/coolify_setup/install.test.ts +++ b/src/coolify_setup/install.test.ts @@ -133,6 +133,28 @@ describe("preflight", () => { expect(checks.reason).toContain("could not read"); }); + it("refuses a server whose memory it could not read", async () => { + // A server that answers the question but not this part of it sends back + // `mem=` — empty, not missing, so the whole-transcript guard above does + // not fire and the size check has nothing to compare. Read as ready, the + // 2GB rule is quietly absent: the install finishes and Coolify does not + // run, on a machine that now refuses a second attempt. + const session = sessionAnswering( + vi.fn(async () => ({ + code: 0, + stdout: "mem=\ncontainer=\nbusy=no", + stderr: "", + })) as never, + ); + const checks = await preflight(session); + + expect(checks.ready).toBe(false); + expect(checks.memoryMb).toBeNull(); + expect(checks.reason).toContain("could not read how much memory"); + // It answered about Coolify, so that part is not in doubt. + expect(checks.installedKnown).toBe(true); + }); + it("still reports a server that is busy", async () => { const session = sessionAnswering( vi.fn(async () => ({ diff --git a/src/coolify_setup/install.ts b/src/coolify_setup/install.ts index d4267e6f74..e81a8824df 100644 --- a/src/coolify_setup/install.ts +++ b/src/coolify_setup/install.ts @@ -155,7 +155,24 @@ export async function preflight( "instead of installing again.", }; } - if (memoryMb !== null && memoryMb < MINIMUM_MEMORY_MB) { + // Unreadable, not unlimited. The transcript guard above only fires when + // nothing at all came back, and a server whose /proc/meminfo cannot be read + // answers `mem=` — an empty value, which reaches here as null and would + // walk straight past the check below. Installing then finishes and Coolify + // does not run, on a machine that now refuses a second attempt. + if (memoryMb === null) { + return { + ready: false, + alreadyInstalled, + installedKnown: true, + memoryMb: null, + reason: + "Dyad could not read how much memory this server has, so it cannot " + + "tell whether Coolify would run on it. Check the server and try " + + "again.", + }; + } + if (memoryMb < MINIMUM_MEMORY_MB) { return { ready: false, alreadyInstalled, From bce43e1ed7fbcaa9e06c85a252af6c678de328d7 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 19:50:53 -0500 Subject: [PATCH 81/91] test(coolify): pin the domain rule on its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What it allows is interpolated into a script that runs as root on the user's server, and it was the one validator here with no spec of its own — reachable only through the panel and the script builder, neither of which would notice the alphabet being widened. Co-Authored-By: Claude Opus 5 --- src/shared/coolify_domain.test.ts | 54 +++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/shared/coolify_domain.test.ts diff --git a/src/shared/coolify_domain.test.ts b/src/shared/coolify_domain.test.ts new file mode 100644 index 0000000000..f8a1aff5e9 --- /dev/null +++ b/src/shared/coolify_domain.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { isPlausibleInstanceDomain } from "./coolify_domain"; + +/** + * Pinned directly, not only through the panel and the script builder. + * + * What this decides ends up inside a command that runs as root on the user's + * server, so the characters it lets through are the point — and a rule with + * no test of its own is a rule that can be widened without anyone noticing. + */ +describe("what Coolify will be given as a domain", () => { + it("takes a name however the user pasted it", () => { + for (const written of [ + "coolify.example.com", + "https://coolify.example.com", + "http://coolify.example.com", + "https://coolify.example.com/", + " coolify.example.com ", + ]) { + expect(isPlausibleInstanceDomain(written)).toBe(true); + } + }); + + it("refuses anything that could end the quoting around it", () => { + // Each of these would leave the script saying something other than what + // it was built to say. Refused rather than escaped: escaping is a thing + // to get subtly wrong once, on someone else's machine, as root. + for (const hostile of [ + "coolify.example.com'", + 'coolify.example.com"', + "coolify.example.com`id`", + "coolify.example.com$(id)", + "coolify.example.com;id", + "coolify.example.com id", + "coolify.example.com\nid", + "coolify.example.com\\", + "coolify.example.com&&id", + "coolify.example.com|id", + "coolify.example.com#", + "$HOME.example.com", + ]) { + expect( + isPlausibleInstanceDomain(hostile), + `${JSON.stringify(hostile)} should be refused`, + ).toBe(false); + } + }); + + it("refuses a value with nothing left once the scheme is off", () => { + for (const empty of ["", " ", "https://", "https:///"]) { + expect(isPlausibleInstanceDomain(empty)).toBe(false); + } + }); +}); From 5f18892a9462cb0b1f0a6577091361b625739eb1 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 21:17:55 -0500 Subject: [PATCH 82/91] fix(coolify): do not call a version it could not read an old one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An answer that is not a version came back as null, which the caller reports as a Coolify too old to set up automatically. The installer fetches the newest one, so that is the least likely thing to be true — and the key this reads is Coolify's own, free to be renamed, which is the ordinary way to get an answer like that. A genuinely old version parses and is refused on its merits, so the sentence this replaces was only ever shown when it was wrong. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/api_token.test.ts | 11 +++++++++-- src/coolify_setup/api_token.ts | 14 +++++++++++++- src/coolify_setup/setup_flow.test.ts | 19 +++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/coolify_setup/api_token.test.ts b/src/coolify_setup/api_token.test.ts index 5ed0cbd94c..247067fd3e 100644 --- a/src/coolify_setup/api_token.test.ts +++ b/src/coolify_setup/api_token.test.ts @@ -75,9 +75,16 @@ describe("readCoolifyVersion", () => { expect(await readCoolifyVersion(session)).toBe("4.3.2"); }); - it("answers null when the reply is not a version", async () => { + it("says it could not read the version rather than that it is old", async () => { + // The installer always fetches the newest Coolify, so "too old" is the + // least likely thing to be true here — and the key this reads is + // Coolify's own, free to be renamed, which is the ordinary way to get an + // answer that is not a version. Saying the version is unsupported sends + // the user looking for a problem with a server they just installed. const session = fakeSession(["Command not found"]); - expect(await readCoolifyVersion(session)).toBeNull(); + await expect(readCoolifyVersion(session)).rejects.toThrow( + /could not read which version/, + ); }); it("does not call a lost link an unreadable version", async () => { diff --git a/src/coolify_setup/api_token.ts b/src/coolify_setup/api_token.ts index 67373532da..54848a3d95 100644 --- a/src/coolify_setup/api_token.ts +++ b/src/coolify_setup/api_token.ts @@ -82,7 +82,19 @@ export async function readCoolifyVersion( `echo config('constants.coolify.version');`, { signal, timeoutMs: TINKER_TIMEOUT_MS }, ); - return /^\d+\.\d+/.test(output.trim()) ? output.trim() : null; + const said = output.trim(); + if (/^\d+\.\d+/.test(said)) return said; + // It answered, and what it said was not a version. Null would send the + // caller down the path that reports a Coolify too old to drive — and the + // installer always fetches the newest one, so that is the least likely + // thing to be true. The key this reads is Coolify's own and free to move, + // which is the ordinary way to arrive here. + throw new DyadError( + "Dyad could not read which version of Coolify this is, so it could not " + + "set up an API token by itself. The server is installed — open it " + + "and make a token there.", + DyadErrorKind.External, + ); } catch (error) { // A cancelled setup is the user stopping, not an instance that cannot be // driven. Swallowing it here would report a version problem for something diff --git a/src/coolify_setup/setup_flow.test.ts b/src/coolify_setup/setup_flow.test.ts index 48ba18ed25..47ff093d42 100644 --- a/src/coolify_setup/setup_flow.test.ts +++ b/src/coolify_setup/setup_flow.test.ts @@ -232,6 +232,25 @@ describe("runServerSetup", () => { expect(result.apiEnabled).toBe(false); }); + it("does not call an unreadable version an unsupported one", async () => { + // The whole install fetches the newest Coolify, so the version being too + // old is the least likely reason the token step did not finish. Reported + // that way, the user goes looking for a problem with a server that is + // minutes old. + const server = fakeServer({ version: "Command not found" }); + const result = await run(server).promise; + + expect(result.token).toBeNull(); + expect(result.tokenUnavailableReason).toMatch( + /could not read which version/, + ); + expect(result.tokenUnavailableReason).not.toMatch( + /version of Coolify could not be set up/, + ); + // The install still stands, and the way on is on the screen. + expect(result.credentials.password).toBeTruthy(); + }); + it("does not tell the user a fresh install is too old when it was only slow", async () => { // A tinker one-liner over the 30s bound on a 2GB box right after an // install is ordinary. Reported as an unsupported version, the user is From f88aae5c52c8997452e1ee82d7e6943f081bb20b Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 21:17:55 -0500 Subject: [PATCH 83/91] fix(coolify): say the details are coming while they are read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every caller introduces this panel as the details it is about to show, and it rendered nothing until the read answered — so "Its details are below" sat over an empty space. The dialog had noticed and said so itself; the two other places had not. Said in the panel now, and taken out of the dialog, which would otherwise be the second place saying the same sentence. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyCredentials.test.tsx | 23 ++++++++++++++++++++ src/components/CoolifyCredentials.tsx | 22 ++++++++++++++++++- src/components/CoolifySignOutDialog.test.tsx | 11 ++++++++++ src/components/CoolifySignOutDialog.tsx | 6 ----- 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/components/CoolifyCredentials.test.tsx b/src/components/CoolifyCredentials.test.tsx index 402a0668d6..412628a649 100644 --- a/src/components/CoolifyCredentials.test.tsx +++ b/src/components/CoolifyCredentials.test.tsx @@ -159,6 +159,29 @@ describe("naming the section", () => { }); }); +describe("while the read is still going", () => { + it("says so rather than leaving the caller's heading over nothing", async () => { + // Every caller introduces this panel as the details it is about to show. + // Rendering nothing until the answer lands leaves "Its details are below" + // with nothing below it, which reads as Dyad holding nothing at all. + h.revealCredentials.mockReturnValue(new Promise(() => {})); + render(); + + expect( + await screen.findByTestId("coolify-credentials-loading"), + ).toBeTruthy(); + expect(screen.queryByTestId("coolify-credentials")).toBeNull(); + }); + + it("gives way once the answer arrives", async () => { + render(); + + await settle(); + expect(screen.queryByTestId("coolify-credentials-loading")).toBeNull(); + expect(screen.getByTestId("coolify-credentials")).toBeTruthy(); + }); +}); + describe("a password Dyad holds but cannot read", () => { it("says so rather than showing a server that never had one", async () => { // readSettings drops a password it cannot decrypt and keeps the account. diff --git a/src/components/CoolifyCredentials.tsx b/src/components/CoolifyCredentials.tsx index c4a7cf018d..1eaba2a50f 100644 --- a/src/components/CoolifyCredentials.tsx +++ b/src/components/CoolifyCredentials.tsx @@ -119,7 +119,11 @@ export function CoolifyCredentials({ // Not held after this leaves the screen: these are the keys to the user's // server, and there is no reason for them to sit in a cache once nothing is // showing them. - const { data: credentials, isError } = useQuery({ + const { + data: credentials, + isError, + isPending, + } = useQuery({ queryKey: queryKeys.coolify.credentials, queryFn: () => ipc.coolifySetup.revealCredentials(), gcTime: 0, @@ -133,6 +137,22 @@ export function CoolifyCredentials({ // in hand — the refetch on window focus, which production does not retry — // still leaves them readable, and taking a password Dyad holds the only copy // of off the screen to report the refresh would be the worse trade. + // Said while it is still being read, for the same reason the failure above + // is said: the callers introduce this panel as the details they are about + // to show, so a blank where they belong reads as Dyad holding nothing. The + // read is local and quick, which is why this is a line rather than a + // skeleton — but "quick" is not "instant" on a cold start. + if (isPending && !isError) { + return ( +

+ Looking up what Dyad has stored… +

+ ); + } + if (isError && !credentials) { return (

{ expect(signOutButton().disabled).toBe(false); }); + it("says it is looking only once", async () => { + // The panel below says it now. Saying it here too put the same sentence + // on screen twice, which is what the read-failure line was moved for. + h.revealCredentials.mockReturnValue(new Promise(() => {})); + open(); + + expect( + await screen.findAllByText(/Looking up what Dyad has stored/), + ).toHaveLength(1); + }); + it("says when it holds a password it cannot read", async () => { // The panel cannot show a row for a value it does not have, so a missing // password would otherwise read as there never having been one. diff --git a/src/components/CoolifySignOutDialog.tsx b/src/components/CoolifySignOutDialog.tsx index 49093bf8e6..e30f98de3b 100644 --- a/src/components/CoolifySignOutDialog.tsx +++ b/src/components/CoolifySignOutDialog.tsx @@ -83,12 +83,6 @@ export function CoolifySignOutDialog({ - {isPending && !isError && ( -

- Looking up what Dyad has stored… -
- )} - {passwordIsLocked && ( From fa34bd76e1bdd0191c1cd9ae81ec18833dc52015 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 21:34:25 -0500 Subject: [PATCH 84/91] docs(coolify): put each comment back over what it describes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading the version says so every way it can now, so the note about answering null described a branch that no longer exists — and the signature still offered a null nothing returns. In the credentials panel the new loading branch was written under the paragraph explaining the failure below it, which left that caveat reading as its reason and the branch it belongs to with nothing over it. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyCredentials.tsx | 23 ++++++++++------------- src/coolify_setup/api_token.ts | 10 +++++----- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/components/CoolifyCredentials.tsx b/src/components/CoolifyCredentials.tsx index 1eaba2a50f..2e23bd3097 100644 --- a/src/components/CoolifyCredentials.tsx +++ b/src/components/CoolifyCredentials.tsx @@ -129,19 +129,11 @@ export function CoolifyCredentials({ gcTime: 0, }); - // Said rather than left blank. Callers introduce this panel as the details - // they are about to show, so rendering nothing at all reads as Dyad holding - // nothing rather than as a read that did not answer. - // - // Only when there is nothing to show. A read that fails over details already - // in hand — the refetch on window focus, which production does not retry — - // still leaves them readable, and taking a password Dyad holds the only copy - // of off the screen to report the refresh would be the worse trade. - // Said while it is still being read, for the same reason the failure above - // is said: the callers introduce this panel as the details they are about - // to show, so a blank where they belong reads as Dyad holding nothing. The - // read is local and quick, which is why this is a line rather than a - // skeleton — but "quick" is not "instant" on a cold start. + // Said rather than left blank, the same reason the failure below is said: + // callers introduce this panel as the details they are about to show, so a + // blank where those belong reads as Dyad holding nothing rather than as a + // read still going. Local and quick, which is why this is a line and not a + // skeleton — but quick is not instant on a cold start. if (isPending && !isError) { return (

{ +): Promise { try { const output = await runTinker( session, @@ -100,10 +100,10 @@ export async function readCoolifyVersion( // driven. Swallowing it here would report a version problem for something // they did on purpose, and carry on setting the server up. if ((error as { kind?: string }).kind === "user_cancelled") throw error; - // Null means the instance answered and what it said was not a version. - // Anything else is the question not getting through, and answering null - // for that tells the user their freshly installed Coolify is too old to - // drive — for something it was never asked. + // Every way of not getting an answer says so as itself. Reported as a + // version this instance does not have, the user is sent looking for a + // problem with a server that is minutes old — so the only thing that + // returns from here is a version it actually read. if (error instanceof SshError && error.failure === "command-timeout") { // Reachable and simply slow, which on a small server right after an // install is ordinary. Worth saying as itself: the version is not the From 52957061bc2b7f6421edcc0b835efa5a113634f0 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 22:25:45 -0500 Subject: [PATCH 85/91] fix(coolify): read every tinker answer the same way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four readers, two beliefs. One matched loosely because "the transcript carries its own noise"; the other three required the whole region to be the answer. Only one of those can be true, and if it is the first, a single notice from Coolify reports a working server as broken — the account not seeded, the API not enabled, the token not minted. They all read one line now: tolerant of anything printed beside the answer, exact about the line itself. That also tightens the loose one, which could have taken the word out of the echo of the script that prints it. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/api_token.test.ts | 7 +++++++ src/coolify_setup/api_token.ts | 19 ++++++++++------- src/coolify_setup/https_setup.test.ts | 30 +++++++++++++++++++++++++++ src/coolify_setup/https_setup.ts | 30 +++++++++++++++++---------- src/coolify_setup/install.test.ts | 21 +++++++++++++++++++ src/coolify_setup/install.ts | 13 ++++-------- src/coolify_setup/tinker.test.ts | 27 +++++++++++++++++++++++- src/coolify_setup/tinker.ts | 19 +++++++++++++++++ 8 files changed, 138 insertions(+), 28 deletions(-) diff --git a/src/coolify_setup/api_token.test.ts b/src/coolify_setup/api_token.test.ts index 247067fd3e..3a72c4f90b 100644 --- a/src/coolify_setup/api_token.test.ts +++ b/src/coolify_setup/api_token.test.ts @@ -155,6 +155,13 @@ describe("enableApi", () => { ); }); + it("reads the answer past a notice Coolify printed", async () => { + // Three of the four readers required the whole region to be the answer, + // so one deprecation notice would have reported a working server broken. + const session = fakeSession(["PHP Deprecated: something\nenabled"]); + await expect(enableApi(session)).resolves.toBeUndefined(); + }); + it("fails when the setting did not take", async () => { const session = fakeSession(["still-disabled"]); await expect(enableApi(session)).rejects.toMatchObject({ diff --git a/src/coolify_setup/api_token.ts b/src/coolify_setup/api_token.ts index 4d7a644337..cc5b4e1353 100644 --- a/src/coolify_setup/api_token.ts +++ b/src/coolify_setup/api_token.ts @@ -2,7 +2,7 @@ import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { COOLIFY_SCOPES_PHP_ARRAY } from "@/shared/coolify_scopes"; import { SshError } from "@/ipc/utils/ssh_client"; import type { SshSession } from "@/ipc/utils/ssh_client"; -import { runTinker } from "./tinker"; +import { answerLine, runTinker } from "./tinker"; /** * How long one of these questions may take. @@ -153,7 +153,9 @@ export async function enableApi( ].join("\n"), { signal, timeoutMs: TINKER_TIMEOUT_MS }, ); - if (output.trim() !== "enabled") { + // One line of the answer, not the whole of it: Coolify prints its own + // notices between the markers. + if (!answerLine(output, (line) => line === "enabled")) { throw new DyadError( "Could not turn Coolify's API on automatically.", DyadErrorKind.External, @@ -225,16 +227,19 @@ export async function mintApiToken( DyadErrorKind.Precondition, ); } - // Sanctum's plain text token is `|<40+ characters>`. Checking the shape - // keeps a stray warning or a partial line from being stored as a credential - // and failing much later, somewhere that cannot explain itself. - if (!/^\d+\|[A-Za-z0-9]{40,}$/.test(token)) { + // Sanctum's plain text token is `|<40+ characters>`. Read as one line + // among any others, so a notice beside it does not lose the token — and + // checked for shape, so a notice is never stored as one. + const minted = answerLine(token, (line) => + /^\d+\|[A-Za-z0-9]{40,}$/.test(line), + ); + if (!minted) { throw new DyadError( "Coolify did not return a usable API token.", DyadErrorKind.External, ); } - return token; + return minted; } export interface AutomaticAccess { diff --git a/src/coolify_setup/https_setup.test.ts b/src/coolify_setup/https_setup.test.ts index 4f9b19c547..08568185d7 100644 --- a/src/coolify_setup/https_setup.test.ts +++ b/src/coolify_setup/https_setup.test.ts @@ -518,6 +518,36 @@ describe("tryEnableHttps", () => { expect(oneLine).toContain("return 'applied'"); }); + it("reads applied past a notice, and not out of the echoed script", async () => { + // The script it sends contains the word, so matching anywhere in the + // region would take the echo of the line that printed it. + const { session } = fakeSession(); + session.run = (async () => ({ + code: 0, + stdout: transcript("PHP Deprecated: something\napplied"), + stderr: "", + })) as unknown as SshSession["run"]; + + await expect( + applyInstanceDomain(session, "coolify.example.com"), + ).resolves.toBeUndefined(); + }); + + it("does not take the word out of the middle of a line", async () => { + // The script it sends carries the word, so a region holding the echo of + // that line — and no answer of its own — is not the server saying yes. + const { session } = fakeSession(); + session.run = (async () => ({ + code: 0, + stdout: transcript("> echo (function () { return 'applied'; })();"), + stderr: "", + })) as unknown as SshSession["run"]; + + await expect( + applyInstanceDomain(session, "coolify.example.com"), + ).rejects.toThrow(); + }); + it("does not call a vetoed save applied", async () => { // Eloquent answers false rather than throwing when a model event stops // the write, so a run that reported success would go on to probe for a diff --git a/src/coolify_setup/https_setup.ts b/src/coolify_setup/https_setup.ts index db4c433756..0719640f01 100644 --- a/src/coolify_setup/https_setup.ts +++ b/src/coolify_setup/https_setup.ts @@ -3,7 +3,7 @@ import { isIP } from "node:net"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { sleep } from "./sleep"; import type { SshSession } from "@/ipc/utils/ssh_client"; -import { runTinker } from "./tinker"; +import { answerLine, runTinker } from "./tinker"; import { IS_TEST_BUILD } from "@/ipc/utils/test_utils"; import { isPlausibleInstanceDomain } from "@/shared/coolify_domain"; import { resolveBoth } from "@/ipc/utils/dns_resolve"; @@ -125,6 +125,12 @@ function dashboardPort(): number { return override ? Number(override) : 8000; } +/** + * Where the dashboard answers. + * + * The same address the setup stores and shows the user, from one definition: + * two copies meant a change to the port could move only half of them. + */ export function plainUrlFor(host: string): string { return `http://${urlHost(host)}:${dashboardPort()}`; } @@ -182,8 +188,10 @@ export async function applyInstanceDomain( ); // Checked, because a Coolify that refused still prints and still ends. Left // unread, its error became a two-minute wait blamed on the certificate - // authority. Matched loosely: the transcript carries its own noise. - if (!answer.includes("applied")) { + // authority. One line of the answer, like the other readers: tolerant of a + // notice beside it, and tight enough that the echoed script line — which + // carries the word — cannot pass for the answer. + if (!answerLine(answer, (line) => line === "applied")) { throw new DyadError( `Coolify would not take the domain: ${answer.trim()}`, DyadErrorKind.External, @@ -291,14 +299,6 @@ export interface HttpsOutcome { reason?: string; } -/** - * Tries to put the instance on HTTPS, and settles for HTTP if it cannot. - * - * The instance is left on plain HTTP rather than pointed at a domain with no - * certificate: a half-configured proxy would answer the dashboard's own address - * with an error, and a working server nobody can open is worse than one that is - * merely unencrypted. - */ /** * Whether a name stands for something a certificate authority could reach. * @@ -313,6 +313,14 @@ function resolvesPublicly(addresses: string[]): boolean { ); } +/** + * Tries to put the instance on HTTPS, and settles for HTTP if it cannot. + * + * The instance is left on plain HTTP rather than pointed at a domain with no + * certificate: a half-configured proxy would answer the dashboard's own address + * with an error, and a working server nobody can open is worse than one that is + * merely unencrypted. + */ export async function tryEnableHttps( session: SshSession, host: string, diff --git a/src/coolify_setup/install.test.ts b/src/coolify_setup/install.test.ts index 433f46248c..fe1d9647c0 100644 --- a/src/coolify_setup/install.test.ts +++ b/src/coolify_setup/install.test.ts @@ -271,6 +271,27 @@ describe("waiting for the admin account", () => { ).rejects.toMatchObject({ kind: DyadErrorKind.UserCancelled }); }); + it("sees the account past a notice printed beside the answer", async () => { + // Required to be the whole answer, one deprecation notice would report a + // seeded account as missing — and the run then fails with Coolify + // refusing to create an account it already created. + const session = sessionAnswering( + vi.fn(async () => ({ + code: 0, + stdout: transcript("PHP Deprecated: something\nyes"), + stderr: "", + })) as never, + ); + + await expect( + waitForAdminSeeded(session, "me@gmail.com", { + timeoutMs: 50, + intervalMs: 1, + attemptTimeoutMs: 50, + }), + ).resolves.toMatchObject({ seeded: true }); + }); + it("bounds the repair and the confirmation after it, not only the poll", async () => { // The loop expiring is where the seeder runs, and the question after it // is the same question — asked on the same server that just failed to diff --git a/src/coolify_setup/install.ts b/src/coolify_setup/install.ts index e81a8824df..2d8220ba10 100644 --- a/src/coolify_setup/install.ts +++ b/src/coolify_setup/install.ts @@ -3,7 +3,7 @@ import { sleep } from "./sleep"; import { plainUrlFor } from "./https_setup"; import { SshError } from "@/ipc/utils/ssh_client"; import type { SshSession } from "@/ipc/utils/ssh_client"; -import { runTinker } from "./tinker"; +import { answerLine, runTinker } from "./tinker"; import type { AdminCredentials } from "./admin_credentials"; import { isShellSafe } from "./admin_credentials"; @@ -263,13 +263,6 @@ export async function installCoolify( } } -/** - * Where the dashboard answers. - * - * The same address the setup stores and shows the user, from one definition: - * two copies meant a change to the port could move only half of them. - */ - /** * Waits for the dashboard to answer. * @@ -337,7 +330,9 @@ export async function isAdminSeeded( `echo \\App\\Models\\User::where('email', getenv('DYAD_ADMIN_EMAIL'))->exists() ? 'yes' : 'no';`, { env: { DYAD_ADMIN_EMAIL: email }, signal, timeoutMs }, ); - return output.trim() === "yes"; + // One line of the answer: a notice printed beside it must not read as + // the account not being there. + return Boolean(answerLine(output, (line) => line === "yes")); } catch (error) { // A container still starting cannot answer at all. That is not the same as // answering no, and treating it as one is how a healthy server gets diff --git a/src/coolify_setup/tinker.test.ts b/src/coolify_setup/tinker.test.ts index 1427795dc5..05273b292f 100644 --- a/src/coolify_setup/tinker.test.ts +++ b/src/coolify_setup/tinker.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import { extractOutput, runTinker, tinkerCommand, wrapScript } from "./tinker"; +import { + answerLine, + extractOutput, + runTinker, + tinkerCommand, + wrapScript, +} from "./tinker"; import type { SshSession } from "@/ipc/utils/ssh_client"; /** @@ -20,6 +26,25 @@ const REAL_TRANSCRIPT = [ "__DYAD_OUT_END__", ].join("\n"); +describe("reading the answer out of a noisy region", () => { + it("finds it beside whatever else Coolify printed", () => { + const region = ["PHP Deprecated: Some notice", "yes", ""].join("\n"); + expect(answerLine(region, (l) => l === "yes")).toBe("yes"); + }); + + it("will not take a line that merely mentions it", () => { + // A warning naming the answer is not the answer. + expect( + answerLine("Warning: expected yes here", (l) => l === "yes"), + ).toBeNull(); + }); + + it("answers nothing when the region has none", () => { + expect(answerLine("no\nstill-disabled", (l) => l === "yes")).toBeNull(); + expect(answerLine("", (l) => l === "yes")).toBeNull(); + }); +}); + describe("finding our output however psysh prompts", () => { it("reads it when the prompt is missing or repeated", () => { // The prompt sharing a line with the output is an artefact of when psysh diff --git a/src/coolify_setup/tinker.ts b/src/coolify_setup/tinker.ts index 225c30882b..0d7626d05a 100644 --- a/src/coolify_setup/tinker.ts +++ b/src/coolify_setup/tinker.ts @@ -83,6 +83,25 @@ export function extractOutput(transcript: string): string | null { return rest.slice(0, endAt).join("\n").trim(); } +/** + * Finds the answer among whatever else the region carries. + * + * Coolify prints its own notices between the markers, so the answer is not + * always the whole of what comes back. Read line by line rather than as one + * value, and exactly per line rather than anywhere in it — a warning that + * mentions the answer is not the answer. + */ +export function answerLine( + region: string, + matches: (line: string) => boolean, +): string | null { + for (const line of region.split(/\r?\n/)) { + const said = line.trim(); + if (said && matches(said)) return said; + } + return null; +} + export interface TinkerOptions { /** * Values the script reads with getenv(). From 255cd07ef136416a70f451ad1653f2e2dae247d9 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 22:25:45 -0500 Subject: [PATCH 86/91] docs(coolify): move four comments back to what they describe Each had drifted above the symbol before it, leaving that one bare and the wrong one explained: the dashboard address, the server key, the HTTPS attempt, and the account a failed write could not store. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/server_key.ts | 14 +++++++------- src/ipc/handlers/coolify_setup_handlers.ts | 18 +++++++++--------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/coolify_setup/server_key.ts b/src/coolify_setup/server_key.ts index b87ebf0acd..87d822e110 100644 --- a/src/coolify_setup/server_key.ts +++ b/src/coolify_setup/server_key.ts @@ -39,13 +39,6 @@ export interface ServerKey { privateKey: string; } -/** - * Returns Dyad's server key, creating it the first time. - * - * Reused rather than regenerated per server: the public half is something the - * user pastes into a console by hand, and making them do that again for every - * server would be the most tedious part of the whole flow. - */ /** * The stored line when it names the key on disk, and the derived one when not. * @@ -72,6 +65,13 @@ function storedMatching(keyPath: string, derived: string): string { } } +/** + * Returns Dyad's server key, creating it the first time. + * + * Reused rather than regenerated per server: the public half is something the + * user pastes into a console by hand, and making them do that again for every + * server would be the most tedious part of the whole flow. + */ export function ensureServerKey(): ServerKey { const keyPath = serverKeyPath(); if (fs.existsSync(keyPath)) { diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index dce5e35450..d0b65e46f1 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -100,15 +100,6 @@ function setupController(): CoolifySetupController { // Trust on first use only when there has been no first use. A server // that was looked at is held to what it showed then. const pinned = inspectedFingerprints.get(serverKeyFor(target)); - /** - * What the account write could not store, if it could not store it. - * - * A run that then fails takes the only copy of the password with it: - * the failed screen carries a message and a log, and the call never - * returns the result that shows it. So it is tried once more where it - * starts to matter, which turns a keychain that was briefly busy into - * nothing at all. - */ /** Whether the server ever reported an account, so a run that ended before one existed can put back what it wrote on the way in. */ let accountConfirmed = false; @@ -125,6 +116,15 @@ function setupController(): CoolifySetupController { * this record from anyone else's writing in the minutes since. */ let provisional: NonNullable | undefined; + /** + * What the account write could not store, if it could not store it. + * + * A run that then fails takes the only copy of the password with it: + * the failed screen carries a message and a log, and the call never + * returns the result that shows it. So it is tried once more where it + * starts to matter, which turns a keychain that was briefly busy into + * nothing at all. + */ let unsavedAccount: { credentials: { email: string; password: string }; dashboardUrl: string; From bb38a4c07e4c02de392ff2f1f635abe145160055 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Fri, 28 Aug 2026 22:40:23 -0500 Subject: [PATCH 87/91] fix(coolify): the fifth reader, and the two beside the token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There were five readers of tinker output, not four. The one left behind reads the version, and it runs first — so a notice there costs the whole automated-token path on a server that answered correctly, and a notice after the version was glued onto it and stored as the version. The no-user and no-team sentinels beside the token were whole-region too, so a notice turned "no team yet" into "no usable token" — the same words a real failure gets, without the reason. Co-Authored-By: Claude Opus 5 --- src/coolify_setup/api_token.test.ts | 23 +++++++++++++++++++++++ src/coolify_setup/api_token.ts | 10 +++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/coolify_setup/api_token.test.ts b/src/coolify_setup/api_token.test.ts index 3a72c4f90b..52d0395983 100644 --- a/src/coolify_setup/api_token.test.ts +++ b/src/coolify_setup/api_token.test.ts @@ -75,6 +75,19 @@ describe("readCoolifyVersion", () => { expect(await readCoolifyVersion(session)).toBe("4.3.2"); }); + it("reads the version past a notice on either side of it", async () => { + // The first of the five readers, and the one the rest are behind: a + // notice here costs the whole automated-token path on a server that + // answered correctly. After it, the notice would have been glued onto + // the version and stored as it. + expect( + await readCoolifyVersion(fakeSession(["PHP Deprecated: x\n4.3.2"])), + ).toBe("4.3.2"); + expect( + await readCoolifyVersion(fakeSession(["4.3.2\nPHP Deprecated: x"])), + ).toBe("4.3.2"); + }); + it("says it could not read the version rather than that it is old", async () => { // The installer always fetches the newest Coolify, so "too old" is the // least likely thing to be true here — and the key this reads is @@ -162,6 +175,16 @@ describe("enableApi", () => { await expect(enableApi(session)).resolves.toBeUndefined(); }); + it("still names a missing team past a notice", async () => { + // Read as the whole answer, a notice beside the sentinel turned "no team + // yet" into "no usable token" — the same words a real failure gets, and + // none of the reason. + const session = fakeSession(["PHP Deprecated: x\nno-team"]); + await expect(mintApiToken(session, "me@gmail.com")).rejects.toThrow( + /no team yet/, + ); + }); + it("fails when the setting did not take", async () => { const session = fakeSession(["still-disabled"]); await expect(enableApi(session)).rejects.toMatchObject({ diff --git a/src/coolify_setup/api_token.ts b/src/coolify_setup/api_token.ts index cc5b4e1353..0aebf4c979 100644 --- a/src/coolify_setup/api_token.ts +++ b/src/coolify_setup/api_token.ts @@ -82,8 +82,8 @@ export async function readCoolifyVersion( `echo config('constants.coolify.version');`, { signal, timeoutMs: TINKER_TIMEOUT_MS }, ); - const said = output.trim(); - if (/^\d+\.\d+/.test(said)) return said; + const said = answerLine(output, (line) => /^\d+\.\d+/.test(line)); + if (said) return said; // It answered, and what it said was not a version. Null would send the // caller down the path that reports a Coolify too old to drive — and the // installer always fetches the newest one, so that is the least likely @@ -214,14 +214,14 @@ export async function mintApiToken( }, ); - const token = output.trim(); - if (token === "no-user") { + const token = output; + if (answerLine(token, (line) => line === "no-user")) { throw new DyadError( "Coolify has no account for this address, so no token could be created.", DyadErrorKind.Precondition, ); } - if (token === "no-team") { + if (answerLine(token, (line) => line === "no-team")) { throw new DyadError( "Coolify's admin account has no team yet, so no token could be created.", DyadErrorKind.Precondition, From 8e4ce58aeaef065674c0f972a6add9d45386101a Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Sat, 29 Aug 2026 15:47:24 -0500 Subject: [PATCH 88/91] docs(coolify): the token does not follow that rule any more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getServerKey said the private half stays in the main process, "the same rule the API token follows". revealCredentials hands the token to the panel, so the comparison stopped being true — and a comment asserting a rule that no longer holds is worse than none. Co-Authored-By: Claude Opus 5 --- src/ipc/handlers/coolify_setup_handlers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index d0b65e46f1..50ec2dee37 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -423,7 +423,8 @@ export function registerCoolifySetupHandlers() { createTypedHandler(coolifySetupContracts.getServerKey, async () => { const key = ensureServerKey(); // Only the public half crosses to the renderer. The private half never - // leaves the main process, the same rule the API token follows. + // leaves the main process — unlike the API token, which revealCredentials + // does hand over so the panel can show it. return { publicKey: key.publicKey }; }); From 7cf1fc0d9564e07968e2d0974894cf05eb6ba41e Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Sat, 29 Aug 2026 19:25:59 -0500 Subject: [PATCH 89/91] test(coolify): pin the transcript shape against a second real Coolify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two captures from 4.3.14, taken the way the 4.3.2 one was. Both put the last prompt on the line the first output lands on, which is the whole basis for telling real output from the echo of the script that produced it — now held against two versions rather than one. The second capture carries a carriage return mid-transcript, where psysh redrew an echo too long to fit, leaving it truncated and holding both "yes" and "no". It sits above the opening marker, so the answer is still read correctly. Three comments corrected while here. answerLine claimed Coolify prints notices between the markers; nothing observed has, so it now says it is insurance and why the per-line trim earns its place. The admin password is not handed over only when the panel asks — settings reads carry it, as they already carry the token. And the packaging list leaves out ssh2's optional native helpers on purpose. Co-Authored-By: Claude Opus 5 --- forge.config.ts | 8 +++++ src/coolify_setup/tinker.test.ts | 52 ++++++++++++++++++++++++++++++++ src/coolify_setup/tinker.ts | 14 ++++++--- src/lib/schemas.ts | 5 +-- 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/forge.config.ts b/forge.config.ts index 40e91dbeb4..b522d64a7d 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -44,6 +44,14 @@ const pgRuntimeDependencies = [ "xtend", ] as const; +/** + * What ssh2 needs at runtime, and only that. + * + * Its optional native helpers — cpu-features, nan, buildcheck — are left out + * on purpose. ssh2 guards those requires and falls back to pure JavaScript, + * so leaving them behind costs some speed and avoids shipping a binding + * compiled against whatever Node the build machine happened to have. + */ const ssh2RuntimeDependencies = [ "ssh2", "asn1", diff --git a/src/coolify_setup/tinker.test.ts b/src/coolify_setup/tinker.test.ts index 05273b292f..a4a0aff648 100644 --- a/src/coolify_setup/tinker.test.ts +++ b/src/coolify_setup/tinker.test.ts @@ -26,6 +26,58 @@ const REAL_TRANSCRIPT = [ "__DYAD_OUT_END__", ].join("\n"); +/** + * The same shape from a later Coolify, captured the same way. + * + * Two versions apart, psysh still flushes its last prompt onto the line the + * first output lands on — which is the whole basis for telling real output + * from the echo of the script that produced it. + */ +const REAL_4_3_14 = [ + '> echo "__DYAD_OUT_START__" . PHP_EOL;', + "", + "> echo config('constants.coolify.version');", + '> echo PHP_EOL . "__DYAD_OUT_END__" . PHP_EOL;', + "> __DYAD_OUT_START__", + "4.3.14", + "__DYAD_OUT_END__", +].join("\n"); + +/** + * The same again, with psysh redrawing an echo it could not fit. + * + * A long input line comes back carrying a carriage return and overwritten + * partway, so the echo is neither complete nor clean. It still lands above + * the opening marker, which is what keeps it out of the answer. + */ +const REAL_MANGLED_ECHO = [ + '> echo "__DYAD_OUT_START__" . PHP_EOL;', + "", + "> \rexists() ? 'yes' : 'no';", + '> echo PHP_EOL . "__DYAD_OUT_END__" . PHP_EOL;', + "> __DYAD_OUT_START__", + "no", + "__DYAD_OUT_END__", +].join("\n"); + +describe("transcripts from a real Coolify", () => { + it("reads the version a 4.3.14 server gave back", () => { + expect(extractOutput(REAL_4_3_14)).toBe("4.3.14"); + expect( + answerLine(extractOutput(REAL_4_3_14) ?? "", (l) => /^\d+\.\d+/.test(l)), + ).toBe("4.3.14"); + }); + + it("is not confused by an echo psysh redrew", () => { + // The mangled line carries a CR and part of the script, including the + // words "yes" and "no" — everything the reader below is looking for. + const region = extractOutput(REAL_MANGLED_ECHO); + expect(region).toBe("no"); + expect(answerLine(region ?? "", (l) => l === "no")).toBe("no"); + expect(answerLine(region ?? "", (l) => l === "yes")).toBeNull(); + }); +}); + describe("reading the answer out of a noisy region", () => { it("finds it beside whatever else Coolify printed", () => { const region = ["PHP Deprecated: Some notice", "yes", ""].join("\n"); diff --git a/src/coolify_setup/tinker.ts b/src/coolify_setup/tinker.ts index 0d7626d05a..fa06a6fe9f 100644 --- a/src/coolify_setup/tinker.ts +++ b/src/coolify_setup/tinker.ts @@ -86,10 +86,16 @@ export function extractOutput(transcript: string): string | null { /** * Finds the answer among whatever else the region carries. * - * Coolify prints its own notices between the markers, so the answer is not - * always the whole of what comes back. Read line by line rather than as one - * value, and exactly per line rather than anywhere in it — a warning that - * mentions the answer is not the answer. + * Insurance rather than something observed: two real transcripts, 4.3.2 and + * 4.3.14, both came back with nothing but the answer between the markers. But + * a notice from Coolify would only have to happen once for a reader that + * demands the whole region to report a working server as broken, and reading + * a line at a time costs nothing when there is only one. + * + * Exactly per line rather than anywhere in it, so a notice that mentions the + * answer is not taken for it. Trimmed per line because psysh writes a + * carriage return mid-transcript when it redraws a long echo — the region's + * own trim only reaches the ends. */ export function answerLine( region: string, diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 65212db033..c00662ceb4 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -265,8 +265,9 @@ export const CoolifySchema = z.object({ * * Kept because Dyad invented this password on the user's behalf, for their * own machine — showing it once and forgetting it leaves them locked out of - * a server they own. Encrypted like the token, and only ever handed to the - * renderer when it is asked for. + * a server they own. Encrypted like the token, and like the token it + * reaches the renderer whenever settings are read, not only when the panel + * asks to show it. * * One object rather than three fields, because they are only ever * meaningful together: an account without the address it opens is a From 84e0fe0fd73bc667d7e11de40314b2ca3a24ab42 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Sun, 30 Aug 2026 14:11:38 -0500 Subject: [PATCH 90/91] fix(coolify): say what to type when the server address is a URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A whole URL pasted into the server address field is handed to ssh2 as a hostname, so the lookup fails and the client reports it as unreachable: "Check the address and that port 22 is open." That names the address as one of two suspects without saying what is wrong with it, and the firewall is the one people go and look at. The inspect handler answers with the rule instead — no https://, no slashes — and keeps only what the system called the fault, so the two halves cannot disagree. Said as the rule rather than as a diagnosis: a slash is all that got us here, so telling someone their address "looks like a URL" would be a guess, and wrong for one they typed a path onto. It never refuses an address. It speaks after a connection has already failed, so a name it does not recognise connects exactly as before. SshError now carries the errno as `systemCode` rather than leaving it to be read back out of a sentence, which is what `failure` already exists to avoid. Deliberately not `code`: a handler writes a mark to that name so the panel does not report a failure the screen is already showing, and only while nothing holds it yet. A field declared on the class holds the name whether or not anything is passed, so calling this `code` would stop that mark being written and tell the user twice. Also drops admin@example.test from the two messages about the admin email. That check refuses an address for five different reasons and the example illustrates the least likely one, so it read as unrelated to whatever the reader had actually typed. Co-Authored-By: Claude Opus 5 --- src/components/CoolifyServerSetup.test.tsx | 4 +- src/components/CoolifyServerSetup.tsx | 4 +- .../handlers/coolify_setup_handlers.test.ts | 142 ++++++++++++++++++ src/ipc/handlers/coolify_setup_handlers.ts | 63 +++++++- src/ipc/utils/ssh_client.test.ts | 38 +++-- src/ipc/utils/ssh_client.ts | 14 ++ 6 files changed, 248 insertions(+), 17 deletions(-) diff --git a/src/components/CoolifyServerSetup.test.tsx b/src/components/CoolifyServerSetup.test.tsx index f4264c53a0..98b418f61b 100644 --- a/src/components/CoolifyServerSetup.test.tsx +++ b/src/components/CoolifyServerSetup.test.tsx @@ -139,7 +139,7 @@ describe("the admin address", () => { "admin@dyad.test", ); - expect(screen.getByText(/admin@example.test are rejected/)).toBeTruthy(); + expect(screen.getByText(/receive mail at/)).toBeTruthy(); }); it("says nothing about an ordinary address", async () => { @@ -147,7 +147,7 @@ describe("the admin address", () => { renderPanel(); await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); - expect(screen.queryByText(/are rejected/)).toBeNull(); + expect(screen.queryByText(/receive mail at/)).toBeNull(); }); it("will not start an install it knows the address fails", async () => { diff --git a/src/components/CoolifyServerSetup.tsx b/src/components/CoolifyServerSetup.tsx index dc5d544485..e0d7c4533f 100644 --- a/src/components/CoolifyServerSetup.tsx +++ b/src/components/CoolifyServerSetup.tsx @@ -450,8 +450,8 @@ export function CoolifyServerSetup({ install. */} {!emailLooksUsable && (

- Coolify checks that the domain resolves, so addresses like - admin@example.test are rejected. Use one you can receive mail at. + Use an address you can receive mail at. Coolify checks that the + domain resolves when it creates the account.

)}

diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts index f6087d74ec..0767867c7d 100644 --- a/src/ipc/handlers/coolify_setup_handlers.test.ts +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -36,6 +36,8 @@ const h = vi.hoisted(() => ({ preflightReady: true, fingerprint: "SHA256:fingerprint", lastConnectTarget: null as null | { host: string }, + /** What the SSH connect throws, for the cases about a failed connect. */ + connectError: null as unknown, })); vi.mock("electron", () => ({ BrowserWindow: { getAllWindows: () => [] } })); @@ -78,6 +80,7 @@ vi.mock("../utils/ssh_client", () => ({ connectSsh: vi.fn( async (target: { host: string }, verify: (fp: string) => boolean) => { h.lastConnectTarget = target; + if (h.connectError) throw h.connectError; verify(h.fingerprint); return { run: vi.fn(), @@ -97,12 +100,25 @@ vi.mock("../utils/ssh_client", () => ({ h.verifiedAgainst.push(expected); return (fingerprint: string) => fingerprint === expected; }, + // Close enough to the real class for what this file asserts: the failure, + // the kind, the errno, and the name `sshFailureOf` matches on. It is not a DyadError, + // so a case about what survives serialization would need more than this. + // + // The failure and the kind are asserted on for opposite reasons. The + // failure is read here and goes no further: it is what drops an unreachable server from + // telemetry, as the user's own network rather than a fault here, and the + // serialized error has no such field. The kind is not read for this + // failure — the filter has already answered on the failure — and it does + // cross. Between them they pin the error as the one the client threw. SshError: class SshError extends Error { constructor( readonly failure: string, message: string, + readonly kind?: string, + readonly systemCode?: string, ) { super(message); + this.name = "SshError"; } }, })); @@ -218,6 +234,7 @@ beforeEach(() => { h.reportsAccount = true; h.failsBeforeCredentials = false; h.lastConnectTarget = null; + h.connectError = null; h.onRunStarted = null; h.runCalls = 0; h.verifiedAgainst.length = 0; @@ -259,6 +276,131 @@ describe("inspect", () => { await call("coolify-setup:inspect", TARGET); expect(h.sessionEnded).toBe(1); }); + + /** + * What the client reports for a name that does not resolve. + * + * Written out rather than shortened: a connect the socket reports as failed + * does not arrive raw — `classify` turns those into an SshError with words + * of its own — so a fixture shaped like the library's own message would be + * a shape this path does not produce, and would let the hint be built + * against text the user never sees. + * + * A function rather than a constant, because the handler rewrites the + * message of the error it is given: one shared instance would carry the + * previous case's hint into the next one. + */ + const UNREACHABLE = () => + new SshError( + "unreachable", + "Could not reach the server (ENOTFOUND). Check the address and that " + + "port 22 is open.", + DyadErrorKind.External, + "ENOTFOUND", + ); + + it("says what to type instead, before anything else", async () => { + // The other way in — a token for a Coolify that already exists — asks for + // exactly that shape, so it is the likeliest wrong answer. What the client + // says on its own names the address as one of two suspects; the other is a + // port nobody is listening on, and that is the one people go and look at. + h.connectError = UNREACHABLE(); + + await expect( + call("coolify-setup:inspect", { + ...TARGET, + host: "https://203.0.113.5:8000", + }), + ).rejects.toThrow(/^Enter just the server address[\s\S]*ENOTFOUND/); + }); + + it("says it for an address with a path on it, too", async () => { + // The half with no scheme in it — an address someone put a path onto, + // copied out of a page that documented one. Worth its own case because + // this is where calling it "a URL" would have been a guess: the message + // states the rule instead, so it is true of both. + h.connectError = UNREACHABLE(); + + await expect( + call("coolify-setup:inspect", { ...TARGET, host: "203.0.113.5/coolify" }), + ).rejects.toThrow(/Enter just the server address/); + }); + + it("keeps the fault it was given, and what it was", async () => { + // Added to the error rather than replacing it. The code is the one part + // of what the client said that a bug report needs — the sentence around + // it offers a closed port, which the hint has just ruled out — and the + // failure decides, in this process before any of it is serialized, + // whether this is reported at all. + h.connectError = UNREACHABLE(); + + await expect( + call("coolify-setup:inspect", { ...TARGET, host: "203.0.113.5/coolify" }), + ).rejects.toMatchObject({ + failure: "unreachable", + kind: DyadErrorKind.External, + // Ends there: the sentence the client wrapped the code in offers a + // closed port as the other suspect, and keeping it would put a second + // answer under the one this just gave. + message: expect.stringMatching(/\(ENOTFOUND\)$/), + }); + }); + + it("falls back to the failure when the system named nothing", async () => { + // Only some of `classify`'s branches have an errno to pass on. The rest + // still carry a failure, which is a word rather than the sentence they + // wrote — and that sentence is the one offering a closed port, which the + // instruction it is appended to has just ruled out. + h.connectError = new SshError( + "timeout", + "The server did not answer in time. Check the address and that port " + + "22 is reachable.", + DyadErrorKind.External, + ); + + await expect( + call("coolify-setup:inspect", { ...TARGET, host: "203.0.113.5/coolify" }), + ).rejects.toThrow(/in it\. \(timeout\)$/); + }); + + it("says nothing about the address once the connection is open", async () => { + // Only the connect is covered, and nothing but this says so. Past it the + // address reached something, so what failed is the server — and the hint + // would be advice the connection that just succeeded has disproved. + h.preflightThrows = true; + + await expect( + call("coolify-setup:inspect", { ...TARGET, host: "203.0.113.5/coolify" }), + ).rejects.toThrow(/^docker never answered$/); + }); + + it("says only the instruction when the error carries neither", async () => { + // Nothing to add is not a reason to add the message back: what is left + // is the one sentence that tells the user what to do. + h.connectError = new Error("something went wrong"); + + await expect( + call("coolify-setup:inspect", { ...TARGET, host: "203.0.113.5/coolify" }), + ).rejects.toThrow(/no \/ characters in it\.$/); + }); + + it("says nothing about an address it does not recognise", async () => { + // The whole point of adding this after the failure rather than before the + // connect. An address shaped like nothing in particular — a single-label + // name, an IPv6 literal — is none of its business, and a hint here would + // be a confident wrong answer on top of a real fault. + // + // Given the same failure as the cases that do get the hint, so the + // address is the only thing separating them from this one: a plainer + // error here would let an implementation that hints on every SshError + // pass this untouched. + for (const host of ["fe80::1", "coolify", "my_server.local"]) { + h.connectError = UNREACHABLE(); + await expect( + call("coolify-setup:inspect", { ...TARGET, host }), + ).rejects.toThrow(/^Could not reach the server \(ENOTFOUND\)\./); + } + }); }); describe("run", () => { diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index 50ec2dee37..c54f7e4a29 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -26,6 +26,7 @@ import { selectCoolifySetupCapabilities } from "@/coolify_setup/capabilities"; import { uuidIdSource } from "@/state_machines/clock"; import { isPlausibleAdminEmail } from "@/shared/coolify_admin_email"; import { isPlausibleInstanceDomain } from "@/shared/coolify_domain"; +import { sshFailureOf } from "@/shared/ssh_failure"; import { IS_TEST_BUILD } from "../utils/test_utils"; const logger = log.scope("coolify_setup_handlers"); @@ -403,6 +404,57 @@ function targetFrom(input: SetupServer, privateKey: string) { }; } +/** + * The same failure, with a line about the address when the address explains it. + * + * A whole URL pasted here is the likeliest wrong answer, because the token + * form asks for exactly that shape — and ssh2 takes it as a hostname, so the + * lookup fails. What the client says on its own hedges between the address + * and a port nobody is listening on, and the port is the one people go and + * look at; this settles it. Said as the rule rather than as a diagnosis: a + * slash is all that got us here, so telling someone their address "looks like + * a URL" would be a guess, and wrong for one they typed a path onto. + * + * Deliberately not a check on the way in. Nothing here decides whether an + * address is usable: an address this does not recognise connects exactly as + * it did before, and this only ever speaks after a connection has already + * failed. That is what keeps it from refusing a name that would have worked + * — a single-label name, a .local, a zone id — none of which this has to + * know about. + * + * The error object is kept rather than replaced with one of ours. The failure + * it carries never reaches the renderer — the serialized error has no such + * field — but it is read on the way out: shouldFilterTelemetryException drops + * an "unreachable" outright, as a server that does not answer is the user's + * own network rather than a fault here. A plain Error would carry no failure, + * match nothing else that filter looks for, and start reporting the connects + * this speaks for — the ones from an address with a slash in it — as + * exceptions. Every other failed connect keeps the error it was given. + * + * What the client said is still replaced, which leaves its wording unread on + * this one path. That is the trade: a message written where the address came + * from can be specific, and one written in the transport cannot. + */ +function withHostShapeHint(host: string, error: T): T { + if (!(error instanceof Error)) return error; + // A slash, and only a slash. A scheme brings two of its own and a path is + // one, so this catches both without naming either — and an address that is + // colons all the way down, which is how an IPv6 literal is written, has + // none and is left alone. + if (!host.includes("/")) return error; + // What went wrong, in the shortest form the error carries it: the errno if + // the system named one, and otherwise the failure. Both are read off the + // error rather than out of its message, and both are a word rather than a + // sentence — the sentence the client wrote offers a closed port as the + // other suspect, which this has just ruled out. + const detail = + (error as { systemCode?: string }).systemCode ?? sshFailureOf(error); + error.message = + "Enter just the server address, for example 203.0.113.5 — with no " + + `https:// and no / characters in it.${detail ? ` (${detail})` : ""}`; + return error; +} + /** Test-only: the pin map and the controller both outlive a single case. */ export function resetCoolifySetupStateForTests(): void { inspectedFingerprints.clear(); @@ -439,7 +491,11 @@ export function registerCoolifySetupHandlers() { trustOnFirstUse((fp) => { fingerprint = fp; }), - ); + ).catch((error: unknown) => { + // Only the connect. Once it is open the address reached something, and + // anything after this is about the server rather than what was typed. + throw withHostShapeHint(input.host, error); + }); try { // Bounded, because nothing else bounds it: the probe asks docker, and a // wedged daemon never answers. Left unbounded the button span forever @@ -492,9 +548,8 @@ export function registerCoolifySetupHandlers() { // account on it — minutes later, with nothing to show for them. if (!isPlausibleAdminEmail(input.adminEmail)) { throw new DyadError( - "Enter an email address whose domain resolves. Coolify checks this " + - "when it creates the admin account, and rejects addresses like " + - "admin@example.test.", + "Use an email address you can receive mail at. Coolify checks that " + + "the domain resolves when it creates the admin account.", DyadErrorKind.Validation, ); } diff --git a/src/ipc/utils/ssh_client.test.ts b/src/ipc/utils/ssh_client.test.ts index 0cab126b82..1726973f42 100644 --- a/src/ipc/utils/ssh_client.test.ts +++ b/src/ipc/utils/ssh_client.test.ts @@ -161,6 +161,8 @@ describe("classifying a failed connection", () => { raw: Record; failure: string; kind: string; + /** What the system called it, where it called it anything. */ + systemCode?: string; }> = [ { name: "a key the server will not take", @@ -189,6 +191,7 @@ describe("classifying a failed connection", () => { }, failure: "unreachable", kind: "external", + systemCode: "ENOTFOUND", }, { name: "a closed port", @@ -199,6 +202,7 @@ describe("classifying a failed connection", () => { }, failure: "unreachable", kind: "external", + systemCode: "ECONNREFUSED", }, { name: "two ends that cannot agree on ciphers", @@ -209,6 +213,16 @@ describe("classifying a failed connection", () => { failure: "handshake-failed", kind: "external", }, + { + // The bucket, but a named one: a socket error this does not recognise + // still says what the system called it, and that name is what makes it + // worth reporting. + name: "an error nothing here recognises, named", + raw: { level: "client-socket", code: "EPIPE", message: "broken pipe" }, + failure: "unknown", + kind: "external", + systemCode: "EPIPE", + }, { name: "anything else", raw: { level: "client-socket", message: "kernel exploded" }, @@ -240,15 +254,21 @@ describe("classifying a failed connection", () => { expect(error.message).not.toMatch(/connect: Handshake failed/i); }); - it.each(CASES)("reads $name as $failure", async ({ raw, failure, kind }) => { - h.nextFailure = Object.assign(new Error(String(raw.message)), raw); - const error = (await connectSsh( - TARGET, - trustOnFirstUse(() => {}), - ).catch((e) => e)) as SshError; - expect(error.failure).toBe(failure); - expect((error as unknown as { kind: string }).kind).toBe(kind); - }); + it.each(CASES)( + "reads $name as $failure", + async ({ raw, failure, kind, systemCode }) => { + h.nextFailure = Object.assign(new Error(String(raw.message)), raw); + const error = (await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ).catch((e) => e)) as SshError; + expect(error.failure).toBe(failure); + expect((error as unknown as { kind: string }).kind).toBe(kind); + // Carried across rather than left in the sentence it was written into, + // so a caller that wants to name the fault does not have to read one. + expect(error.systemCode).toBe(systemCode); + }, + ); }); describe("running a command", () => { diff --git a/src/ipc/utils/ssh_client.ts b/src/ipc/utils/ssh_client.ts index 3b20b2d5f7..cf6834dcac 100644 --- a/src/ipc/utils/ssh_client.ts +++ b/src/ipc/utils/ssh_client.ts @@ -40,6 +40,18 @@ export class SshError extends DyadError { readonly failure: SshFailure, message: string, kind: DyadErrorKind, + /** + * What the operating system called it, where it said anything. + * + * Carried rather than left in the message for the same reason `failure` + * is: a caller that wants to say ENOTFOUND should not have to find it in + * a sentence. Deliberately not `code`, which a handler writes a mark to + * so the panel does not report a failure the screen is already showing — + * and writes only while nothing holds that name yet. Declared here, the + * name is held whether or not anything is passed, so calling this `code` + * would stop that mark being written at all and tell the user twice. + */ + readonly systemCode?: string, ) { super(message, kind); this.name = "SshError"; @@ -140,6 +152,7 @@ function classify( `Could not reach the server (${err.code}). Check the address and that ` + `port 22 is open.`, DyadErrorKind.External, + err.code, ); } return new SshError( @@ -148,6 +161,7 @@ function classify( ? `The connection to the server failed: ${err.message}` : `Could not connect over SSH: ${err.message}`, DyadErrorKind.External, + err.code, ); } From 13949e8b981d4a8183f0f7eef422aac3ee136896 Mon Sep 17 00:00:00 2001 From: Ryan Groch Date: Sun, 30 Aug 2026 16:40:57 -0500 Subject: [PATCH 91/91] fix(coolify): say which rule turned an admin address down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unrelated rules refuse an address for the Coolify admin account: one about where mail goes, and one about what Dyad can put in a shell word and then a .env file. Both said the same thing — use an address you can receive mail at — which is untrue of the second. `will!s@gmail.com` is a working address, and its owner was told they could not receive mail at it, with nothing in the message to act on. The check now says which rule refused, and the panel and the handler both show what it says rather than a sentence of their own. The character message names every character the check rejects, because being told to remove one you did not type leaves you with nothing to change. Moves the SSH fake from e2e-tests/helpers to src/testing, where the other shared harnesses already live. The unit suite was reaching into the Playwright tree for it, so a change made for one suite could break the other. Nothing in the app graph reaches it, so it stays out of the bundle. Also corrects the reason a certificate test exists. It reads as though a domain pointing at a router that forwards to a private box could be given a certificate; it cannot, and the flow refuses it a step later for the address it resolves to. What the test actually holds is which refusal the user reads: with the domain kept, the message names the address, which is the thing that is private. Nulled, it would name the domain and send someone to check a name that was answering perfectly well. Co-Authored-By: Claude Opus 5 --- e2e-tests/coolify_setup.spec.ts | 2 +- src/components/CoolifyServerSetup.tsx | 17 ++-- src/coolify_setup/https_setup.test.ts | 10 ++- .../setup_flow.integration.test.ts | 2 +- src/ipc/handlers/coolify_setup_handlers.ts | 20 ++--- src/shared/coolify_admin_email.test.ts | 77 +++++++++++++++++-- src/shared/coolify_admin_email.ts | 56 ++++++++++---- .../testing}/fake_ssh_server.ts | 0 8 files changed, 137 insertions(+), 47 deletions(-) rename {e2e-tests/helpers => src/testing}/fake_ssh_server.ts (100%) diff --git a/e2e-tests/coolify_setup.spec.ts b/e2e-tests/coolify_setup.spec.ts index b1fab0ed20..23821b883b 100644 --- a/e2e-tests/coolify_setup.spec.ts +++ b/e2e-tests/coolify_setup.spec.ts @@ -6,7 +6,7 @@ import { Timeout } from "./helpers/constants"; import { startFakeSshServer, type FakeSshServer, -} from "./helpers/fake_ssh_server"; +} from "../src/testing/fake_ssh_server"; /** * Installing Coolify onto a server, through the packaged app. diff --git a/src/components/CoolifyServerSetup.tsx b/src/components/CoolifyServerSetup.tsx index e0d7c4533f..ef953e974b 100644 --- a/src/components/CoolifyServerSetup.tsx +++ b/src/components/CoolifyServerSetup.tsx @@ -16,7 +16,7 @@ import type { import { showError } from "@/lib/toast"; import { queryKeys } from "@/lib/queryKeys"; import { useCoolifySetupSnapshot } from "@/hooks/useCoolifySetupSnapshot"; -import { isPlausibleAdminEmail } from "@/shared/coolify_admin_email"; +import { adminEmailRefusal } from "@/shared/coolify_admin_email"; import { isPlausibleInstanceDomain } from "@/shared/coolify_domain"; import { selectCoolifySetupCapabilities } from "@/coolify_setup/capabilities"; @@ -210,7 +210,8 @@ export function CoolifyServerSetup({ await ipc.coolifySetup.dismiss().catch(showError); }; - const emailLooksUsable = !adminEmail || isPlausibleAdminEmail(adminEmail); + const emailRefusal = adminEmail ? adminEmailRefusal(adminEmail) : null; + const emailLooksUsable = !emailRefusal; const domainLooksUsable = !customDomain.trim() || isPlausibleInstanceDomain(customDomain); // --- Finished --- @@ -445,13 +446,13 @@ export function CoolifyServerSetup({ value={adminEmail} onChange={(e) => setAdminEmail(e.target.value)} /> - {/* Checked while typing, because Coolify resolves the domain when it - creates the account — and finding out afterwards costs the whole - install. */} - {!emailLooksUsable && ( + {/* Checked while typing, because neither reason is cheap to find out + later: a domain Coolify will not take costs the whole install, and + an address Dyad cannot put in a shell word costs a run that + connects, looks the server over, and then fails. */} + {emailRefusal && (

- Use an address you can receive mail at. Coolify checks that the - domain resolves when it creates the account. + {emailRefusal}

)}

diff --git a/src/coolify_setup/https_setup.test.ts b/src/coolify_setup/https_setup.test.ts index 08568185d7..371986c272 100644 --- a/src/coolify_setup/https_setup.test.ts +++ b/src/coolify_setup/https_setup.test.ts @@ -81,9 +81,13 @@ describe("certificateDomainFor", () => { ); }); - it("still takes a domain the user gave for such a server", () => { - // Their own domain may point at a router that forwards to it, which is a - // different question from what the address itself can be reached at. + it("keeps a domain the user gave, whatever the address is", () => { + // Not because the certificate can be had — the caller turns this server + // down a step later for the address it resolves to. It decides which + // refusal the user reads: with the domain kept, the message names the + // address, which is the thing that is actually private. Nulled here, it + // would name the domain instead and send someone to check a name that + // was answering perfectly well. expect(certificateDomainFor("192.168.1.50", "coolify.example.com")).toBe( "coolify.example.com", ); diff --git a/src/coolify_setup/setup_flow.integration.test.ts b/src/coolify_setup/setup_flow.integration.test.ts index a9646d8c6c..adefa40a2f 100644 --- a/src/coolify_setup/setup_flow.integration.test.ts +++ b/src/coolify_setup/setup_flow.integration.test.ts @@ -6,7 +6,7 @@ import { generateSshKeyPair, startFakeSshServer, type FakeSshServer, -} from "../../e2e-tests/helpers/fake_ssh_server"; +} from "@/testing/fake_ssh_server"; /** * The setup flow over a real SSH connection, without the app around it. diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts index c54f7e4a29..c7a52544a3 100644 --- a/src/ipc/handlers/coolify_setup_handlers.ts +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -24,7 +24,7 @@ import { runServerSetup } from "@/coolify_setup/setup_flow"; import { CoolifySetupController } from "@/coolify_setup/controller"; import { selectCoolifySetupCapabilities } from "@/coolify_setup/capabilities"; import { uuidIdSource } from "@/state_machines/clock"; -import { isPlausibleAdminEmail } from "@/shared/coolify_admin_email"; +import { adminEmailRefusal } from "@/shared/coolify_admin_email"; import { isPlausibleInstanceDomain } from "@/shared/coolify_domain"; import { sshFailureOf } from "@/shared/ssh_failure"; import { IS_TEST_BUILD } from "../utils/test_utils"; @@ -543,15 +543,15 @@ export function registerCoolifySetupHandlers() { // DO NOT LOG this handler: its result carries the generated admin password. createTypedHandler(coolifySetupContracts.run, async (_, input) => { - // Checked before anything is done, because Coolify resolves the domain when - // it seeds its admin and a rejected address leaves an install with no - // account on it — minutes later, with nothing to show for them. - if (!isPlausibleAdminEmail(input.adminEmail)) { - throw new DyadError( - "Use an email address you can receive mail at. Coolify checks that " + - "the domain resolves when it creates the admin account.", - DyadErrorKind.Validation, - ); + // Checked before anything is done, though the two cost differently. + // Coolify resolves the domain when it seeds its admin, so a domain it + // will not take is found out minutes in, on a finished install with no + // account on it. An address buildInstallScript cannot put in a shell word + // never reaches the server at all — but not before a connection, a + // preflight, and an account record written and then taken back off. + const emailRefusal = adminEmailRefusal(input.adminEmail); + if (emailRefusal) { + throw new DyadError(emailRefusal, DyadErrorKind.Validation); } if (input.customDomain && !isPlausibleInstanceDomain(input.customDomain)) { throw new DyadError( diff --git a/src/shared/coolify_admin_email.test.ts b/src/shared/coolify_admin_email.test.ts index 9bffa974c4..dd4f4dd6f0 100644 --- a/src/shared/coolify_admin_email.test.ts +++ b/src/shared/coolify_admin_email.test.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from "vitest"; -import { isPlausibleAdminEmail } from "./coolify_admin_email"; +import { adminEmailRefusal } from "./coolify_admin_email"; describe("reserved domains", () => { it("refuses a subdomain of a documentation domain", () => { // RFC 2606 reserves everything under these, so Coolify's seeder will // refuse the address minutes into an install. - expect(isPlausibleAdminEmail("admin@mail.example.com")).toBe(false); - expect(isPlausibleAdminEmail("admin@notexample.com")).toBe(true); + expect(adminEmailRefusal("admin@mail.example.com")).not.toBeNull(); + expect(adminEmailRefusal("admin@notexample.com")).toBeNull(); }); }); -describe("isPlausibleAdminEmail", () => { +describe("addresses Coolify would turn down", () => { // Coolify resolves the domain, so these fail on the server however // well-formed they look. Catching them here means the user finds out while // typing rather than after a multi-minute install that seeds nothing. @@ -24,14 +24,75 @@ describe("isPlausibleAdminEmail", () => { ["admin@ dyad.sh", false, "a space is not allowed"], ["someone@gmail.com", true, "an ordinary address"], ["dev+coolify@sub.domain.co.uk", true, "tagging and subdomains are fine"], - ])("reads %s as %s (%s)", (email, expected) => { - expect(isPlausibleAdminEmail(email)).toBe(expected); + ])("reads %s as %s (%s)", (email, usable) => { + expect(adminEmailRefusal(email) === null).toBe(usable); }); it("does not reject a domain merely for containing a reserved word", () => { // The check looks at the last label; a stricter rule would turn away // addresses that work perfectly well. - expect(isPlausibleAdminEmail("admin@test-lab.com")).toBe(true); - expect(isPlausibleAdminEmail("admin@example-corp.io")).toBe(true); + expect(adminEmailRefusal("admin@test-lab.com")).toBeNull(); + expect(adminEmailRefusal("admin@example-corp.io")).toBeNull(); + }); +}); + +describe("adminEmailRefusal", () => { + it("does not call an address Dyad cannot send undeliverable", () => { + // `!` and `#` are legal in a local part, so this address may work + // perfectly well — the refusal is Dyad's, and saying the domain does not + // resolve sends the user to check something that was never wrong. + const refusal = adminEmailRefusal("will!s@gmail.com"); + + expect(refusal).toMatch(/can't send/); + expect(refusal).not.toMatch(/receive mail at/); + }); + + it("names every character it turns an address down for", () => { + // The message lists them, so the list has to be the one the check uses. + // Being told to remove a character you did not type leaves you with + // nothing to change, which is the whole of what this message is for. + const refusal = adminEmailRefusal("will!s@gmail.com") ?? ""; + for (const named of ["quote", "backslash", "`", "$", "#", "!"]) { + expect(refusal).toContain(named); + } + + // And every one of them really does land on that message rather than on + // the one about mail. A newline cannot reach it — the shape check reads + // it as whitespace first — so it is not among these. + for (const address of [ + "will's@gmail.com", + 'will"s@gmail.com', + "will\\s@gmail.com", + "will`s@gmail.com", + "will$s@gmail.com", + "will#s@gmail.com", + "will!s@gmail.com", + ]) { + expect(adminEmailRefusal(address)).toBe(refusal); + } + }); + + it("does not blame characters for an address that is not one yet", () => { + // The branch a half-typed address lands on, which is most of the + // keystrokes anyone makes here — so the wrong message on it is the one a + // user sees most. `foo..com` is the same: a shape Coolify will not + // resolve, not a character Dyad cannot send. + const undeliverable = adminEmailRefusal("admin@dyad.test"); + + expect(adminEmailRefusal("adm")).toBe(undeliverable); + expect(adminEmailRefusal("admin@nodomain")).toBe(undeliverable); + expect(adminEmailRefusal("admin@foo..com")).toBe(undeliverable); + // Reserved for documentation, which is a fact about where mail goes and + // not about anything Dyad cannot send. + expect(adminEmailRefusal("admin@example.com")).toBe(undeliverable); + expect(adminEmailRefusal("admin@mail.example.net")).toBe(undeliverable); + }); + + it("still says what a domain nobody can reach is", () => { + expect(adminEmailRefusal("admin@dyad.test")).toMatch(/receive mail at/); + }); + + it("says nothing about an address it takes", () => { + expect(adminEmailRefusal("me@gmail.com")).toBeNull(); }); }); diff --git a/src/shared/coolify_admin_email.ts b/src/shared/coolify_admin_email.ts index 818ff7915d..b103b784f8 100644 --- a/src/shared/coolify_admin_email.ts +++ b/src/shared/coolify_admin_email.ts @@ -1,23 +1,45 @@ +const UNDELIVERABLE = + "Use an email address you can receive mail at. Coolify checks that the " + + "domain resolves when it creates the admin account."; + +const UNSENDABLE = + "Dyad can't send an address containing quotes, backslashes or ` $ # ! to " + + "the server. Try one without them."; + /** - * Whether Coolify will accept an address for the admin account it seeds. + * Why this address cannot be the admin account, in words for the person who + * typed it, or null where it can. Not always Coolify's answer: one of the two + * rules below is Dyad's own limit, and an address it refuses may be one + * Coolify would have taken. * * Lives in shared/ so the panel can warn while the user is still typing and * the handler can refuse before it starts, without the two drifting apart. - * Getting it wrong is expensive in a way most validation is not: Coolify checks - * this when it seeds the account, minutes into an install, and a rejected - * address leaves a finished install with no account on it. + * Getting the domain wrong is expensive in a way most validation is not: + * Coolify checks it when it seeds the account, minutes into an install, and a + * rejected address leaves a finished install with no account on it. * - * Kept free of any Node import for the same reason — the renderer imports it, - * and a `crypto` import here would take the whole window down with it. + * Two different rules refuse an address here and they are not the same news. + * One is about where mail goes; the other is about what Dyad can put in a + * shell command. Saying the first for both told someone whose address does + * work that they could not receive mail at it, which is not true and leaves + * them nothing to change. + * + * Kept free of any Node import — the renderer imports this, and a `crypto` + * import here would take the whole window down with it. */ -export function isPlausibleAdminEmail(email: string): boolean { +export function adminEmailRefusal(email: string): string | null { const trimmed = email.trim(); - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return false; - // The address is sent to the server inside a quoted shell word, and - // buildInstallCommand refuses anything that could end that quoting. Said - // here too, so it is said while the address is being typed rather than - // after Dyad has connected and looked the server over. - if (/['"\\`$\n\r#!]/.test(trimmed)) return false; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return UNDELIVERABLE; + // The address goes to the server inside a quoted shell word and from there + // into a .env file, and buildInstallScript refuses every character that + // could break either — so the set is wider than the quoting alone would + // need. Said here too, so it is said while the address is being typed + // rather than after Dyad has connected and looked the server over. + // + // Some of these are legal in a local part, so this is Dyad's limit rather + // than the address being wrong — and it says so, because telling someone + // their working address is undeliverable sends them to fix the wrong thing. + if (/['"\\`$\n\r#!]/.test(trimmed)) return UNSENDABLE; // One trailing dot is a legal way to write an absolute name, and Coolify // resolves the same domain either way — so it is removed before the checks // below rather than letting `dyad.test.` past the reserved list. @@ -29,12 +51,14 @@ export function isPlausibleAdminEmail(email: string): boolean { // Coolify will accept, and finding that out costs the whole install. Said // as "not empty" rather than as an alphabet, so an internationalised domain // is left alone. - if (!/^[^\s.@]+(\.[^\s.@]+)+$/.test(domain)) return false; + if (!/^[^\s.@]+(\.[^\s.@]+)+$/.test(domain)) return UNDELIVERABLE; // Reserved by RFC 2606 and RFC 6761 for testing and documentation, so none // of them resolve and none of them can ever be accepted. const reserved = ["test", "example", "invalid", "localhost", "local"]; const lastLabel = domain.slice(domain.lastIndexOf(".") + 1); - if (reserved.includes(lastLabel)) return false; + if (reserved.includes(lastLabel)) return UNDELIVERABLE; const documentation = ["example.com", "example.net", "example.org"]; - return !documentation.some((d) => domain === d || domain.endsWith(`.${d}`)); + return documentation.some((d) => domain === d || domain.endsWith(`.${d}`)) + ? UNDELIVERABLE + : null; } diff --git a/e2e-tests/helpers/fake_ssh_server.ts b/src/testing/fake_ssh_server.ts similarity index 100% rename from e2e-tests/helpers/fake_ssh_server.ts rename to src/testing/fake_ssh_server.ts