@oh-my-pi/pi-coding-agent 3.33.0 → 3.35.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 +57 -8
- 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 +214 -31
- 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 +33 -4
- package/src/core/session-manager.ts +16 -1
- package/src/core/settings-manager.ts +20 -6
- 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/edit.ts +1 -0
- package/src/core/tools/grep.ts +1 -1
- package/src/core/tools/index.test.ts +1 -0
- package/src/core/tools/index.ts +5 -0
- package/src/core/tools/output.ts +1 -1
- package/src/core/tools/read.ts +24 -11
- package/src/core/tools/renderers.ts +3 -0
- package/src/core/tools/ssh.ts +302 -0
- package/src/core/tools/task/index.ts +11 -2
- package/src/core/tools/task/model-resolver.ts +5 -4
- 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 +4 -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/components/tool-execution.ts +15 -12
- package/src/modes/interactive/interactive-mode.ts +43 -9
- 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/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));
|
|
@@ -609,6 +633,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
609
633
|
const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
|
|
610
634
|
time("discoverContextFiles");
|
|
611
635
|
|
|
636
|
+
let agent: Agent;
|
|
637
|
+
let session: AgentSession;
|
|
638
|
+
|
|
612
639
|
const toolSession: ToolSession = {
|
|
613
640
|
cwd,
|
|
614
641
|
hasUI: options.hasUI ?? false,
|
|
@@ -619,6 +646,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
619
646
|
getSessionFile: () => sessionManager.getSessionFile() ?? null,
|
|
620
647
|
getSessionSpawns: () => options.spawns ?? "*",
|
|
621
648
|
getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
|
|
649
|
+
getActiveModelString: () => {
|
|
650
|
+
const activeModel = agent?.state.model;
|
|
651
|
+
return activeModel ? formatModelString(activeModel) : undefined;
|
|
652
|
+
},
|
|
622
653
|
settings: settingsManager,
|
|
623
654
|
};
|
|
624
655
|
|
|
@@ -758,8 +789,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
758
789
|
extensionRunner = new ExtensionRunner(extensionsResult.extensions, cwd, sessionManager, modelRegistry);
|
|
759
790
|
}
|
|
760
791
|
|
|
761
|
-
let agent: Agent;
|
|
762
|
-
let session: AgentSession;
|
|
763
792
|
const getSessionContext = () => ({
|
|
764
793
|
sessionManager,
|
|
765
794
|
modelRegistry,
|
|
@@ -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";
|
|
@@ -1338,6 +1338,21 @@ export class SessionManager {
|
|
|
1338
1338
|
return this.leafId ? this.byId.get(this.leafId) : undefined;
|
|
1339
1339
|
}
|
|
1340
1340
|
|
|
1341
|
+
/**
|
|
1342
|
+
* Get the most recent model role from the current session path.
|
|
1343
|
+
* Returns undefined if no model change has been recorded.
|
|
1344
|
+
*/
|
|
1345
|
+
getLastModelChangeRole(): string | undefined {
|
|
1346
|
+
let current = this.getLeafEntry();
|
|
1347
|
+
while (current) {
|
|
1348
|
+
if (current.type === "model_change") {
|
|
1349
|
+
return current.role ?? "default";
|
|
1350
|
+
}
|
|
1351
|
+
current = current.parentId ? this.byId.get(current.parentId) : undefined;
|
|
1352
|
+
}
|
|
1353
|
+
return undefined;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1341
1356
|
getEntry(id: string): SessionEntry | undefined {
|
|
1342
1357
|
return this.byId.get(id);
|
|
1343
1358
|
}
|
|
@@ -371,7 +371,8 @@ export class SettingsManager {
|
|
|
371
371
|
private settingsPath: string | null;
|
|
372
372
|
private cwd: string | null;
|
|
373
373
|
private globalSettings: Settings;
|
|
374
|
-
private
|
|
374
|
+
private overrides: Settings;
|
|
375
|
+
private settings!: Settings;
|
|
375
376
|
private persist: boolean;
|
|
376
377
|
|
|
377
378
|
private constructor(settingsPath: string | null, cwd: string | null, initialSettings: Settings, persist: boolean) {
|
|
@@ -379,8 +380,8 @@ export class SettingsManager {
|
|
|
379
380
|
this.cwd = cwd;
|
|
380
381
|
this.persist = persist;
|
|
381
382
|
this.globalSettings = initialSettings;
|
|
382
|
-
|
|
383
|
-
this.
|
|
383
|
+
this.overrides = {};
|
|
384
|
+
this.rebuildSettings();
|
|
384
385
|
|
|
385
386
|
// Apply environment variables from settings
|
|
386
387
|
this.applyEnvironmentVariables();
|
|
@@ -474,9 +475,17 @@ export class SettingsManager {
|
|
|
474
475
|
return SettingsManager.migrateSettings(merged as Record<string, unknown>);
|
|
475
476
|
}
|
|
476
477
|
|
|
478
|
+
private rebuildSettings(projectSettings?: Settings): void {
|
|
479
|
+
const resolvedProjectSettings = projectSettings ?? this.loadProjectSettings();
|
|
480
|
+
this.settings = normalizeSettings(
|
|
481
|
+
deepMergeSettings(deepMergeSettings(this.globalSettings, resolvedProjectSettings), this.overrides),
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
477
485
|
/** Apply additional overrides on top of current settings */
|
|
478
486
|
applyOverrides(overrides: Partial<Settings>): void {
|
|
479
|
-
this.
|
|
487
|
+
this.overrides = deepMergeSettings(this.overrides, overrides);
|
|
488
|
+
this.rebuildSettings();
|
|
480
489
|
}
|
|
481
490
|
|
|
482
491
|
private save(): void {
|
|
@@ -491,9 +500,9 @@ export class SettingsManager {
|
|
|
491
500
|
// Save only global settings (project settings are read-only)
|
|
492
501
|
writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8");
|
|
493
502
|
|
|
494
|
-
// Re-merge project settings into active settings
|
|
503
|
+
// Re-merge project settings into active settings (preserve overrides)
|
|
495
504
|
const projectSettings = this.loadProjectSettings();
|
|
496
|
-
this.
|
|
505
|
+
this.rebuildSettings(projectSettings);
|
|
497
506
|
} catch (error) {
|
|
498
507
|
console.error(`Warning: Could not save settings file: ${error}`);
|
|
499
508
|
}
|
|
@@ -523,6 +532,11 @@ export class SettingsManager {
|
|
|
523
532
|
this.globalSettings.modelRoles = {};
|
|
524
533
|
}
|
|
525
534
|
this.globalSettings.modelRoles[role] = model;
|
|
535
|
+
|
|
536
|
+
if (this.overrides.modelRoles && this.overrides.modelRoles[role] !== undefined) {
|
|
537
|
+
this.overrides.modelRoles[role] = model;
|
|
538
|
+
}
|
|
539
|
+
|
|
526
540
|
this.save();
|
|
527
541
|
}
|
|
528
542
|
|
|
@@ -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
|
+
}
|