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
|
/**
* ssh extension — manifest + `activate(host)`.
*
* Provides TWO typed service handles (the seams other units already declared):
* 1. `remoteExecBackendFactoryHandle` (@dispatch/exec-backend) — `(alias) =>
* ExecBackend`; this is what makes `resolveBackend(computerId)` return a
* remote backend (exec-backend lazy-looks-it-up at tool-execute time).
* 2. `computerServiceHandle` (@dispatch/transport-http) — the `ComputerService`
* the HTTP routes delegate to (list/get/status/test).
*
* `activate` builds the service with REAL edges (`node:fs` + real `ssh2.Client`)
* and registers both. The injected-deps seam (`SshServiceDeps`) lets the
* integration test drive the same real ssh2 against a live sshd (mirrors how
* `packages/mcp` injects its spawn/read adapters — no `@dispatch/*` mocking).
*/
import { access, appendFile, readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { remoteExecBackendFactoryHandle } from "@dispatch/exec-backend";
import type { Extension, HostAPI, Logger, Manifest } from "@dispatch/kernel";
import { computerServiceHandle } from "@dispatch/transport-http/dist/seam.js";
import { Client } from "ssh2";
import { resolveComputer as resolveComputerFromConfig } from "./config.js";
import { createSshService, type SshServiceDeps } from "./service.js";
export const manifest: Manifest = {
id: "ssh",
name: "SSH Remote Execution",
version: "0.0.0",
apiVersion: "^0.1.0",
trust: "bundled",
activation: "eager",
// exec-backend owns the resolver; ssh provides the remote factory it looks up
// at runtime (lazy, post-activation). Declaring dependsOn keeps the DAG honest
// even though the lookup itself is deferred to tool-execute time.
dependsOn: ["exec-backend"],
capabilities: { fs: true, network: true },
contributes: { services: ["ssh", "exec-backend/remote-factory"] },
};
/**
* Build the ssh extension with injectable edges. The production `extension`
* passes real `node:fs` + real `ssh2`; a test passes the same real edges
* against a live sshd (the integration test — no `@dispatch/*` mocking).
*/
export function makeSshExtension(deps: SshServiceDeps): Extension {
const store: { close: (() => Promise<void>) | null } = { close: null };
return {
manifest,
activate(host: HostAPI) {
const { service, pool, remoteFactory } = createSshService(deps);
store.close = () => pool.closeAll();
host.provideService(remoteExecBackendFactoryHandle, remoteFactory);
host.provideService(computerServiceHandle, service);
host.logger.info("ssh extension activated");
},
async deactivate() {
await store.close?.();
store.close = null;
},
};
}
// ─── real node:fs + ssh2 adapters (production wiring) ─────────────────────
/**
* Resolve the real `SshServiceDeps` against the live filesystem + ssh2. The
* `resolveComputer` dep is wired from the pure config reader using the same
* live `readConfigText`/`readFileText` edges, so the pool connects with params
* resolved fresh from `~/.ssh/config` on each acquire (decision #4).
*/
export function createSshServiceDeps(hostLogger: Logger): SshServiceDeps {
const sshDir = join(homedir(), ".ssh");
const configPath = join(sshDir, "config");
const knownHostsPath = join(sshDir, "known_hosts");
const readConfigText = async (): Promise<string> => readFile(configPath, "utf8");
const readFileText = async (path: string): Promise<string> => readFile(path, "utf8");
const defaultUser = process.env.USER ?? homedir().split("/").pop() ?? "root";
return {
logger: hostLogger,
homeDir: homedir(),
defaultUser,
knownHostsPath,
readConfigText,
readFileText,
pathExists: async (path: string) =>
access(path)
.then(() => true)
.catch(() => false),
appendKnownHosts: async (path: string, line: string) =>
appendFile(path, `${line}\n`, { encoding: "utf8" }),
newClient: () => new Client(),
// Resolve a computer alias → `Computer` by reading the live config. Reads
// fresh on each call (the config is the source of truth; a Host block added
// between turns is picked up). Returns null for an unknown/stale alias.
resolveComputer: async (alias: string) => {
const [configText, knownHostsText] = await Promise.all([
readConfigText().catch(async () => ""),
readFileText(knownHostsPath).catch(async () => ""),
]);
return resolveComputerFromConfig(alias, {
configText,
knownHostsText,
defaultUser,
homeDir: homedir(),
});
},
};
}
/** Production extension: real `node:fs` + real `ssh2`. */
export const extension: Extension = {
manifest,
activate(host: HostAPI) {
const deps = createSshServiceDeps(host.logger);
makeSshExtension(deps).activate(host);
},
};
|