@oh-my-pi/pi-coding-agent 3.33.0 → 3.34.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.
- package/CHANGELOG.md +34 -9
- package/docs/custom-tools.md +1 -1
- package/docs/extensions.md +4 -4
- package/docs/hooks.md +2 -2
- package/docs/sdk.md +4 -8
- package/examples/custom-tools/README.md +2 -2
- package/examples/extensions/README.md +1 -1
- package/examples/extensions/todo.ts +1 -1
- package/examples/hooks/custom-compaction.ts +4 -2
- package/examples/hooks/handoff.ts +1 -1
- package/examples/hooks/qna.ts +1 -1
- package/examples/sdk/02-custom-model.ts +1 -1
- package/examples/sdk/README.md +1 -1
- package/package.json +5 -5
- package/src/capability/ssh.ts +42 -0
- package/src/cli/file-processor.ts +1 -1
- package/src/cli/list-models.ts +1 -1
- package/src/core/agent-session.ts +19 -5
- package/src/core/auth-storage.ts +1 -1
- package/src/core/compaction/branch-summarization.ts +2 -2
- package/src/core/compaction/compaction.ts +2 -2
- package/src/core/compaction/utils.ts +1 -1
- package/src/core/custom-tools/types.ts +1 -1
- package/src/core/extensions/runner.ts +1 -1
- package/src/core/extensions/types.ts +1 -1
- package/src/core/extensions/wrapper.ts +1 -1
- package/src/core/hooks/runner.ts +2 -2
- package/src/core/hooks/types.ts +1 -1
- package/src/core/index.ts +11 -0
- package/src/core/messages.ts +1 -1
- package/src/core/model-registry.ts +1 -1
- package/src/core/model-resolver.ts +7 -6
- package/src/core/sdk.ts +26 -2
- package/src/core/session-manager.ts +1 -1
- package/src/core/ssh/connection-manager.ts +466 -0
- package/src/core/ssh/ssh-executor.ts +190 -0
- package/src/core/ssh/sshfs-mount.ts +162 -0
- package/src/core/ssh-executor.ts +5 -0
- package/src/core/system-prompt.ts +424 -1
- package/src/core/title-generator.ts +2 -2
- package/src/core/tools/index.test.ts +1 -0
- package/src/core/tools/index.ts +3 -0
- package/src/core/tools/output.ts +1 -1
- package/src/core/tools/read.ts +24 -11
- package/src/core/tools/renderers.ts +2 -0
- package/src/core/tools/ssh.ts +302 -0
- package/src/core/tools/task/index.ts +1 -1
- package/src/core/tools/task/types.ts +1 -1
- package/src/core/tools/task/worker.ts +1 -1
- package/src/core/voice.ts +1 -1
- package/src/discovery/index.ts +3 -0
- package/src/discovery/ssh.ts +162 -0
- package/src/main.ts +1 -1
- package/src/modes/interactive/components/assistant-message.ts +1 -1
- package/src/modes/interactive/components/custom-message.ts +1 -1
- package/src/modes/interactive/components/footer.ts +1 -1
- package/src/modes/interactive/components/hook-message.ts +1 -1
- package/src/modes/interactive/components/model-selector.ts +1 -1
- package/src/modes/interactive/components/oauth-selector.ts +1 -1
- package/src/modes/interactive/components/status-line.ts +1 -1
- package/src/modes/interactive/interactive-mode.ts +1 -1
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/rpc/rpc-client.ts +1 -1
- package/src/modes/rpc/rpc-types.ts +1 -1
- package/src/prompts/system-prompt.md +4 -0
- package/src/prompts/tools/ssh.md +74 -0
- package/src/utils/image-resize.ts +1 -1
package/src/core/messages.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* and provides a transformer to convert them to LLM-compatible messages.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { ImageContent, Message, TextContent } from "@mariozechner/pi-ai";
|
|
9
8
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
9
|
+
import type { ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
|
|
10
10
|
|
|
11
11
|
export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
|
|
12
12
|
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type KnownProvider,
|
|
12
12
|
type Model,
|
|
13
13
|
normalizeDomain,
|
|
14
|
-
} from "@
|
|
14
|
+
} from "@oh-my-pi/pi-ai";
|
|
15
15
|
import { type Static, Type } from "@sinclair/typebox";
|
|
16
16
|
import AjvModule from "ajv";
|
|
17
17
|
import type { AuthStorage } from "./auth-storage";
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Model resolution, scoping, and initial selection
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@mariozechner/pi-ai";
|
|
6
5
|
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
6
|
+
import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
|
|
7
7
|
import chalk from "chalk";
|
|
8
8
|
import { minimatch } from "minimatch";
|
|
9
9
|
import { isValidThinkingLevel } from "../cli/args";
|
|
@@ -25,6 +25,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
|
|
|
25
25
|
cerebras: "zai-glm-4.6",
|
|
26
26
|
zai: "glm-4.6",
|
|
27
27
|
mistral: "devstral-medium-latest",
|
|
28
|
+
opencode: "claude-sonnet-4-5",
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
export interface ScopedModel {
|
|
@@ -79,7 +80,7 @@ function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Mod
|
|
|
79
80
|
const provider = modelPattern.substring(0, slashIndex);
|
|
80
81
|
const modelId = modelPattern.substring(slashIndex + 1);
|
|
81
82
|
const providerMatch = availableModels.find(
|
|
82
|
-
(m) => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase()
|
|
83
|
+
(m) => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase(),
|
|
83
84
|
);
|
|
84
85
|
if (providerMatch) {
|
|
85
86
|
return providerMatch;
|
|
@@ -97,7 +98,7 @@ function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Mod
|
|
|
97
98
|
const matches = availableModels.filter(
|
|
98
99
|
(m) =>
|
|
99
100
|
m.id.toLowerCase().includes(modelPattern.toLowerCase()) ||
|
|
100
|
-
m.name?.toLowerCase().includes(modelPattern.toLowerCase())
|
|
101
|
+
m.name?.toLowerCase().includes(modelPattern.toLowerCase()),
|
|
101
102
|
);
|
|
102
103
|
|
|
103
104
|
if (matches.length === 0) {
|
|
@@ -351,7 +352,7 @@ export async function restoreModelFromSession(
|
|
|
351
352
|
savedModelId: string,
|
|
352
353
|
currentModel: Model<Api> | undefined,
|
|
353
354
|
shouldPrintMessages: boolean,
|
|
354
|
-
modelRegistry: ModelRegistry
|
|
355
|
+
modelRegistry: ModelRegistry,
|
|
355
356
|
): Promise<{ model: Model<Api> | undefined; fallbackMessage: string | undefined }> {
|
|
356
357
|
const restoredModel = modelRegistry.find(savedProvider, savedModelId);
|
|
357
358
|
|
|
@@ -427,7 +428,7 @@ export async function restoreModelFromSession(
|
|
|
427
428
|
*/
|
|
428
429
|
export async function findSmolModel(
|
|
429
430
|
modelRegistry: ModelRegistry,
|
|
430
|
-
savedModel?: string
|
|
431
|
+
savedModel?: string,
|
|
431
432
|
): Promise<Model<Api> | undefined> {
|
|
432
433
|
const availableModels = modelRegistry.getAvailable();
|
|
433
434
|
if (availableModels.length === 0) return undefined;
|
|
@@ -470,7 +471,7 @@ export async function findSmolModel(
|
|
|
470
471
|
*/
|
|
471
472
|
export async function findSlowModel(
|
|
472
473
|
modelRegistry: ModelRegistry,
|
|
473
|
-
savedModel?: string
|
|
474
|
+
savedModel?: string,
|
|
474
475
|
): Promise<Model<Api> | undefined> {
|
|
475
476
|
const availableModels = modelRegistry.getAvailable();
|
|
476
477
|
if (availableModels.length === 0) return undefined;
|
package/src/core/sdk.ts
CHANGED
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { join } from "node:path";
|
|
30
|
-
import type { Model } from "@mariozechner/pi-ai";
|
|
31
30
|
import { Agent, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
31
|
+
import type { Model } from "@oh-my-pi/pi-ai";
|
|
32
32
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
33
33
|
import chalk from "chalk";
|
|
34
34
|
// Import discovery to register all providers on startup
|
|
@@ -37,6 +37,7 @@ import { loadSync as loadCapability } from "../capability/index";
|
|
|
37
37
|
import { type Rule, ruleCapability } from "../capability/rule";
|
|
38
38
|
import { getAgentDir, getConfigDirPaths } from "../config";
|
|
39
39
|
import { initializeWithSettings } from "../discovery";
|
|
40
|
+
import { registerAsyncCleanup } from "../modes/cleanup";
|
|
40
41
|
import { AgentSession } from "./agent-session";
|
|
41
42
|
import { AuthStorage } from "./auth-storage";
|
|
42
43
|
import {
|
|
@@ -67,6 +68,8 @@ import { SessionManager } from "./session-manager";
|
|
|
67
68
|
import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager";
|
|
68
69
|
import { loadSkills as loadSkillsInternal, type Skill } from "./skills";
|
|
69
70
|
import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./slash-commands";
|
|
71
|
+
import { closeAllConnections } from "./ssh/connection-manager";
|
|
72
|
+
import { unmountAll } from "./ssh/sshfs-mount";
|
|
70
73
|
import {
|
|
71
74
|
buildSystemPrompt as buildSystemPromptInternal,
|
|
72
75
|
loadProjectContextFiles as loadContextFilesInternal,
|
|
@@ -83,6 +86,7 @@ import {
|
|
|
83
86
|
createGrepTool,
|
|
84
87
|
createLsTool,
|
|
85
88
|
createReadTool,
|
|
89
|
+
createSshTool,
|
|
86
90
|
createTools,
|
|
87
91
|
createWriteTool,
|
|
88
92
|
filterRulebookRules,
|
|
@@ -204,6 +208,7 @@ export {
|
|
|
204
208
|
// Individual tool factories (for custom usage)
|
|
205
209
|
createReadTool,
|
|
206
210
|
createBashTool,
|
|
211
|
+
createSshTool,
|
|
207
212
|
createEditTool,
|
|
208
213
|
createWriteTool,
|
|
209
214
|
createGrepTool,
|
|
@@ -399,6 +404,23 @@ function isCustomTool(tool: CustomTool | ToolDefinition): tool is CustomTool {
|
|
|
399
404
|
|
|
400
405
|
const TOOL_DEFINITION_MARKER = Symbol("__isToolDefinition");
|
|
401
406
|
|
|
407
|
+
let sshCleanupRegistered = false;
|
|
408
|
+
|
|
409
|
+
async function cleanupSshResources(): Promise<void> {
|
|
410
|
+
const results = await Promise.allSettled([closeAllConnections(), unmountAll()]);
|
|
411
|
+
for (const result of results) {
|
|
412
|
+
if (result.status === "rejected") {
|
|
413
|
+
logger.warn("SSH cleanup failed", { error: String(result.reason) });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function registerSshCleanup(): void {
|
|
419
|
+
if (sshCleanupRegistered) return;
|
|
420
|
+
sshCleanupRegistered = true;
|
|
421
|
+
registerAsyncCleanup(() => cleanupSshResources());
|
|
422
|
+
}
|
|
423
|
+
|
|
402
424
|
function customToolToDefinition(tool: CustomTool): ToolDefinition {
|
|
403
425
|
const definition: ToolDefinition & { [TOOL_DEFINITION_MARKER]: true } = {
|
|
404
426
|
name: tool.name,
|
|
@@ -471,7 +493,7 @@ function createCustomToolsExtension(tools: CustomTool[]): ExtensionFactory {
|
|
|
471
493
|
* const { session } = await createAgentSession();
|
|
472
494
|
*
|
|
473
495
|
* // With explicit model
|
|
474
|
-
* import { getModel } from '@
|
|
496
|
+
* import { getModel } from '@oh-my-pi/pi-ai';
|
|
475
497
|
* const { session } = await createAgentSession({
|
|
476
498
|
* model: getModel('anthropic', 'claude-opus-4-5'),
|
|
477
499
|
* thinkingLevel: 'high',
|
|
@@ -498,6 +520,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
498
520
|
const agentDir = options.agentDir ?? getDefaultAgentDir();
|
|
499
521
|
const eventBus = options.eventBus ?? createEventBus();
|
|
500
522
|
|
|
523
|
+
registerSshCleanup();
|
|
524
|
+
|
|
501
525
|
// Use provided or create AuthStorage and ModelRegistry
|
|
502
526
|
const authStorage = options.authStorage ?? (await discoverAuthStorage(agentDir));
|
|
503
527
|
const modelRegistry = options.modelRegistry ?? (await discoverModels(authStorage, agentDir));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { basename, join, resolve } from "node:path";
|
|
2
|
-
import type { ImageContent, Message, TextContent, Usage } from "@mariozechner/pi-ai";
|
|
3
2
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
3
|
+
import type { ImageContent, Message, TextContent, Usage } from "@oh-my-pi/pi-ai";
|
|
4
4
|
import { nanoid } from "nanoid";
|
|
5
5
|
import { getAgentDir as getDefaultAgentDir } from "../config";
|
|
6
6
|
import { resizeImage } from "../utils/image-resize";
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { CONFIG_DIR_NAME } from "../../config";
|
|
5
|
+
import { logger } from "../logger";
|
|
6
|
+
|
|
7
|
+
export interface SSHConnectionTarget {
|
|
8
|
+
name: string;
|
|
9
|
+
host: string;
|
|
10
|
+
username?: string;
|
|
11
|
+
port?: number;
|
|
12
|
+
keyPath?: string;
|
|
13
|
+
compat?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type SSHHostOs = "windows" | "linux" | "macos" | "unknown";
|
|
17
|
+
export type SSHHostShell = "cmd" | "powershell" | "bash" | "zsh" | "sh" | "unknown";
|
|
18
|
+
|
|
19
|
+
export interface SSHHostInfo {
|
|
20
|
+
version: number;
|
|
21
|
+
os: SSHHostOs;
|
|
22
|
+
shell: SSHHostShell;
|
|
23
|
+
compatShell?: "bash" | "sh";
|
|
24
|
+
compatEnabled: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CONTROL_DIR = join(homedir(), CONFIG_DIR_NAME, "ssh-control");
|
|
28
|
+
const CONTROL_PATH = join(CONTROL_DIR, "%h.sock");
|
|
29
|
+
const HOST_INFO_DIR = join(homedir(), CONFIG_DIR_NAME, "remote-host");
|
|
30
|
+
const HOST_INFO_VERSION = 2;
|
|
31
|
+
|
|
32
|
+
const activeHosts = new Map<string, SSHConnectionTarget>();
|
|
33
|
+
const pendingConnections = new Map<string, Promise<void>>();
|
|
34
|
+
const hostInfoCache = new Map<string, SSHHostInfo>();
|
|
35
|
+
|
|
36
|
+
function ensureControlDir(): void {
|
|
37
|
+
if (!existsSync(CONTROL_DIR)) {
|
|
38
|
+
mkdirSync(CONTROL_DIR, { recursive: true, mode: 0o700 });
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
chmodSync(CONTROL_DIR, 0o700);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
logger.debug("SSH control dir chmod failed", { path: CONTROL_DIR, error: String(err) });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function ensureHostInfoDir(): void {
|
|
48
|
+
if (!existsSync(HOST_INFO_DIR)) {
|
|
49
|
+
mkdirSync(HOST_INFO_DIR, { recursive: true, mode: 0o700 });
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
chmodSync(HOST_INFO_DIR, 0o700);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
logger.debug("SSH host info dir chmod failed", { path: HOST_INFO_DIR, error: String(err) });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sanitizeHostName(name: string): string {
|
|
59
|
+
const sanitized = name.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
60
|
+
return sanitized.length > 0 ? sanitized : "host";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getHostInfoPath(name: string): string {
|
|
64
|
+
return join(HOST_INFO_DIR, `${sanitizeHostName(name)}.json`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateKeyPermissions(keyPath?: string): void {
|
|
68
|
+
if (!keyPath) return;
|
|
69
|
+
if (!existsSync(keyPath)) {
|
|
70
|
+
throw new Error(`SSH key not found: ${keyPath}`);
|
|
71
|
+
}
|
|
72
|
+
const stats = statSync(keyPath);
|
|
73
|
+
if (!stats.isFile()) {
|
|
74
|
+
throw new Error(`SSH key is not a file: ${keyPath}`);
|
|
75
|
+
}
|
|
76
|
+
const mode = stats.mode & 0o777;
|
|
77
|
+
if ((mode & 0o077) !== 0) {
|
|
78
|
+
throw new Error(`SSH key permissions must be 600 or stricter: ${keyPath}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildSshTarget(host: SSHConnectionTarget): string {
|
|
83
|
+
return host.username ? `${host.username}@${host.host}` : host.host;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildCommonArgs(host: SSHConnectionTarget): string[] {
|
|
87
|
+
const args = [
|
|
88
|
+
"-o",
|
|
89
|
+
"ControlMaster=auto",
|
|
90
|
+
"-o",
|
|
91
|
+
`ControlPath=${CONTROL_PATH}`,
|
|
92
|
+
"-o",
|
|
93
|
+
"ControlPersist=3600",
|
|
94
|
+
"-o",
|
|
95
|
+
"BatchMode=yes",
|
|
96
|
+
"-o",
|
|
97
|
+
"StrictHostKeyChecking=accept-new",
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
if (host.port) {
|
|
101
|
+
args.push("-p", String(host.port));
|
|
102
|
+
}
|
|
103
|
+
if (host.keyPath) {
|
|
104
|
+
args.push("-i", host.keyPath);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return args;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function decodeOutput(buffer?: Uint8Array): string {
|
|
111
|
+
if (!buffer || buffer.length === 0) return "";
|
|
112
|
+
return new TextDecoder().decode(buffer).trim();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function runSshSync(args: string[]): { exitCode: number | null; stderr: string } {
|
|
116
|
+
const result = Bun.spawnSync(["ssh", ...args], {
|
|
117
|
+
stdin: "ignore",
|
|
118
|
+
stdout: "ignore",
|
|
119
|
+
stderr: "pipe",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return { exitCode: result.exitCode, stderr: decodeOutput(result.stderr) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function runSshCaptureSync(args: string[]): { exitCode: number | null; stdout: string; stderr: string } {
|
|
126
|
+
const result = Bun.spawnSync(["ssh", ...args], {
|
|
127
|
+
stdin: "ignore",
|
|
128
|
+
stdout: "pipe",
|
|
129
|
+
stderr: "pipe",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
exitCode: result.exitCode,
|
|
134
|
+
stdout: decodeOutput(result.stdout),
|
|
135
|
+
stderr: decodeOutput(result.stderr),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function ensureSshBinary(): void {
|
|
140
|
+
if (!Bun.which("ssh")) {
|
|
141
|
+
throw new Error("ssh binary not found on PATH");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseOs(value: unknown): SSHHostOs | null {
|
|
146
|
+
if (typeof value !== "string") return null;
|
|
147
|
+
const normalized = value.trim().toLowerCase();
|
|
148
|
+
switch (normalized) {
|
|
149
|
+
case "windows":
|
|
150
|
+
return "windows";
|
|
151
|
+
case "linux":
|
|
152
|
+
return "linux";
|
|
153
|
+
case "macos":
|
|
154
|
+
case "darwin":
|
|
155
|
+
return "macos";
|
|
156
|
+
case "unknown":
|
|
157
|
+
return "unknown";
|
|
158
|
+
default:
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseShell(value: unknown): SSHHostShell | null {
|
|
164
|
+
if (typeof value !== "string") return null;
|
|
165
|
+
const normalized = value.trim().toLowerCase();
|
|
166
|
+
if (!normalized) return "unknown";
|
|
167
|
+
if (normalized.includes("bash")) return "bash";
|
|
168
|
+
if (normalized.includes("zsh")) return "zsh";
|
|
169
|
+
if (normalized.includes("pwsh") || normalized.includes("powershell")) return "powershell";
|
|
170
|
+
if (normalized.includes("cmd.exe") || normalized === "cmd") return "cmd";
|
|
171
|
+
if (normalized.endsWith("sh") || normalized.includes("/sh")) return "sh";
|
|
172
|
+
return "unknown";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parseCompatShell(value: unknown): "bash" | "sh" | undefined {
|
|
176
|
+
if (value === "bash" || value === "sh") return value;
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function applyCompatOverride(host: SSHConnectionTarget, info: SSHHostInfo): SSHHostInfo {
|
|
181
|
+
const compatShell =
|
|
182
|
+
info.compatShell ??
|
|
183
|
+
(info.os === "windows" && info.shell === "bash"
|
|
184
|
+
? "bash"
|
|
185
|
+
: info.os === "windows" && info.shell === "sh"
|
|
186
|
+
? "sh"
|
|
187
|
+
: undefined);
|
|
188
|
+
const compatEnabled = host.compat === false ? false : info.os === "windows" && compatShell !== undefined;
|
|
189
|
+
if (host.compat === true && !compatShell) {
|
|
190
|
+
logger.warn("SSH compat requested but no compatible shell detected", {
|
|
191
|
+
host: host.name,
|
|
192
|
+
shell: info.shell,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return { ...info, version: info.version ?? 0, compatShell, compatEnabled };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function parseHostInfo(value: unknown): SSHHostInfo | null {
|
|
199
|
+
if (!value || typeof value !== "object") return null;
|
|
200
|
+
const record = value as Record<string, unknown>;
|
|
201
|
+
const os = parseOs(record.os) ?? "unknown";
|
|
202
|
+
const shell = parseShell(record.shell) ?? "unknown";
|
|
203
|
+
const compatShell = parseCompatShell(record.compatShell);
|
|
204
|
+
const compatEnabled = typeof record.compatEnabled === "boolean" ? record.compatEnabled : false;
|
|
205
|
+
const version = typeof record.version === "number" ? record.version : 0;
|
|
206
|
+
return {
|
|
207
|
+
version,
|
|
208
|
+
os,
|
|
209
|
+
shell,
|
|
210
|
+
compatShell,
|
|
211
|
+
compatEnabled,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function shouldRefreshHostInfo(host: SSHConnectionTarget, info: SSHHostInfo): boolean {
|
|
216
|
+
if (info.version !== HOST_INFO_VERSION) return true;
|
|
217
|
+
if (info.os === "unknown") return true;
|
|
218
|
+
if (info.os !== "windows" && info.compatEnabled) return true;
|
|
219
|
+
if (info.os === "windows" && info.compatEnabled && !info.compatShell) return true;
|
|
220
|
+
if (info.os === "windows" && info.compatShell === "bash" && info.shell === "unknown") return true;
|
|
221
|
+
if (host.compat === true && info.os === "windows" && !info.compatShell) return true;
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function loadHostInfoFromDisk(host: SSHConnectionTarget): SSHHostInfo | undefined {
|
|
226
|
+
const path = getHostInfoPath(host.name);
|
|
227
|
+
if (!existsSync(path)) return undefined;
|
|
228
|
+
try {
|
|
229
|
+
const raw = readFileSync(path, "utf-8");
|
|
230
|
+
const parsed = parseHostInfo(JSON.parse(raw));
|
|
231
|
+
if (!parsed) return undefined;
|
|
232
|
+
const resolved = applyCompatOverride(host, parsed);
|
|
233
|
+
hostInfoCache.set(host.name, resolved);
|
|
234
|
+
return resolved;
|
|
235
|
+
} catch (err) {
|
|
236
|
+
logger.warn("Failed to load SSH host info", { host: host.name, error: String(err) });
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function loadHostInfoFromDiskByName(hostName: string): SSHHostInfo | undefined {
|
|
242
|
+
const path = getHostInfoPath(hostName);
|
|
243
|
+
if (!existsSync(path)) return undefined;
|
|
244
|
+
try {
|
|
245
|
+
const raw = readFileSync(path, "utf-8");
|
|
246
|
+
const parsed = parseHostInfo(JSON.parse(raw));
|
|
247
|
+
if (!parsed) return undefined;
|
|
248
|
+
return parsed;
|
|
249
|
+
} catch (err) {
|
|
250
|
+
logger.warn("Failed to load SSH host info", { host: hostName, error: String(err) });
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function persistHostInfo(host: SSHConnectionTarget, info: SSHHostInfo): Promise<void> {
|
|
256
|
+
try {
|
|
257
|
+
ensureHostInfoDir();
|
|
258
|
+
const path = getHostInfoPath(host.name);
|
|
259
|
+
const payload = { ...info, version: HOST_INFO_VERSION };
|
|
260
|
+
hostInfoCache.set(host.name, payload);
|
|
261
|
+
await Bun.write(path, JSON.stringify(payload, null, 2), { createPath: true });
|
|
262
|
+
} catch (err) {
|
|
263
|
+
logger.warn("Failed to persist SSH host info", { host: host.name, error: String(err) });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function probeHostInfo(host: SSHConnectionTarget): Promise<SSHHostInfo> {
|
|
268
|
+
const command = 'echo "$OSTYPE|$SHELL|$BASH_VERSION" 2>/dev/null || echo "%OS%|%COMSPEC%|"';
|
|
269
|
+
const result = runSshCaptureSync(buildRemoteCommand(host, command));
|
|
270
|
+
if (result.exitCode !== 0 && !result.stdout) {
|
|
271
|
+
logger.debug("SSH host probe failed", { host: host.name, error: result.stderr });
|
|
272
|
+
const fallback: SSHHostInfo = {
|
|
273
|
+
version: HOST_INFO_VERSION,
|
|
274
|
+
os: "unknown",
|
|
275
|
+
shell: "unknown",
|
|
276
|
+
compatShell: undefined,
|
|
277
|
+
compatEnabled: false,
|
|
278
|
+
};
|
|
279
|
+
hostInfoCache.set(host.name, fallback);
|
|
280
|
+
return fallback;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const output = (result.stdout || result.stderr).split("\n")[0]?.trim() ?? "";
|
|
284
|
+
const [rawOs = "", rawShell = "", rawBash = ""] = output.split("|");
|
|
285
|
+
const ostype = rawOs.trim();
|
|
286
|
+
const shellRaw = rawShell.trim();
|
|
287
|
+
const bashVersion = rawBash.trim();
|
|
288
|
+
const outputLower = output.toLowerCase();
|
|
289
|
+
const osLower = ostype.toLowerCase();
|
|
290
|
+
const shellLower = shellRaw.toLowerCase();
|
|
291
|
+
const unexpandedPosixVars =
|
|
292
|
+
output.includes("$OSTYPE") || output.includes("$SHELL") || output.includes("$BASH_VERSION");
|
|
293
|
+
const windowsDetected =
|
|
294
|
+
osLower.includes("windows") ||
|
|
295
|
+
osLower.includes("msys") ||
|
|
296
|
+
osLower.includes("cygwin") ||
|
|
297
|
+
osLower.includes("mingw") ||
|
|
298
|
+
outputLower.includes("windows_nt") ||
|
|
299
|
+
outputLower.includes("comspec") ||
|
|
300
|
+
shellLower.includes("cmd") ||
|
|
301
|
+
shellLower.includes("powershell") ||
|
|
302
|
+
unexpandedPosixVars ||
|
|
303
|
+
output.includes("%OS%");
|
|
304
|
+
|
|
305
|
+
let os: SSHHostOs = "unknown";
|
|
306
|
+
if (windowsDetected) {
|
|
307
|
+
os = "windows";
|
|
308
|
+
} else if (osLower.includes("darwin")) {
|
|
309
|
+
os = "macos";
|
|
310
|
+
} else if (osLower.includes("linux") || osLower.includes("gnu")) {
|
|
311
|
+
os = "linux";
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let shell: SSHHostShell = "unknown";
|
|
315
|
+
if (shellLower.includes("bash")) {
|
|
316
|
+
shell = "bash";
|
|
317
|
+
} else if (shellLower.includes("zsh")) {
|
|
318
|
+
shell = "zsh";
|
|
319
|
+
} else if (shellLower.includes("pwsh") || shellLower.includes("powershell")) {
|
|
320
|
+
shell = "powershell";
|
|
321
|
+
} else if (shellLower.includes("cmd.exe") || shellLower === "cmd") {
|
|
322
|
+
shell = "cmd";
|
|
323
|
+
} else if (shellLower.endsWith("sh") || shellLower.includes("/sh")) {
|
|
324
|
+
shell = "sh";
|
|
325
|
+
} else if (os === "windows" && !shellLower) {
|
|
326
|
+
shell = "cmd";
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const hasBash = !unexpandedPosixVars && (Boolean(bashVersion) || shell === "bash");
|
|
330
|
+
let compatShell: SSHHostInfo["compatShell"];
|
|
331
|
+
if (os === "windows" && host.compat !== false) {
|
|
332
|
+
const bashProbe = runSshCaptureSync(buildRemoteCommand(host, 'bash -lc "echo OMP_BASH_OK"'));
|
|
333
|
+
if (bashProbe.exitCode === 0 && bashProbe.stdout.includes("OMP_BASH_OK")) {
|
|
334
|
+
compatShell = "bash";
|
|
335
|
+
} else {
|
|
336
|
+
const shProbe = runSshCaptureSync(buildRemoteCommand(host, 'sh -lc "echo OMP_SH_OK"'));
|
|
337
|
+
if (shProbe.exitCode === 0 && shProbe.stdout.includes("OMP_SH_OK")) {
|
|
338
|
+
compatShell = "sh";
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
} else if (os === "windows" && hasBash) {
|
|
342
|
+
compatShell = "bash";
|
|
343
|
+
} else if (os === "windows" && shell === "sh") {
|
|
344
|
+
compatShell = "sh";
|
|
345
|
+
}
|
|
346
|
+
const compatEnabled = host.compat === false ? false : os === "windows" && compatShell !== undefined;
|
|
347
|
+
|
|
348
|
+
const info: SSHHostInfo = applyCompatOverride(host, {
|
|
349
|
+
version: HOST_INFO_VERSION,
|
|
350
|
+
os,
|
|
351
|
+
shell,
|
|
352
|
+
compatShell,
|
|
353
|
+
compatEnabled,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
hostInfoCache.set(host.name, info);
|
|
357
|
+
await persistHostInfo(host, info);
|
|
358
|
+
return info;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function getHostInfo(hostName: string): SSHHostInfo | undefined {
|
|
362
|
+
return hostInfoCache.get(hostName) ?? loadHostInfoFromDiskByName(hostName);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function getHostInfoForHost(host: SSHConnectionTarget): SSHHostInfo | undefined {
|
|
366
|
+
const cached = hostInfoCache.get(host.name);
|
|
367
|
+
if (cached) {
|
|
368
|
+
const resolved = applyCompatOverride(host, cached);
|
|
369
|
+
if (resolved !== cached) hostInfoCache.set(host.name, resolved);
|
|
370
|
+
return resolved;
|
|
371
|
+
}
|
|
372
|
+
return loadHostInfoFromDisk(host);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function ensureHostInfo(host: SSHConnectionTarget): Promise<SSHHostInfo> {
|
|
376
|
+
const cached = hostInfoCache.get(host.name);
|
|
377
|
+
if (cached) {
|
|
378
|
+
const resolved = applyCompatOverride(host, cached);
|
|
379
|
+
hostInfoCache.set(host.name, resolved);
|
|
380
|
+
if (!shouldRefreshHostInfo(host, resolved)) return resolved;
|
|
381
|
+
}
|
|
382
|
+
const fromDisk = loadHostInfoFromDisk(host);
|
|
383
|
+
if (fromDisk && !shouldRefreshHostInfo(host, fromDisk)) return fromDisk;
|
|
384
|
+
await ensureConnection(host);
|
|
385
|
+
const current = hostInfoCache.get(host.name);
|
|
386
|
+
if (current && !shouldRefreshHostInfo(host, current)) return current;
|
|
387
|
+
return probeHostInfo(host);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function buildRemoteCommand(host: SSHConnectionTarget, command: string): string[] {
|
|
391
|
+
validateKeyPermissions(host.keyPath);
|
|
392
|
+
return [...buildCommonArgs(host), buildSshTarget(host), command];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export async function ensureConnection(host: SSHConnectionTarget): Promise<void> {
|
|
396
|
+
const key = host.name;
|
|
397
|
+
const pending = pendingConnections.get(key);
|
|
398
|
+
if (pending) {
|
|
399
|
+
await pending;
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const promise = (async () => {
|
|
404
|
+
ensureSshBinary();
|
|
405
|
+
ensureControlDir();
|
|
406
|
+
validateKeyPermissions(host.keyPath);
|
|
407
|
+
|
|
408
|
+
const target = buildSshTarget(host);
|
|
409
|
+
const check = runSshSync(["-O", "check", ...buildCommonArgs(host), target]);
|
|
410
|
+
if (check.exitCode === 0) {
|
|
411
|
+
activeHosts.set(key, host);
|
|
412
|
+
if (!hostInfoCache.has(key) && !loadHostInfoFromDisk(host)) {
|
|
413
|
+
await probeHostInfo(host);
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const start = runSshSync(["-M", "-N", "-f", ...buildCommonArgs(host), target]);
|
|
419
|
+
if (start.exitCode !== 0) {
|
|
420
|
+
const detail = start.stderr ? `: ${start.stderr}` : "";
|
|
421
|
+
throw new Error(`Failed to start SSH master for ${target}${detail}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
activeHosts.set(key, host);
|
|
425
|
+
if (!hostInfoCache.has(key) && !loadHostInfoFromDisk(host)) {
|
|
426
|
+
await probeHostInfo(host);
|
|
427
|
+
}
|
|
428
|
+
})();
|
|
429
|
+
|
|
430
|
+
pendingConnections.set(key, promise);
|
|
431
|
+
try {
|
|
432
|
+
await promise;
|
|
433
|
+
} finally {
|
|
434
|
+
pendingConnections.delete(key);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function closeConnectionInternal(host: SSHConnectionTarget): void {
|
|
439
|
+
const target = buildSshTarget(host);
|
|
440
|
+
runSshSync(["-O", "exit", ...buildCommonArgs(host), target]);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export async function closeConnection(hostName: string): Promise<void> {
|
|
444
|
+
const host = activeHosts.get(hostName);
|
|
445
|
+
if (!host) {
|
|
446
|
+
closeConnectionInternal({ name: hostName, host: hostName });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
closeConnectionInternal(host);
|
|
450
|
+
activeHosts.delete(hostName);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export async function closeAllConnections(): Promise<void> {
|
|
454
|
+
for (const [name, host] of Array.from(activeHosts.entries())) {
|
|
455
|
+
closeConnectionInternal(host);
|
|
456
|
+
activeHosts.delete(name);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function getControlPathTemplate(): string {
|
|
461
|
+
return CONTROL_PATH;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function getControlDir(): string {
|
|
465
|
+
return CONTROL_DIR;
|
|
466
|
+
}
|