@patimweb/pi-ssh 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * ssh_setup tool -- Store the details of a host.
3
+ *
4
+ * A profile is set active automatically if it is the first one. Credentials
5
+ * go to ~/.pi/ssh-config.json, readable only by the owner.
6
+ */
7
+
8
+ import { Type } from "typebox";
9
+ import { expandPath, saveProfile } from "../config.ts";
10
+ import type { SetupParams, SshProfile } from "../types.ts";
11
+
12
+ export const SshSetupTool = {
13
+ name: "ssh_setup",
14
+ label: "SSH Setup",
15
+ description:
16
+ "Store an SSH host: address, user, and either a password or the path to a private key. Call this before any other ssh tool. Credentials are saved to ~/.pi/ssh-config.json (owner-readable only). If you only have a password, ssh_authorize can turn that into key-based login afterwards.",
17
+ parameters: Type.Object({
18
+ name: Type.String({
19
+ description: "Profile name, e.g. 'staging', 'nas'. Short and memorable.",
20
+ }),
21
+ host: Type.String({ description: "Hostname or IP address." }),
22
+ user: Type.String({ description: "Login user on the remote host." }),
23
+ port: Type.Optional(Type.Number({ description: "SSH port. Default 22.", default: 22 })),
24
+ password: Type.Optional(
25
+ Type.String({
26
+ description:
27
+ "Password for this host. Stored in plaintext in the config file; prefer a key, or run ssh_authorize afterwards to switch to one.",
28
+ }),
29
+ ),
30
+ privateKeyPath: Type.Optional(
31
+ Type.String({
32
+ description: "Path to a private key on this machine, e.g. ~/.ssh/id_ed25519.",
33
+ }),
34
+ ),
35
+ passphrase: Type.Optional(
36
+ Type.String({ description: "Passphrase for that private key, if it has one." }),
37
+ ),
38
+ strictHostKey: Type.Optional(
39
+ Type.Boolean({
40
+ description:
41
+ "Refuse hosts whose key is not in known_hosts. Default true. Turning it off removes the only protection against a machine-in-the-middle.",
42
+ default: true,
43
+ }),
44
+ ),
45
+ }),
46
+
47
+ execute(_toolCallId: string, params: SetupParams, _signal: AbortSignal) {
48
+ if (!params.host?.trim()) throw new Error("host must not be empty.");
49
+ if (!params.user?.trim()) throw new Error("user must not be empty.");
50
+
51
+ const port = params.port ?? 22;
52
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
53
+ throw new Error("port must be an integer between 1 and 65535.");
54
+ }
55
+ if (!params.password && !params.privateKeyPath) {
56
+ throw new Error(
57
+ "Give either a password or a privateKeyPath -- otherwise there is no way to log in.",
58
+ );
59
+ }
60
+
61
+ const profile: SshProfile = {
62
+ host: params.host.trim(),
63
+ user: params.user.trim(),
64
+ port,
65
+ ...(params.password ? { password: params.password } : {}),
66
+ ...(params.privateKeyPath
67
+ ? { privateKeyPath: expandPath(params.privateKeyPath) }
68
+ : {}),
69
+ ...(params.passphrase ? { passphrase: params.passphrase } : {}),
70
+ ...(params.strictHostKey === false ? { strictHostKey: false } : {}),
71
+ };
72
+
73
+ saveProfile(params.name, profile);
74
+
75
+ const advice = profile.password && !profile.privateKeyPath
76
+ ? "\n\nThis profile logs in with a password. Run ssh_authorize to generate a key, install it on the host, and stop needing the password."
77
+ : "";
78
+
79
+ return {
80
+ content: [
81
+ {
82
+ type: "text" as const,
83
+ text: `SSH profile "${params.name}" saved: ${profile.user}@${profile.host}:${profile.port}.${advice}`,
84
+ },
85
+ ],
86
+ details: {
87
+ profile: params.name,
88
+ host: profile.host,
89
+ port: profile.port,
90
+ user: profile.user,
91
+ hasPassword: Boolean(profile.password),
92
+ hasKey: Boolean(profile.privateKeyPath),
93
+ },
94
+ };
95
+ },
96
+ };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * ssh_status tool -- Show configured hosts and optionally test one.
3
+ */
4
+
5
+ import { Type } from "typebox";
6
+ import { getActiveProfile, getProfiles, resolveProfile } from "../config.ts";
7
+ import { withConnection } from "../clients/ssh-client.ts";
8
+ import { formatIdentity, formatProfileStatus } from "../formatting/formatters.ts";
9
+
10
+ export const SshStatusTool = {
11
+ name: "ssh_status",
12
+ label: "SSH Status",
13
+ description:
14
+ "List the configured SSH hosts and how each authenticates. With connect: true it also opens a connection to verify that the credentials and host key still work.",
15
+ parameters: Type.Object({
16
+ profile: Type.Optional(
17
+ Type.String({ description: "Profile to check. Defaults to the active one." }),
18
+ ),
19
+ connect: Type.Optional(
20
+ Type.Boolean({
21
+ description: "Actually connect to verify the host works. Default false.",
22
+ default: false,
23
+ }),
24
+ ),
25
+ }),
26
+
27
+ async execute(
28
+ _toolCallId: string,
29
+ params: { profile?: string; connect?: boolean },
30
+ signal: AbortSignal,
31
+ ) {
32
+ const profiles = getProfiles();
33
+ const overview = formatProfileStatus(profiles, getActiveProfile());
34
+
35
+ if (Object.keys(profiles).length === 0 || !params.connect) {
36
+ return {
37
+ content: [{ type: "text" as const, text: overview }],
38
+ details: {
39
+ count: Object.keys(profiles).length,
40
+ profiles: Object.keys(profiles),
41
+ activeProfile: getActiveProfile(),
42
+ },
43
+ };
44
+ }
45
+
46
+ const { name, profile } = resolveProfile(params.profile);
47
+ let connection: string;
48
+ let reachable = false;
49
+ try {
50
+ const identity = await withConnection(profile, { signal }, async (conn) =>
51
+ conn.identity,
52
+ );
53
+ connection = formatIdentity(identity);
54
+ reachable = true;
55
+ } catch (err) {
56
+ connection = `Connection to "${name}" failed:\n${(err as Error).message}`;
57
+ }
58
+
59
+ return {
60
+ content: [{ type: "text" as const, text: `${overview}\n\n${connection}` }],
61
+ details: {
62
+ count: Object.keys(profiles).length,
63
+ profiles: Object.keys(profiles),
64
+ activeProfile: getActiveProfile(),
65
+ checked: name,
66
+ reachable,
67
+ },
68
+ };
69
+ },
70
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * ssh_upload tool -- Copy a local file to the remote host over SFTP.
3
+ */
4
+
5
+ import { Type } from "typebox";
6
+ import { resolveProfile } from "../config.ts";
7
+ import { uploadFile, withConnection } from "../clients/ssh-client.ts";
8
+ import { formatTransfer } from "../formatting/formatters.ts";
9
+
10
+ export const SshUploadTool = {
11
+ name: "ssh_upload",
12
+ label: "SSH Upload File",
13
+ description:
14
+ "Copy a file from this machine to the remote host over SFTP. The remote path must include the file name; an existing file at that path is overwritten.",
15
+ parameters: Type.Object({
16
+ localPath: Type.String({ description: "File on this machine. Absolute paths are safest." }),
17
+ remotePath: Type.String({
18
+ description: "Destination path on the remote host, including the file name.",
19
+ }),
20
+ profile: Type.Optional(
21
+ Type.String({ description: "SSH profile to use. Defaults to the active one." }),
22
+ ),
23
+ acceptNewHostKey: Type.Optional(
24
+ Type.Boolean({ description: "Record an unknown host key.", default: false }),
25
+ ),
26
+ }),
27
+
28
+ async execute(
29
+ _toolCallId: string,
30
+ params: {
31
+ localPath: string;
32
+ remotePath: string;
33
+ profile?: string;
34
+ acceptNewHostKey?: boolean;
35
+ },
36
+ signal: AbortSignal,
37
+ ) {
38
+ const { name, profile } = resolveProfile(params.profile);
39
+
40
+ const result = await withConnection(
41
+ profile,
42
+ { signal, acceptNewHostKey: params.acceptNewHostKey },
43
+ (connection) => uploadFile(connection, params.localPath, params.remotePath),
44
+ );
45
+
46
+ return {
47
+ content: [{ type: "text" as const, text: formatTransfer(result, "up") }],
48
+ details: { profile: name, ...result },
49
+ };
50
+ },
51
+ };
package/src/types.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Data types for the pi SSH extension.
3
+ *
4
+ * All domain data is represented as plain immutable-shaped interfaces.
5
+ * No behavior, no classes, no inheritance -- just data (errors excepted).
6
+ */
7
+
8
+ // Configuration
9
+
10
+ export interface SshProfile {
11
+ readonly host: string;
12
+ readonly port: number;
13
+ readonly user: string;
14
+ /** Password auth. Stored in plaintext, so the file is 0600. */
15
+ readonly password?: string;
16
+ /** Path to a private key on this machine. The key itself is not copied. */
17
+ readonly privateKeyPath?: string;
18
+ /** Passphrase for that key, when it has one. */
19
+ readonly passphrase?: string;
20
+ /** Override the known_hosts file for this profile. */
21
+ readonly knownHostsFile?: string;
22
+ /**
23
+ * Refuse to connect to a host whose key is not on record. On by default:
24
+ * turning it off removes the only defence against a machine-in-the-middle.
25
+ */
26
+ readonly strictHostKey?: boolean;
27
+ readonly connectTimeoutMs?: number;
28
+ }
29
+
30
+ export interface SshProfiles {
31
+ readonly profiles: Record<string, SshProfile>;
32
+ readonly activeProfile: string | null;
33
+ }
34
+
35
+ // Domain
36
+
37
+ export interface ExecResult {
38
+ readonly command: string;
39
+ readonly stdout: string;
40
+ readonly stderr: string;
41
+ readonly code: number | null;
42
+ /** Set when the command was killed by a signal rather than exiting. */
43
+ readonly signal?: string;
44
+ readonly durationMs: number;
45
+ /** True when output was cut off at the configured limit. */
46
+ readonly truncated: boolean;
47
+ }
48
+
49
+ export interface RemoteEntry {
50
+ readonly name: string;
51
+ readonly type: "file" | "directory" | "symlink" | "other";
52
+ readonly size: number;
53
+ readonly modified?: string;
54
+ readonly mode: string;
55
+ }
56
+
57
+ export interface TransferResult {
58
+ readonly localPath: string;
59
+ readonly remotePath: string;
60
+ readonly size: number;
61
+ }
62
+
63
+ export interface ServerIdentity {
64
+ readonly host: string;
65
+ readonly port: number;
66
+ readonly user: string;
67
+ readonly fingerprint: string;
68
+ readonly keyType: string;
69
+ /** How the host key compared to known_hosts. */
70
+ readonly hostKeyVerdict: string;
71
+ /** Which authentication method actually succeeded. */
72
+ readonly authMethod: string;
73
+ }
74
+
75
+ export interface AuthorizeResult {
76
+ readonly profile: string;
77
+ readonly keyPath: string;
78
+ readonly publicKeyPath: string;
79
+ readonly fingerprint: string;
80
+ /** False when the key was already present in authorized_keys. */
81
+ readonly installed: boolean;
82
+ /** True when a passwordless login was confirmed afterwards. */
83
+ readonly verified: boolean;
84
+ readonly authorizedKeysPath: string;
85
+ }
86
+
87
+ // Tool parameter shapes
88
+
89
+ export interface SetupParams {
90
+ readonly name: string;
91
+ readonly host: string;
92
+ readonly user: string;
93
+ readonly port?: number;
94
+ readonly password?: string;
95
+ readonly privateKeyPath?: string;
96
+ readonly passphrase?: string;
97
+ readonly strictHostKey?: boolean;
98
+ }
99
+
100
+ // Errors
101
+
102
+ export class SshNotConfiguredError extends Error {
103
+ constructor() {
104
+ super("No SSH host configured. Use the ssh_setup tool first (host, user, and a password or key).");
105
+ this.name = "SshNotConfiguredError";
106
+ }
107
+ }
108
+
109
+ export class HostKeyChangedError extends Error {
110
+ readonly fingerprint: string;
111
+
112
+ constructor(message: string, fingerprint: string) {
113
+ super(message);
114
+ this.name = "HostKeyChangedError";
115
+ this.fingerprint = fingerprint;
116
+ }
117
+ }
118
+
119
+ export class UnknownHostKeyError extends Error {
120
+ readonly fingerprint: string;
121
+
122
+ constructor(message: string, fingerprint: string) {
123
+ super(message);
124
+ this.name = "UnknownHostKeyError";
125
+ this.fingerprint = fingerprint;
126
+ }
127
+ }
128
+
129
+ export class SshAuthError extends Error {
130
+ constructor(message: string) {
131
+ super(message);
132
+ this.name = "SshAuthError";
133
+ }
134
+ }
135
+
136
+ export class RemoteCommandError extends Error {
137
+ readonly result: ExecResult;
138
+
139
+ constructor(message: string, result: ExecResult) {
140
+ super(message);
141
+ this.name = "RemoteCommandError";
142
+ this.result = result;
143
+ }
144
+ }