@kontextmind/kxm 0.7.5 → 0.7.7
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/.claude-plugin/marketplace.json +1 -1
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +43485 -42676
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime.js +693 -32
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/cli/context-skills.ts +373 -0
- package/plugins/kxm/src/cli/hub.ts +614 -0
- package/plugins/kxm/src/cli/roles.ts +615 -0
- package/plugins/kxm/src/cli/system.ts +906 -0
- package/plugins/kxm/src/cli/tasks.ts +364 -0
- package/plugins/kxm/src/cli/types.ts +270 -0
- package/plugins/kxm/src/cli/vnext.ts +698 -0
- package/plugins/kxm/src/cli/workflows.ts +699 -0
- package/plugins/kxm/src/cli.ts +206 -4257
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/modes.ts +348 -0
- package/plugins/kxm/src/runtime.ts +2 -0
- package/plugins/kxm/src/ssh-remote.ts +560 -0
- package/schemas/vnext/modes.schema.json +56 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multiplexed remote SSH execution, worker orchestration, and secure config discovery.
|
|
3
|
+
*
|
|
4
|
+
* Implements:
|
|
5
|
+
* - Safe `~/.ssh/config` parsing and alias discovery without socket connections or credential leaks.
|
|
6
|
+
* - OpenSSH `ControlMaster` connection reuse (.kxm/run/ssh-sockets/%C) for low-latency command execution.
|
|
7
|
+
* - In-memory masked credential passing via `sshpass -e` (env) and `sudo -S -p ''` (stdin).
|
|
8
|
+
* - Safety seatbelt validation and pinned host key verification.
|
|
9
|
+
* - Output bounding and truncation (50KB / 2000 lines).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from "node:child_process";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
import { assertCommandSeatbelt, assertPinnedSshHostKeyPolicy } from "./safety-integrity.ts";
|
|
17
|
+
|
|
18
|
+
export const MAX_SSH_OUTPUT_BYTES = 50 * 1024; // 50KB
|
|
19
|
+
export const MAX_SSH_OUTPUT_LINES = 2000;
|
|
20
|
+
export const DEFAULT_SOCKET_DIR = ".kxm/run/ssh-sockets";
|
|
21
|
+
export const DEFAULT_CONTROL_PERSIST = "10m";
|
|
22
|
+
|
|
23
|
+
export interface SshHostInfo {
|
|
24
|
+
alias: string;
|
|
25
|
+
hostName?: string | undefined;
|
|
26
|
+
user?: string | undefined;
|
|
27
|
+
port?: number | undefined;
|
|
28
|
+
proxyJump?: string | undefined;
|
|
29
|
+
identityFiles?: string[] | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SshRunParams {
|
|
33
|
+
action: "info" | "command" | "file";
|
|
34
|
+
host?: string | undefined;
|
|
35
|
+
command?: string | undefined;
|
|
36
|
+
sudo?: boolean | undefined;
|
|
37
|
+
file_path?: string | undefined;
|
|
38
|
+
file_content?: string | undefined;
|
|
39
|
+
file_op?: "write" | "read" | "append" | undefined;
|
|
40
|
+
reason?: string | undefined;
|
|
41
|
+
password?: string | undefined;
|
|
42
|
+
sudo_password?: string | undefined;
|
|
43
|
+
socket_dir?: string | undefined;
|
|
44
|
+
control_persist?: string | undefined;
|
|
45
|
+
timeout_ms?: number | undefined;
|
|
46
|
+
strict_host_key?: boolean | undefined;
|
|
47
|
+
execFn?: ((cmd: string, args: string[], options: SpawnSyncOptionsWithStringEncoding) => {
|
|
48
|
+
status: number | null;
|
|
49
|
+
stdout: string | Buffer;
|
|
50
|
+
stderr: string | Buffer;
|
|
51
|
+
error?: Error | undefined;
|
|
52
|
+
}) | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SshExecutionReceipt {
|
|
56
|
+
ok: boolean;
|
|
57
|
+
action: "info" | "command" | "file";
|
|
58
|
+
host?: string | undefined;
|
|
59
|
+
resolvedHost?: string | undefined;
|
|
60
|
+
user?: string | undefined;
|
|
61
|
+
port?: number | undefined;
|
|
62
|
+
exitCode?: number | undefined;
|
|
63
|
+
stdout?: string | undefined;
|
|
64
|
+
stderr?: string | undefined;
|
|
65
|
+
durationMs?: number | undefined;
|
|
66
|
+
truncated?: boolean | undefined;
|
|
67
|
+
socketReused?: boolean | undefined;
|
|
68
|
+
fileProcessed?: string | undefined;
|
|
69
|
+
bytesProcessed?: number | undefined;
|
|
70
|
+
hosts?: SshHostInfo[] | undefined;
|
|
71
|
+
error?: string | undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Truncates output to 50KB and 2000 lines max.
|
|
76
|
+
*/
|
|
77
|
+
export function truncateSshOutput(raw: string): { text: string; truncated: boolean } {
|
|
78
|
+
let text = raw;
|
|
79
|
+
let truncated = false;
|
|
80
|
+
|
|
81
|
+
if (Buffer.byteLength(text, "utf-8") > MAX_SSH_OUTPUT_BYTES) {
|
|
82
|
+
const buf = Buffer.from(text, "utf-8");
|
|
83
|
+
text = buf.subarray(0, MAX_SSH_OUTPUT_BYTES).toString("utf-8");
|
|
84
|
+
truncated = true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const lines = text.split("\n");
|
|
88
|
+
if (lines.length > MAX_SSH_OUTPUT_LINES) {
|
|
89
|
+
text = lines.slice(0, MAX_SSH_OUTPUT_LINES).join("\n");
|
|
90
|
+
truncated = true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (truncated) {
|
|
94
|
+
text += `\n\n[kxm: ssh output truncated to ${MAX_SSH_OUTPUT_BYTES / 1024}KB / ${MAX_SSH_OUTPUT_LINES} lines]`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { text, truncated };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parses ~/.ssh/config safely to list host aliases and their configurations.
|
|
102
|
+
* Does not read private key files or open network sockets.
|
|
103
|
+
*/
|
|
104
|
+
export function parseSshConfig(configPath?: string): SshHostInfo[] {
|
|
105
|
+
const targetPath = configPath ?? join(homedir(), ".ssh", "config");
|
|
106
|
+
if (!existsSync(targetPath)) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const content = readFileSync(targetPath, "utf-8");
|
|
112
|
+
const lines = content.split("\n");
|
|
113
|
+
const hosts: SshHostInfo[] = [];
|
|
114
|
+
let currentHosts: SshHostInfo[] = [];
|
|
115
|
+
|
|
116
|
+
for (const rawLine of lines) {
|
|
117
|
+
const line = rawLine.trim();
|
|
118
|
+
if (!line || line.startsWith("#")) continue;
|
|
119
|
+
|
|
120
|
+
const spaceIdx = line.search(/\s/);
|
|
121
|
+
if (spaceIdx === -1) continue;
|
|
122
|
+
|
|
123
|
+
const key = line.slice(0, spaceIdx).trim().toLowerCase();
|
|
124
|
+
const value = line.slice(spaceIdx + 1).trim();
|
|
125
|
+
|
|
126
|
+
if (key === "host") {
|
|
127
|
+
// Space-separated host patterns; skip wildcard-only matches like "*"
|
|
128
|
+
const aliases = value.split(/\s+/).filter((a) => a !== "*");
|
|
129
|
+
currentHosts = [];
|
|
130
|
+
for (const alias of aliases) {
|
|
131
|
+
const entry: SshHostInfo = { alias, identityFiles: [] };
|
|
132
|
+
currentHosts.push(entry);
|
|
133
|
+
hosts.push(entry);
|
|
134
|
+
}
|
|
135
|
+
} else if (currentHosts.length > 0) {
|
|
136
|
+
for (const currentHost of currentHosts) {
|
|
137
|
+
if (key === "hostname") {
|
|
138
|
+
currentHost.hostName = value;
|
|
139
|
+
} else if (key === "user") {
|
|
140
|
+
currentHost.user = value;
|
|
141
|
+
} else if (key === "port") {
|
|
142
|
+
const p = Number.parseInt(value, 10);
|
|
143
|
+
if (!Number.isNaN(p)) currentHost.port = p;
|
|
144
|
+
} else if (key === "proxyjump") {
|
|
145
|
+
currentHost.proxyJump = value;
|
|
146
|
+
} else if (key === "identityfile") {
|
|
147
|
+
currentHost.identityFiles = currentHost.identityFiles ?? [];
|
|
148
|
+
currentHost.identityFiles.push(value);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return hosts;
|
|
155
|
+
} catch {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolves effective SSH parameters for a specific host using `ssh -G <host>`.
|
|
162
|
+
* Does not initiate any network connections.
|
|
163
|
+
*/
|
|
164
|
+
export function resolveSshHostG(
|
|
165
|
+
host: string,
|
|
166
|
+
execFn: typeof spawnSync = spawnSync,
|
|
167
|
+
): SshHostInfo {
|
|
168
|
+
try {
|
|
169
|
+
const result = execFn("ssh", ["-G", host], { encoding: "utf-8" });
|
|
170
|
+
if (result.status !== 0 || !result.stdout) {
|
|
171
|
+
return { alias: host, hostName: host };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const lines = String(result.stdout).split("\n");
|
|
175
|
+
const info: SshHostInfo = { alias: host, identityFiles: [] };
|
|
176
|
+
|
|
177
|
+
for (const rawLine of lines) {
|
|
178
|
+
const line = rawLine.trim();
|
|
179
|
+
if (!line) continue;
|
|
180
|
+
const spaceIdx = line.indexOf(" ");
|
|
181
|
+
if (spaceIdx === -1) continue;
|
|
182
|
+
|
|
183
|
+
const key = line.slice(0, spaceIdx).toLowerCase();
|
|
184
|
+
const value = line.slice(spaceIdx + 1).trim();
|
|
185
|
+
|
|
186
|
+
if (key === "hostname") {
|
|
187
|
+
info.hostName = value;
|
|
188
|
+
} else if (key === "user") {
|
|
189
|
+
info.user = value;
|
|
190
|
+
} else if (key === "port") {
|
|
191
|
+
const p = Number.parseInt(value, 10);
|
|
192
|
+
if (!Number.isNaN(p)) info.port = p;
|
|
193
|
+
} else if (key === "proxyjump") {
|
|
194
|
+
if (value && value !== "none") info.proxyJump = value;
|
|
195
|
+
} else if (key === "identityfile") {
|
|
196
|
+
info.identityFiles = info.identityFiles ?? [];
|
|
197
|
+
info.identityFiles.push(value);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return info;
|
|
202
|
+
} catch {
|
|
203
|
+
return { alias: host, hostName: host };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Ensures control socket directory exists with secure permissions (0700).
|
|
209
|
+
*/
|
|
210
|
+
export function ensureSocketDir(socketDir: string = DEFAULT_SOCKET_DIR): string {
|
|
211
|
+
const resolved = resolve(socketDir);
|
|
212
|
+
if (!existsSync(resolved)) {
|
|
213
|
+
mkdirSync(resolved, { recursive: true, mode: 0o700 });
|
|
214
|
+
}
|
|
215
|
+
return resolved;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Formats standard OpenSSH arguments including ControlMaster multiplexing
|
|
220
|
+
* and safety invariants.
|
|
221
|
+
*/
|
|
222
|
+
export function buildSshArgs(options: {
|
|
223
|
+
host: string;
|
|
224
|
+
socketDir?: string | undefined;
|
|
225
|
+
controlPersist?: string | undefined;
|
|
226
|
+
batchMode?: boolean | undefined;
|
|
227
|
+
strictHostKey?: boolean | undefined;
|
|
228
|
+
extraArgs?: string[] | undefined;
|
|
229
|
+
}): string[] {
|
|
230
|
+
const socketDir = ensureSocketDir(options.socketDir ?? DEFAULT_SOCKET_DIR);
|
|
231
|
+
const controlPath = join(socketDir, "%C");
|
|
232
|
+
const persist = options.controlPersist ?? DEFAULT_CONTROL_PERSIST;
|
|
233
|
+
|
|
234
|
+
const args: string[] = [
|
|
235
|
+
"-o", `ControlMaster=auto`,
|
|
236
|
+
"-o", `ControlPath=${controlPath}`,
|
|
237
|
+
"-o", `ControlPersist=${persist}`,
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
if (options.batchMode !== false) {
|
|
241
|
+
args.push("-o", "BatchMode=yes");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (options.strictHostKey !== false) {
|
|
245
|
+
args.push("-o", "StrictHostKeyChecking=yes");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (options.extraArgs) {
|
|
249
|
+
args.push(...options.extraArgs);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Validate that no insecure host key flags are present
|
|
253
|
+
assertPinnedSshHostKeyPolicy(args);
|
|
254
|
+
|
|
255
|
+
args.push(options.host);
|
|
256
|
+
return args;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Checks if a ControlMaster socket is active for the target host.
|
|
261
|
+
*/
|
|
262
|
+
export function checkControlSocket(
|
|
263
|
+
host: string,
|
|
264
|
+
socketDir: string = DEFAULT_SOCKET_DIR,
|
|
265
|
+
execFn: typeof spawnSync = spawnSync,
|
|
266
|
+
): boolean {
|
|
267
|
+
const resolvedDir = ensureSocketDir(socketDir);
|
|
268
|
+
const controlPath = join(resolvedDir, "%C");
|
|
269
|
+
try {
|
|
270
|
+
const result = execFn("ssh", ["-O", "check", "-o", `ControlPath=${controlPath}`, host], {
|
|
271
|
+
encoding: "utf-8",
|
|
272
|
+
});
|
|
273
|
+
return result.status === 0;
|
|
274
|
+
} catch {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Closes an active ControlMaster socket for the target host.
|
|
281
|
+
*/
|
|
282
|
+
export function closeControlSocket(
|
|
283
|
+
host: string,
|
|
284
|
+
socketDir: string = DEFAULT_SOCKET_DIR,
|
|
285
|
+
execFn: typeof spawnSync = spawnSync,
|
|
286
|
+
): boolean {
|
|
287
|
+
const resolvedDir = ensureSocketDir(socketDir);
|
|
288
|
+
const controlPath = join(resolvedDir, "%C");
|
|
289
|
+
try {
|
|
290
|
+
const result = execFn("ssh", ["-O", "stop", "-o", `ControlPath=${controlPath}`, host], {
|
|
291
|
+
encoding: "utf-8",
|
|
292
|
+
});
|
|
293
|
+
return result.status === 0;
|
|
294
|
+
} catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Cleans up all orphaned or expired ControlMaster sockets in the socket directory.
|
|
301
|
+
*/
|
|
302
|
+
export function pruneSocketDir(socketDir: string = DEFAULT_SOCKET_DIR): number {
|
|
303
|
+
const resolvedDir = ensureSocketDir(socketDir);
|
|
304
|
+
let removed = 0;
|
|
305
|
+
try {
|
|
306
|
+
const entries = readdirSync(resolvedDir);
|
|
307
|
+
for (const entry of entries) {
|
|
308
|
+
const fullPath = join(resolvedDir, entry);
|
|
309
|
+
try {
|
|
310
|
+
const stat = statSync(fullPath);
|
|
311
|
+
if (stat.isSocket()) {
|
|
312
|
+
rmSync(fullPath, { force: true });
|
|
313
|
+
removed++;
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
// Skip unreadable entries
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} catch {
|
|
320
|
+
// Ignore directory read errors
|
|
321
|
+
}
|
|
322
|
+
return removed;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Main execution handler for `ssh_run` tool.
|
|
327
|
+
*/
|
|
328
|
+
export function executeSshRun(params: SshRunParams): SshExecutionReceipt {
|
|
329
|
+
const startTime = Date.now();
|
|
330
|
+
const execSyncFn = params.execFn ?? spawnSync;
|
|
331
|
+
|
|
332
|
+
// 1. Action: "info"
|
|
333
|
+
if (params.action === "info") {
|
|
334
|
+
if (params.host) {
|
|
335
|
+
const hostInfo = resolveSshHostG(params.host, execSyncFn as typeof spawnSync);
|
|
336
|
+
return {
|
|
337
|
+
ok: true,
|
|
338
|
+
action: "info",
|
|
339
|
+
host: params.host,
|
|
340
|
+
resolvedHost: hostInfo.hostName ?? params.host,
|
|
341
|
+
user: hostInfo.user,
|
|
342
|
+
port: hostInfo.port,
|
|
343
|
+
hosts: [hostInfo],
|
|
344
|
+
durationMs: Date.now() - startTime,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const discovered = parseSshConfig();
|
|
349
|
+
return {
|
|
350
|
+
ok: true,
|
|
351
|
+
action: "info",
|
|
352
|
+
hosts: discovered,
|
|
353
|
+
durationMs: Date.now() - startTime,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Require host for command and file actions
|
|
358
|
+
if (!params.host) {
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
action: params.action,
|
|
362
|
+
error: 'Parameter "host" is required for command and file actions.',
|
|
363
|
+
durationMs: Date.now() - startTime,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const host = params.host;
|
|
368
|
+
const socketDir = params.socket_dir ?? DEFAULT_SOCKET_DIR;
|
|
369
|
+
const wasSocketActive = checkControlSocket(host, socketDir, execSyncFn as typeof spawnSync);
|
|
370
|
+
|
|
371
|
+
// 2. Action: "command"
|
|
372
|
+
if (params.action === "command") {
|
|
373
|
+
if (!params.command) {
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
action: "command",
|
|
377
|
+
host,
|
|
378
|
+
error: 'Parameter "command" is required for command action.',
|
|
379
|
+
durationMs: Date.now() - startTime,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Safety check: block destructive commands
|
|
384
|
+
try {
|
|
385
|
+
assertCommandSeatbelt(params.command);
|
|
386
|
+
} catch (err: unknown) {
|
|
387
|
+
return {
|
|
388
|
+
ok: false,
|
|
389
|
+
action: "command",
|
|
390
|
+
host,
|
|
391
|
+
error: (err as Error).message,
|
|
392
|
+
durationMs: Date.now() - startTime,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
let remoteCommand = params.command;
|
|
397
|
+
let inputStdin: string | undefined = undefined;
|
|
398
|
+
|
|
399
|
+
// Handle remote sudo via stdin pipe
|
|
400
|
+
if (params.sudo) {
|
|
401
|
+
const sudoPwd = params.sudo_password ?? params.password;
|
|
402
|
+
if (sudoPwd) {
|
|
403
|
+
inputStdin = `${sudoPwd}\n`;
|
|
404
|
+
remoteCommand = `sudo -S -p '' -- sh -c ${JSON.stringify(params.command)}`;
|
|
405
|
+
} else {
|
|
406
|
+
remoteCommand = `sudo -- sh -c ${JSON.stringify(params.command)}`;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const useSshPass = Boolean(params.password);
|
|
411
|
+
const sshArgs = buildSshArgs({
|
|
412
|
+
host,
|
|
413
|
+
socketDir,
|
|
414
|
+
controlPersist: params.control_persist,
|
|
415
|
+
batchMode: !useSshPass,
|
|
416
|
+
strictHostKey: params.strict_host_key,
|
|
417
|
+
});
|
|
418
|
+
sshArgs.push("--", remoteCommand);
|
|
419
|
+
|
|
420
|
+
const spawnEnv: NodeJS.ProcessEnv = { ...process.env };
|
|
421
|
+
let binary = "ssh";
|
|
422
|
+
let finalArgs = sshArgs;
|
|
423
|
+
|
|
424
|
+
// Masked credential security: SSH password via SSHPASS environment variable
|
|
425
|
+
if (useSshPass && params.password) {
|
|
426
|
+
binary = "sshpass";
|
|
427
|
+
finalArgs = ["-e", "ssh", ...sshArgs];
|
|
428
|
+
spawnEnv.SSHPASS = params.password;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
const result = execSyncFn(binary, finalArgs, {
|
|
433
|
+
encoding: "utf-8",
|
|
434
|
+
env: spawnEnv,
|
|
435
|
+
input: inputStdin,
|
|
436
|
+
timeout: params.timeout_ms ?? 120_000,
|
|
437
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const stdoutRaw = String(result.stdout || "");
|
|
441
|
+
const stderrRaw = String(result.stderr || "");
|
|
442
|
+
|
|
443
|
+
// Sanitize stderr from potential sudo password echoes
|
|
444
|
+
const sanitizedStderr = stderrRaw.replace(/\[sudo\] password for [^:]+:\s*/gi, "");
|
|
445
|
+
|
|
446
|
+
const stdoutTrunc = truncateSshOutput(stdoutRaw);
|
|
447
|
+
const stderrTrunc = truncateSshOutput(sanitizedStderr);
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
ok: result.status === 0,
|
|
451
|
+
action: "command",
|
|
452
|
+
host,
|
|
453
|
+
exitCode: result.status ?? 1,
|
|
454
|
+
stdout: stdoutTrunc.text,
|
|
455
|
+
stderr: stderrTrunc.text,
|
|
456
|
+
truncated: stdoutTrunc.truncated || stderrTrunc.truncated,
|
|
457
|
+
socketReused: wasSocketActive,
|
|
458
|
+
durationMs: Date.now() - startTime,
|
|
459
|
+
error: result.error ? result.error.message : undefined,
|
|
460
|
+
};
|
|
461
|
+
} catch (err: unknown) {
|
|
462
|
+
return {
|
|
463
|
+
ok: false,
|
|
464
|
+
action: "command",
|
|
465
|
+
host,
|
|
466
|
+
error: (err as Error).message,
|
|
467
|
+
durationMs: Date.now() - startTime,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// 3. Action: "file"
|
|
473
|
+
if (params.action === "file") {
|
|
474
|
+
if (!params.file_path) {
|
|
475
|
+
return {
|
|
476
|
+
ok: false,
|
|
477
|
+
action: "file",
|
|
478
|
+
host,
|
|
479
|
+
error: 'Parameter "file_path" is required for file action.',
|
|
480
|
+
durationMs: Date.now() - startTime,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const filePath = params.file_path;
|
|
485
|
+
const op = params.file_op ?? "write";
|
|
486
|
+
|
|
487
|
+
if (op === "read") {
|
|
488
|
+
const readCmd = params.sudo ? `sudo cat ${JSON.stringify(filePath)}` : `cat ${JSON.stringify(filePath)}`;
|
|
489
|
+
return executeSshRun({
|
|
490
|
+
...params,
|
|
491
|
+
action: "command",
|
|
492
|
+
command: readCmd,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const content = params.file_content ?? "";
|
|
497
|
+
const redirectOp = op === "append" ? ">>" : ">";
|
|
498
|
+
const writeCmd = params.sudo
|
|
499
|
+
? `sudo tee ${op === "append" ? "-a " : ""}${JSON.stringify(filePath)} > /dev/null`
|
|
500
|
+
: `cat ${redirectOp} ${JSON.stringify(filePath)}`;
|
|
501
|
+
|
|
502
|
+
const useSshPass = Boolean(params.password);
|
|
503
|
+
const sshArgs = buildSshArgs({
|
|
504
|
+
host,
|
|
505
|
+
socketDir,
|
|
506
|
+
controlPersist: params.control_persist,
|
|
507
|
+
batchMode: !useSshPass,
|
|
508
|
+
strictHostKey: params.strict_host_key,
|
|
509
|
+
});
|
|
510
|
+
sshArgs.push("--", writeCmd);
|
|
511
|
+
|
|
512
|
+
const spawnEnv: NodeJS.ProcessEnv = { ...process.env };
|
|
513
|
+
let binary = "ssh";
|
|
514
|
+
let finalArgs = sshArgs;
|
|
515
|
+
|
|
516
|
+
if (useSshPass && params.password) {
|
|
517
|
+
binary = "sshpass";
|
|
518
|
+
finalArgs = ["-e", "ssh", ...sshArgs];
|
|
519
|
+
spawnEnv.SSHPASS = params.password;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
const result = execSyncFn(binary, finalArgs, {
|
|
524
|
+
encoding: "utf-8",
|
|
525
|
+
env: spawnEnv,
|
|
526
|
+
input: content,
|
|
527
|
+
timeout: params.timeout_ms ?? 60_000,
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
ok: result.status === 0,
|
|
532
|
+
action: "file",
|
|
533
|
+
host,
|
|
534
|
+
exitCode: result.status ?? 1,
|
|
535
|
+
stdout: String(result.stdout || ""),
|
|
536
|
+
stderr: String(result.stderr || ""),
|
|
537
|
+
fileProcessed: filePath,
|
|
538
|
+
bytesProcessed: Buffer.byteLength(content, "utf-8"),
|
|
539
|
+
socketReused: wasSocketActive,
|
|
540
|
+
durationMs: Date.now() - startTime,
|
|
541
|
+
error: result.error ? result.error.message : undefined,
|
|
542
|
+
};
|
|
543
|
+
} catch (err: unknown) {
|
|
544
|
+
return {
|
|
545
|
+
ok: false,
|
|
546
|
+
action: "file",
|
|
547
|
+
host,
|
|
548
|
+
error: (err as Error).message,
|
|
549
|
+
durationMs: Date.now() - startTime,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return {
|
|
555
|
+
ok: false,
|
|
556
|
+
action: params.action,
|
|
557
|
+
error: `Unknown action: ${(params as { action: string }).action}`,
|
|
558
|
+
durationMs: Date.now() - startTime,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://schemas.kxm.dev/vnext/modes.schema.json",
|
|
4
|
+
"title": "KXM Modes Configuration",
|
|
5
|
+
"description": "Declarative major modes and domain modules for selective tool loading and context scoping",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["schema", "majorModes"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schema": { "const": "kxm.modes.v1" },
|
|
10
|
+
"majorModes": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"additionalProperties": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"required": ["baseTools"],
|
|
15
|
+
"properties": {
|
|
16
|
+
"description": { "type": "string" },
|
|
17
|
+
"baseTools": {
|
|
18
|
+
"type": "array",
|
|
19
|
+
"items": { "type": "string" }
|
|
20
|
+
},
|
|
21
|
+
"contextFiles": {
|
|
22
|
+
"type": "array",
|
|
23
|
+
"items": { "type": "string" }
|
|
24
|
+
},
|
|
25
|
+
"thinkingLevel": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"enum": ["low", "medium", "high", "xhigh"]
|
|
28
|
+
},
|
|
29
|
+
"model": { "type": "string" }
|
|
30
|
+
},
|
|
31
|
+
"additionalProperties": false
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"domains": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"additionalProperties": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"required": ["tools"],
|
|
39
|
+
"properties": {
|
|
40
|
+
"description": { "type": "string" },
|
|
41
|
+
"tools": {
|
|
42
|
+
"type": "array",
|
|
43
|
+
"items": { "type": "string" }
|
|
44
|
+
},
|
|
45
|
+
"contextFiles": {
|
|
46
|
+
"type": "array",
|
|
47
|
+
"items": { "type": "string" }
|
|
48
|
+
},
|
|
49
|
+
"promptSnippet": { "type": "string" }
|
|
50
|
+
},
|
|
51
|
+
"additionalProperties": false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"additionalProperties": false
|
|
56
|
+
}
|