From b03b9dbb43455f91f8af414bfa87babba0c8671f Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 26 Sep 2026 20:44:06 +0000 Subject: [PATCH] feat(desktop/fleet): run a command/script on the local machine, not just servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Fleet view could only run a script over SSH on selected servers; there was no way to run something LOCALLY — e.g. a script that itself fans out to the fleet (an ssh loop, an rsync push, the threatcrush rollout). Add a synthetic "localhost (this machine)" target to the fleet server list. It runs through a LocalSession that implements the exact slice of SshSession the runner uses (execStream → ExecHandle), backed by child_process, so runFleet targets the local host with zero fleet-core changes: connectFleet() hands back a LocalSession for the reserved `local` connection id and a pooled SshSession for every other host. The interpreter, hazard check, streaming, run history and saved commands are all identical — only the transport differs. Because it flows through the existing fleet path, local runs are gated by the same hazard-check + confirmation the renderer already can't bypass; this adds no new trust boundary (the fleet feature already accepts renderer-supplied script text). The renderer needs no change: FleetView lists servers dynamically, so localhost shows up as a tickable target and its output streams like any host. LocalSession spawns detached and kills the whole process group on timeout/cancel, so a script that spawned its own children is stopped as a group. Tests: local-session.test.ts (stdout/stderr line streaming, exit codes, stdin, timeout→timedOut, cancel). Desktop suite 162 pass; renderer + electron typecheck clean. Co-Authored-By: Claude Opus 4.8 --- apps/desktop/electron/main/services/fleet.ts | 59 ++++++++- .../main/services/local-session.test.ts | 55 ++++++++ .../electron/main/services/local-session.ts | 118 ++++++++++++++++++ 3 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/electron/main/services/local-session.test.ts create mode 100644 apps/desktop/electron/main/services/local-session.ts diff --git a/apps/desktop/electron/main/services/fleet.ts b/apps/desktop/electron/main/services/fleet.ts index 6f006c2..87c4427 100644 --- a/apps/desktop/electron/main/services/fleet.ts +++ b/apps/desktop/electron/main/services/fleet.ts @@ -9,11 +9,51 @@ import { type SudoMode, } from '@diskpush/fleet-core' import type { Connection, FleetCommand, FleetHostResult, FleetList, HostUpdateReport } from '@diskpush/schemas' -import { sshConfigConnections } from '@diskpush/ssh-core' +import { sshConfigConnections, type SshSession } from '@diskpush/ssh-core' +import { userInfo } from 'node:os' import { IPC, type FleetRequest } from '../../shared/contract.js' import { dropSession, sessionFor } from './sessions.js' +import { LOCAL_CONNECTION_ID, isLocalConnectionId, localSession } from './local-session.js' import { store } from './store.js' +/** + * The synthetic "this machine" target. + * + * It appears in the fleet server list like any other server, so the user can + * tick it and run a command/script LOCALLY — including one that fans out to the + * fleet itself (an ssh loop, an rsync push). Everything else about a fleet run + * (interpreter, hazard check, streaming, history) is identical; only the + * transport differs, which is decided in connectFleet(). + */ +function localhostConnection(): Connection { + const now = new Date().toISOString() + return { + id: LOCAL_CONNECTION_ID, + name: 'localhost (this machine)', + host: 'localhost', + port: 22, + username: userInfo().username, + authType: 'agent', + keyPath: null, + defaultLocalPath: null, + defaultRemotePath: null, + jumpHost: null, + rsyncPath: null, + connectTimeoutSeconds: 15, + keepaliveSeconds: 30, + forwardAgent: false, + tags: ['local'], + notes: 'Runs on this machine, not over SSH.', + createdAt: now, + updatedAt: now, + } +} + +/** Local host runs in-process; every other host is a pooled SSH session. */ +function connectFleet(connection: Connection): Promise { + return isLocalConnectionId(connection.id) ? Promise.resolve(localSession()) : sessionFor(connection) +} + /** * Fleet operations for the desktop. * @@ -30,7 +70,12 @@ const running = new Map() export async function fleetServers(): Promise { const saved = await (await store()).listConnections() const savedNames = new Set(saved.map((connection) => connection.name)) - return [...saved, ...sshConfigConnections().filter((host) => !savedNames.has(host.name))] + // localhost first, then saved connections, then ~/.ssh/config hosts. + return [ + localhostConnection(), + ...saved, + ...sshConfigConnections().filter((host) => !savedNames.has(host.name)), + ] } export async function fleetCommands(): Promise { @@ -133,7 +178,7 @@ export async function startFleet(request: FleetRequest, sender: WebContents): Pr onFailure: request.onFailure, runId, signal: controller.signal, - connect: (connection) => sessionFor(connection), + connect: (connection) => connectFleet(connection), // No release: the desktop pools sessions across browsing and // transfers, so closing one here would shut a file pane's connection // out from under it. @@ -163,7 +208,11 @@ export async function startFleet(request: FleetRequest, sender: WebContents): Pr // A command that changed sshd, the login shell or a key would leave a // pooled session pointing at a server that no longer works the way the // session assumes. Cheaper to reconnect than to debug that later. - if (request.sudo) for (const connection of connections) dropSession(connection.id) + if (request.sudo) { + for (const connection of connections) { + if (!isLocalConnectionId(connection.id)) dropSession(connection.id) + } + } } })() @@ -258,7 +307,7 @@ export async function checkFleetServers(input: { connections, concurrency: input.concurrency, timeoutSeconds: input.timeoutSeconds, - connect: (connection) => sessionFor(connection), + connect: (connection) => connectFleet(connection), }) return reports } diff --git a/apps/desktop/electron/main/services/local-session.test.ts b/apps/desktop/electron/main/services/local-session.test.ts new file mode 100644 index 0000000..a4a52f1 --- /dev/null +++ b/apps/desktop/electron/main/services/local-session.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest' +import { localExecStream, isLocalConnectionId, LOCAL_CONNECTION_ID } from './local-session.js' + +describe('isLocalConnectionId', () => { + it('recognizes the reserved local id and nothing else', () => { + expect(isLocalConnectionId(LOCAL_CONNECTION_ID)).toBe(true) + expect(isLocalConnectionId('local')).toBe(true) + expect(isLocalConnectionId('ssh-config:dev1')).toBe(false) + expect(isLocalConnectionId('some-uuid')).toBe(false) + }) +}) + +describe('localExecStream', () => { + it('captures stdout and a zero exit, and streams whole lines', async () => { + const lines: string[] = [] + const handle = localExecStream("printf 'one\\ntwo\\n'", { onStdout: (l) => lines.push(l) }) + const res = await handle.finished + expect(res.code).toBe(0) + expect(res.timedOut).toBe(false) + expect(res.stdout).toBe('one\ntwo\n') + expect(lines).toEqual(['one', 'two']) + }) + + it('captures a non-zero exit code', async () => { + const res = await localExecStream('exit 3').finished + expect(res.code).toBe(3) + }) + + it('separates stderr and streams it', async () => { + const errs: string[] = [] + const res = await localExecStream('echo oops 1>&2', { onStderr: (l) => errs.push(l) }).finished + expect(res.stderr).toBe('oops\n') + expect(errs).toEqual(['oops']) + expect(res.code).toBe(0) + }) + + it('feeds stdin to the command (as sudo -S would use)', async () => { + const res = await localExecStream('cat', { stdin: 'secret-line\n' }).finished + expect(res.stdout).toBe('secret-line\n') + }) + + it('enforces a timeout and marks the result timedOut', async () => { + const res = await localExecStream('sleep 30', { timeoutSeconds: 1 }).finished + expect(res.timedOut).toBe(true) + expect(res.code).not.toBe(0) + }, 15000) + + it('can be cancelled', async () => { + const handle = localExecStream('sleep 30') + setTimeout(() => handle.cancel(), 50) + const res = await handle.finished + expect(res.code).not.toBe(0) + expect(res.timedOut).toBe(false) + }, 15000) +}) diff --git a/apps/desktop/electron/main/services/local-session.ts b/apps/desktop/electron/main/services/local-session.ts new file mode 100644 index 0000000..e900527 --- /dev/null +++ b/apps/desktop/electron/main/services/local-session.ts @@ -0,0 +1,118 @@ +import { spawn } from 'node:child_process' +import type { ExecHandle, ExecResult, ExecStreamOptions, SshSession } from '@diskpush/ssh-core' + +/** + * A "session" that runs a fleet command on THIS machine instead of over SSH. + * + * It implements the exact slice of SshSession that the fleet runner uses — + * `execStream(command, options): ExecHandle` — so `runFleet` can target the + * local host with no changes: the desktop's connect() hands back a LocalSession + * for the synthetic `localhost` connection and a pooled SshSession for the rest. + * + * The command is the same interpreter invocation the runner builds for a remote + * host (e.g. `sh -c '