summaryrefslogtreecommitdiffhomepage
path: root/packages/ssh/src/service.ts
blob: 6a809c69249c9e417962bd012971d9e62134a447 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
/**
 * ComputerService — the read-only computer discovery + live-state surface the
 * transport-http routes delegate to (`computerServiceHandle`), plus the remote
 * `ExecBackend` factory exec-backend consumes (`remoteExecBackendFactoryHandle`).
 *
 * This is the IMPERATIVE SHELL that wires the pure config reader (`config.ts`)
 * to the real filesystem + the `SshConnectionPool`. It reads `~/.ssh/config` +
 * `~/.ssh/known_hosts` (read-only — decision #4: computers are discovered, not
 * CRUD'd), resolves aliases, and delegates connect/test/status to the pool.
 *
 * `usageCount` (on `ComputerEntry`) is INJECTED, not owned here: the ssh package
 * discovers computers; how many conversations/workspaces reference an alias is
 * conversation-store data. host-bin wires `getUsageCounts` from conversation-store
 * later (a CR — conversation-store needs a count-by-alias helper); until then it
 * defaults to 0 so the feature is fully functional (discovery + connect).
 */

import type { ExecBackend } from "@dispatch/exec-backend";
import type { Logger } from "@dispatch/kernel";
import type { ComputerStatusResponse, TestComputerResponse } from "@dispatch/transport-contract";
import type { ComputerService } from "@dispatch/transport-http/dist/seam.js";
import type { Computer, ComputerEntry } from "@dispatch/wire";
import { createSshExecBackend } from "./backend.js";
import { resolveComputer, resolveComputers } from "./config.js";
import { createSshConnectionPool, type SshConnectionPool, type SshPoolDeps } from "./pool.js";

/**
 * Edges the service drives (mirrors mcp's injected deps). The real wiring
 * (extension.ts) passes `node:fs` + real ssh2; the integration test passes the
 * same real edges against a real sshd.
 */
export interface SshServiceDeps extends SshPoolDeps {
	readonly logger: Logger;
	/** Read `~/.ssh/config` text (the source of truth — decision #4). */
	readonly readConfigText: () => Promise<string>;
	/** The current OS user (fallback when the config sets no `User`). */
	readonly defaultUser: string;
	/** Home dir, for resolving `~` in `IdentityFile`/default key probing. */
	readonly homeDir: string;
	/**
	 * Optional: alias → usage count (conversations/workspaces referencing it).
	 * host-bin wires this from conversation-store; absent → every count is 0.
	 */
	readonly getUsageCounts?: () => Promise<ReadonlyMap<string, number>>;
}

/** Build the `ComputerService` + the remote-`ExecBackend` factory. */
export function createSshService(deps: SshServiceDeps): {
	readonly service: ComputerService;
	readonly pool: SshConnectionPool;
	/** `(computerId) => ExecBackend` — provided via remoteExecBackendFactoryHandle. */
	readonly remoteFactory: (computerId: string) => ExecBackend;
} {
	const pool = createSshConnectionPool(deps);

	async function readEnv() {
		const [configText, knownHostsText] = await Promise.all([
			deps.readConfigText().catch(async () => ""),
			deps.readFileText(deps.knownHostsPath).catch(async () => ""),
		]);
		return { configText, knownHostsText, defaultUser: deps.defaultUser, homeDir: deps.homeDir };
	}

	const service: ComputerService = {
		async listComputers(): Promise<readonly ComputerEntry[]> {
			const env = await readEnv();
			const computers = resolveComputers(env);
			const counts = deps.getUsageCounts !== undefined ? await deps.getUsageCounts() : new Map();
			return computers.map(
				(c): ComputerEntry => ({
					...c,
					usageCount: counts.get(c.alias) ?? 0,
				}),
			);
		},

		async getComputer(alias: string): Promise<Computer | null> {
			const env = await readEnv();
			return resolveComputer(alias, env);
		},

		async getStatus(alias: string): Promise<ComputerStatusResponse> {
			const env = await readEnv();
			const computer = resolveComputer(alias, env);
			if (computer === null) {
				return {
					alias,
					state: "disconnected",
					knownHost: false,
				};
			}
			// Surface the pool's live state for this alias (disconnected if never
			// acquired; connecting/connected/error once a connect is attempted).
			const entry = pool.status().find((s) => s.computerId === alias);
			if (entry === undefined) {
				return { alias, state: "disconnected", knownHost: computer.knownHost };
			}
			if (entry.error !== undefined) {
				return { alias, state: "error", error: entry.error, knownHost: computer.knownHost };
			}
			return { alias, state: entry.state, knownHost: computer.knownHost };
		},

		async test(alias: string): Promise<TestComputerResponse> {
			const env = await readEnv();
			const computer = resolveComputer(alias, env);
			if (computer === null) {
				return { alias, ok: false, error: `unknown computer alias "${alias}"` };
			}
			// One-shot probe: acquire (connects), run a trivial command, then drop
			// the connection so a test never holds a pooled socket open (plan §9.1).
			try {
				const conn = await pool.acquire(alias);
				const client = await conn.getClient();
				const ok = await runProbe(client);
				if (ok) {
					// Successful connect pins the host key (the accept-new analog);
					// a fresh known_hosts read reflects the new pin.
					deps.logger.info("computer test ok", { alias });
				}
				await pool.drop(alias);
				return ok
					? { alias, ok: true }
					: { alias, ok: false, error: "remote command returned no exit code" };
			} catch (err: unknown) {
				await pool.drop(alias).catch(() => undefined);
				const message = err instanceof Error ? err.message : String(err);
				deps.logger.warn("computer test failed", { alias, error: message });
				return { alias, ok: false, error: message };
			}
		},
	};

	/**
	 * The factory exec-backend consumes: given a computerId (alias), return a
	 * remote `ExecBackend`. The backend acquires lazily — merely building it
	 * (in the resolver) opens NO connection; the first method call connects.
	 * Only the alias is captured; the pool re-resolves connection params from
	 * `~/.ssh/config` at connect time, so no stale snapshot is held here.
	 */
	const remoteFactory = (computerId: string): ExecBackend =>
		createSshExecBackend(computerId, async (alias) => pool.acquire(alias));

	return { service, pool, remoteFactory };
}

/** Run `true` over SSH as a connectivity probe; resolve ok=true on exit 0. */
function runProbe(client: import("ssh2").Client): Promise<boolean> {
	return new Promise<boolean>((resolve) => {
		client.exec("true", { pty: false }, (err, stream) => {
			if (err !== null && err !== undefined) {
				resolve(false);
				return;
			}
			let exitCode: number | null = null;
			stream.on("exit", (code: number | null) => {
				exitCode = code;
			});
			stream.on("close", () => {
				resolve(exitCode === 0);
			});
		});
	});
}