minimal-agent 0.18.13 → 0.18.14
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/package.json +1 -3
- package/plugins/workflow-runner/src/runtime/context.js +1 -1
- package/plugins/workflow-runner/src/runtime/context.ts +1 -1
- package/plugins/workflow-runner/test/embeddedExecution.test.ts +2 -5
- package/plugins/workflow-runner/test/execLifetime.test.ts +15 -2
- package/skills/ssh-remote-dev/SKILL.md +61 -0
- package/skills/ssh-remote-dev/agents/openai.yaml +4 -0
- package/src/advisor/README.md +62 -24
- package/src/advisor/advisorNudge.js +199 -0
- package/src/advisor/advisorNudge.ts +232 -0
- package/src/advisor/deliveryReview.js +35 -12
- package/src/advisor/deliveryReview.ts +35 -11
- package/src/advisor/presentation.js +7 -10
- package/src/advisor/presentation.ts +10 -13
- package/src/advisor/reviewContext.js +22 -58
- package/src/advisor/reviewContext.ts +24 -67
- package/src/cli/configMenu.js +17 -128
- package/src/cli/configMenu.tsx +17 -193
- package/src/cli/print.js +2 -0
- package/src/cli/print.ts +2 -0
- package/src/cli/streamJson.js +2 -0
- package/src/cli/streamJson.ts +2 -0
- package/src/cli/wizards/advisorWizard.js +9 -7
- package/src/cli/wizards/advisorWizard.tsx +9 -7
- package/src/cli/wizards/contextAdvisorWizard.js +9 -7
- package/src/cli/wizards/contextAdvisorWizard.tsx +9 -7
- package/src/cli/wizards/providerPresets.js +27 -1
- package/src/cli/wizards/providerPresets.ts +44 -2
- package/src/cli/wizards/providerWizard.js +51 -10
- package/src/cli/wizards/providerWizard.tsx +59 -9
- package/src/config/configDefaults.js +1 -1
- package/src/config/configDefaults.ts +1 -1
- package/src/config/configDiagnostics.js +1 -21
- package/src/config/configDiagnostics.ts +3 -23
- package/src/config/configFile.js +11 -4
- package/src/config/configFile.ts +27 -7
- package/src/config/configManagement.js +1 -0
- package/src/config/configManagement.ts +2 -0
- package/src/config/configService.js +12 -8
- package/src/config/configService.ts +16 -11
- package/src/config/numericLimits.js +0 -4
- package/src/config/numericLimits.ts +0 -4
- package/src/config/reasoningEffort.js +16 -0
- package/src/config/reasoningEffort.ts +18 -0
- package/src/context/budgetPlan.js +1 -1
- package/src/context/budgetPlan.ts +1 -1
- package/src/context/optimizeConversation.js +1 -1
- package/src/context/optimizeConversation.ts +1 -1
- package/src/harness/stallGuard.js +8 -13
- package/src/harness/stallGuard.ts +8 -12
- package/src/llm/client.js +64 -17
- package/src/llm/client.ts +62 -7
- package/src/llm/providers/openaiFormat.js +14 -3
- package/src/llm/providers/openaiFormat.ts +17 -3
- package/src/llm/providers/responsesFormat.js +275 -0
- package/src/llm/providers/responsesFormat.ts +306 -0
- package/src/loop/README.md +18 -3
- package/src/loop/liveDiagramGate.js +63 -0
- package/src/loop/liveDiagramGate.ts +61 -0
- package/src/loop/messages.js +12 -0
- package/src/loop/messages.ts +13 -0
- package/src/loop/options.ts +7 -2
- package/src/loop/toolBatch.js +0 -1
- package/src/loop/toolBatch.ts +0 -1
- package/src/loop/toolExecutor.js +11 -46
- package/src/loop/toolExecutor.ts +12 -55
- package/src/loop/toolPresentation.js +2 -13
- package/src/loop/toolPresentation.ts +2 -13
- package/src/loop/toolSemantics.js +3 -4
- package/src/loop/toolSemantics.ts +4 -6
- package/src/loop.js +222 -214
- package/src/loop.ts +226 -232
- package/src/memory/dependencies.js +11 -3
- package/src/memory/dependencies.ts +11 -3
- package/src/memory/episodicMemory.js +59 -18
- package/src/memory/episodicMemory.ts +58 -18
- package/src/memory/experienceStore.js +4 -3
- package/src/memory/experienceStore.ts +4 -3
- package/src/memory/factCandidates.js +53 -1
- package/src/memory/factCandidates.ts +54 -1
- package/src/memory/factLearning.js +2 -2
- package/src/memory/factLearning.ts +2 -2
- package/src/memory/jobPolicy.js +24 -5
- package/src/memory/jobPolicy.ts +25 -5
- package/src/memory/learning.js +2 -2
- package/src/memory/learning.ts +2 -2
- package/src/memory/learningModel.js +7 -0
- package/src/memory/learningModel.ts +7 -0
- package/src/memory/managementService.js +8 -7
- package/src/memory/managementService.ts +16 -8
- package/src/memory/memoryService.js +104 -33
- package/src/memory/memoryService.ts +132 -28
- package/src/memory/projectKnowledge.js +39 -3
- package/src/memory/projectKnowledge.ts +45 -3
- package/src/memory/recallQuery.js +56 -0
- package/src/memory/recallQuery.ts +58 -0
- package/src/memory/retrieval.js +39 -10
- package/src/memory/retrieval.ts +46 -11
- package/src/memory/turnJournal.js +75 -10
- package/src/memory/turnJournal.ts +66 -10
- package/src/memory/types.ts +7 -1
- package/src/prompts/delegation.js +2 -1
- package/src/prompts/delegation.ts +3 -1
- package/src/prompts/policyRegistry.js +9 -12
- package/src/prompts/policyRegistry.ts +9 -12
- package/src/prompts/promptAssembly.js +6 -5
- package/src/prompts/promptAssembly.ts +8 -6
- package/src/prompts/visualPresentation.js +39 -10
- package/src/prompts/visualPresentation.ts +38 -9
- package/src/runtime/answerPreparation.js +1 -1
- package/src/runtime/answerPreparation.ts +1 -1
- package/src/runtime/fileLock.js +19 -3
- package/src/runtime/fileLock.ts +19 -3
- package/src/runtime/invocationPolicy.js +1 -1
- package/src/runtime/invocationPolicy.ts +1 -1
- package/src/runtime/outputContract.js +0 -4
- package/src/runtime/outputContract.ts +0 -7
- package/src/session/runAgentTurn.js +3 -0
- package/src/session/runAgentTurn.ts +3 -0
- package/src/team/onlineToolContract.js +0 -1
- package/src/team/onlineToolContract.ts +0 -1
- package/src/tools/advisor/advisor.js +207 -365
- package/src/tools/advisor/advisor.ts +211 -383
- package/src/tools/agent/agentTypes.js +0 -1
- package/src/tools/agent/agentTypes.ts +0 -1
- package/src/tools/agent/coordinator.js +4 -1
- package/src/tools/agent/coordinator.ts +4 -1
- package/src/tools/bash/semantics.js +4 -5
- package/src/tools/bash/semantics.ts +4 -5
- package/src/tools/capabilities.js +2 -3
- package/src/tools/capabilities.ts +2 -4
- package/src/tools/index.js +0 -16
- package/src/tools/index.ts +0 -16
- package/src/tools/memory/maintenance.js +8 -9
- package/src/tools/memory/maintenance.ts +13 -11
- package/src/tools/memory/memory.js +5 -1
- package/src/tools/memory/memory.ts +5 -0
- package/src/tools/optimizeConversation/README.md +4 -6
- package/src/tools/toolPromptGuides.js +2 -9
- package/src/tools/toolPromptGuides.ts +2 -9
- package/src/types.ts +28 -105
- package/src/ui/App.js +2 -0
- package/src/ui/App.tsx +1 -0
- package/src/ui/MessageList.js +8 -44
- package/src/ui/MessageList.tsx +3 -81
- package/src/ui/hooks/useChat.js +2 -0
- package/src/ui/hooks/useChat.ts +2 -0
- package/src/ui/toolProgress.js +1 -1
- package/src/ui/toolProgress.ts +1 -1
- package/src/web/protocol.ts +10 -3
- package/src/advisor/advisorCoordinator.js +0 -734
- package/src/advisor/advisorCoordinator.ts +0 -825
- package/src/cli/wizards/sshHostWizard.js +0 -156
- package/src/cli/wizards/sshHostWizard.tsx +0 -214
- package/src/context/sshHosts.js +0 -290
- package/src/context/sshHosts.ts +0 -312
- package/src/loop/publication.js +0 -94
- package/src/loop/publication.ts +0 -124
- package/src/runtime/finalAnswer.js +0 -9
- package/src/runtime/finalAnswer.ts +0 -13
- package/src/tools/advisor/advisorDecision.js +0 -79
- package/src/tools/advisor/advisorDecision.ts +0 -92
- package/src/tools/ssh/ssh.js +0 -152
- package/src/tools/ssh/ssh.ts +0 -167
- package/src/tools/ssh/sshAvailability.js +0 -35
- package/src/tools/ssh/sshAvailability.ts +0 -39
- package/src/tools/ssh/sshClient.js +0 -177
- package/src/tools/ssh/sshClient.ts +0 -189
- package/src/tools/ssh/sshDownload.js +0 -77
- package/src/tools/ssh/sshDownload.ts +0 -92
- package/src/tools/ssh/sshHostsTool.js +0 -36
- package/src/tools/ssh/sshHostsTool.ts +0 -48
- package/src/tools/ssh/sshUpload.js +0 -72
- package/src/tools/ssh/sshUpload.ts +0 -85
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
/**
|
|
3
|
-
* ============================================================
|
|
4
|
-
* src/cli/wizards/sshHostWizard.tsx —— 新增/编辑一个 SSH 主机
|
|
5
|
-
* ------------------------------------------------------------
|
|
6
|
-
* 三阶段状态机(同 advisorWizard.tsx 的做法):基础信息 → 认证方式
|
|
7
|
-
* (分叉:密码 or 私钥)→ 高级选项,最后一步才真正落盘。alias 是主机的
|
|
8
|
-
* 唯一标识,编辑时不可改(同模型 id 的语义),所以编辑模式直接跳过第一
|
|
9
|
-
* 阶段的 alias 字段。
|
|
10
|
-
* ============================================================
|
|
11
|
-
*/
|
|
12
|
-
import { useState } from 'react';
|
|
13
|
-
import { addSshHost, updateSshHost, } from '../../context/sshHosts.js';
|
|
14
|
-
import { CONFIG_LIMITS, normalizeLimited } from '../../config/numericLimits.js';
|
|
15
|
-
import { FieldForm } from './fieldForm.js';
|
|
16
|
-
function parsePort(value, fallback) {
|
|
17
|
-
const parsed = Number.parseInt(String(value ?? ''), 10);
|
|
18
|
-
return Number.isFinite(parsed) ? parsed : fallback;
|
|
19
|
-
}
|
|
20
|
-
export function SshHostWizard({ workspaceId, existing, onDone, onCancel }) {
|
|
21
|
-
const [phase, setPhase] = useState('basic');
|
|
22
|
-
const [basic, setBasic] = useState(existing
|
|
23
|
-
? { alias: existing.alias, host: existing.host, port: String(existing.port), username: existing.username }
|
|
24
|
-
: null);
|
|
25
|
-
const [authMethod, setAuthMethod] = useState(existing?.authMethod ?? 'password');
|
|
26
|
-
const [authDraft, setAuthDraft] = useState(existing
|
|
27
|
-
? { password: existing.password, privateKeyPath: existing.privateKeyPath, passphrase: existing.passphrase }
|
|
28
|
-
: null);
|
|
29
|
-
if (phase === 'basic') {
|
|
30
|
-
return (_jsx(FieldForm, { title: existing ? `编辑 SSH 主机 "${existing.alias}"` : '新增 SSH 主机', fields: [
|
|
31
|
-
...(existing ? [] : [{
|
|
32
|
-
key: 'alias', label: '别名(模型/工具用它引用主机,不含空格)', type: 'text', default: '',
|
|
33
|
-
hint: '例如 jetson、prod-server;只能是字母数字与 . _ -',
|
|
34
|
-
}]),
|
|
35
|
-
{ key: 'host', label: '主机地址(IP 或域名)', type: 'text', default: existing?.host ?? '' },
|
|
36
|
-
{ key: 'port', label: '端口', type: 'text', default: String(existing?.port ?? 22), optional: true },
|
|
37
|
-
{ key: 'username', label: '用户名', type: 'text', default: existing?.username ?? '' },
|
|
38
|
-
], onSubmit: async (values) => {
|
|
39
|
-
const alias = existing?.alias ?? String(values.alias ?? '').trim();
|
|
40
|
-
const host = String(values.host ?? '').trim();
|
|
41
|
-
const username = String(values.username ?? '').trim();
|
|
42
|
-
if (!alias)
|
|
43
|
-
throw new Error('别名不能为空');
|
|
44
|
-
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(alias))
|
|
45
|
-
throw new Error('别名格式不合法(只能是字母数字与 . _ -)');
|
|
46
|
-
if (!host)
|
|
47
|
-
throw new Error('主机地址不能为空');
|
|
48
|
-
if (!username)
|
|
49
|
-
throw new Error('用户名不能为空');
|
|
50
|
-
setBasic({
|
|
51
|
-
alias,
|
|
52
|
-
host,
|
|
53
|
-
port: String(normalizeLimited(parsePort(values.port, 22), CONFIG_LIMITS.sshPort, 22)),
|
|
54
|
-
username,
|
|
55
|
-
});
|
|
56
|
-
setPhase('auth');
|
|
57
|
-
}, onCancel: onCancel }));
|
|
58
|
-
}
|
|
59
|
-
if (phase === 'auth') {
|
|
60
|
-
return (_jsx(FieldForm, { title: "\u8BA4\u8BC1\u65B9\u5F0F", fields: [
|
|
61
|
-
{
|
|
62
|
-
key: 'authMethod',
|
|
63
|
-
label: '认证方式',
|
|
64
|
-
type: 'select',
|
|
65
|
-
options: ['password', 'privateKey'],
|
|
66
|
-
default: authMethod,
|
|
67
|
-
hint: 'password=密码认证(部分设备如 Jetson 只支持这种)· privateKey=私钥认证',
|
|
68
|
-
},
|
|
69
|
-
], onSubmit: async (values) => {
|
|
70
|
-
const method = values.authMethod === 'privateKey' ? 'privateKey' : 'password';
|
|
71
|
-
setAuthMethod(method);
|
|
72
|
-
setPhase(method === 'password' ? 'auth-password' : 'auth-key');
|
|
73
|
-
}, onCancel: onCancel }));
|
|
74
|
-
}
|
|
75
|
-
if (phase === 'auth-password') {
|
|
76
|
-
// 只有「编辑且此前也是密码认证」才允许留空沿用旧值——从私钥切到密码认证时
|
|
77
|
-
// 旧密码根本不存在,必须强制录入,否则会存出一条没有真实凭据的主机记录。
|
|
78
|
-
const canKeepExisting = Boolean(existing && existing.authMethod === 'password');
|
|
79
|
-
return (_jsx(FieldForm, { title: "\u5BC6\u7801\u8BA4\u8BC1", fields: [{
|
|
80
|
-
key: 'password', label: '密码', type: 'password',
|
|
81
|
-
default: authDraft?.password ?? '',
|
|
82
|
-
optional: canKeepExisting,
|
|
83
|
-
hint: canKeepExisting ? '留空 = 保持现有密码不变' : undefined,
|
|
84
|
-
}], onSubmit: async (values) => {
|
|
85
|
-
const password = String(values.password ?? '').trim();
|
|
86
|
-
if (!password && !canKeepExisting)
|
|
87
|
-
throw new Error('密码不能为空');
|
|
88
|
-
setAuthDraft({ password: password || existing?.password, privateKeyPath: undefined, passphrase: undefined });
|
|
89
|
-
setPhase('advanced');
|
|
90
|
-
}, onCancel: onCancel }));
|
|
91
|
-
}
|
|
92
|
-
if (phase === 'auth-key') {
|
|
93
|
-
// 同上:只有此前也是私钥认证才允许留空沿用旧路径。
|
|
94
|
-
const canKeepExisting = Boolean(existing && existing.authMethod === 'privateKey');
|
|
95
|
-
return (_jsx(FieldForm, { title: "\u79C1\u94A5\u8BA4\u8BC1", fields: [
|
|
96
|
-
{
|
|
97
|
-
key: 'privateKeyPath', label: '私钥文件路径', type: 'text',
|
|
98
|
-
default: authDraft?.privateKeyPath ?? '', optional: canKeepExisting,
|
|
99
|
-
hint: canKeepExisting ? '留空 = 保持现有私钥路径不变' : '本机绝对路径,例如 C:\\Users\\you\\.ssh\\id_ed25519',
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
key: 'passphrase', label: '私钥密码(没有则留空)', type: 'password',
|
|
103
|
-
default: authDraft?.passphrase ?? '', optional: true,
|
|
104
|
-
},
|
|
105
|
-
], onSubmit: async (values) => {
|
|
106
|
-
const privateKeyPath = String(values.privateKeyPath ?? '').trim();
|
|
107
|
-
if (!privateKeyPath && !canKeepExisting)
|
|
108
|
-
throw new Error('私钥文件路径不能为空');
|
|
109
|
-
setAuthDraft({
|
|
110
|
-
password: undefined,
|
|
111
|
-
privateKeyPath: privateKeyPath || existing?.privateKeyPath,
|
|
112
|
-
passphrase: String(values.passphrase ?? '').trim() || undefined,
|
|
113
|
-
});
|
|
114
|
-
setPhase('advanced');
|
|
115
|
-
}, onCancel: onCancel }));
|
|
116
|
-
}
|
|
117
|
-
// phase === 'advanced'
|
|
118
|
-
return (_jsx(FieldForm, { title: "\u9AD8\u7EA7\u9009\u9879", fields: [
|
|
119
|
-
{
|
|
120
|
-
key: 'remoteShell', label: '远程 shell 类型', type: 'select',
|
|
121
|
-
options: ['bash', 'cmd', 'powershell'],
|
|
122
|
-
default: existing?.remoteShell ?? 'bash',
|
|
123
|
-
hint: '用于危险命令拦截判断(如 rm/del),必须与主机实际 shell 一致;Linux/树莓派/Jetson 选 bash',
|
|
124
|
-
},
|
|
125
|
-
{
|
|
126
|
-
key: 'timeoutMs', label: '默认操作超时(秒,留空用工具默认值)', type: 'text',
|
|
127
|
-
default: existing?.timeoutMs ? String(Math.round(existing.timeoutMs / 1000)) : '', optional: true,
|
|
128
|
-
},
|
|
129
|
-
], onSubmit: async (values) => {
|
|
130
|
-
if (!basic)
|
|
131
|
-
throw new Error('缺少基础信息'); // 状态机不应该走到这一步却没有 basic
|
|
132
|
-
const remoteShell = (values.remoteShell === 'cmd' || values.remoteShell === 'powershell')
|
|
133
|
-
? values.remoteShell
|
|
134
|
-
: 'bash';
|
|
135
|
-
const timeoutSeconds = String(values.timeoutMs ?? '').trim();
|
|
136
|
-
const timeoutMs = timeoutSeconds
|
|
137
|
-
? normalizeLimited(Number.parseInt(timeoutSeconds, 10) * 1000, CONFIG_LIMITS.sshTimeoutMs, CONFIG_LIMITS.sshTimeoutMs.min)
|
|
138
|
-
: undefined;
|
|
139
|
-
const host = {
|
|
140
|
-
alias: basic.alias,
|
|
141
|
-
host: basic.host,
|
|
142
|
-
port: Number.parseInt(basic.port, 10),
|
|
143
|
-
username: basic.username,
|
|
144
|
-
authMethod,
|
|
145
|
-
...(authMethod === 'password' ? { password: authDraft?.password } : {}),
|
|
146
|
-
...(authMethod === 'privateKey' ? { privateKeyPath: authDraft?.privateKeyPath, passphrase: authDraft?.passphrase } : {}),
|
|
147
|
-
remoteShell,
|
|
148
|
-
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
149
|
-
};
|
|
150
|
-
if (existing)
|
|
151
|
-
await updateSshHost(workspaceId, existing.alias, host);
|
|
152
|
-
else
|
|
153
|
-
await addSshHost(workspaceId, host);
|
|
154
|
-
onDone();
|
|
155
|
-
}, onCancel: onCancel }));
|
|
156
|
-
}
|
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ============================================================
|
|
3
|
-
* src/cli/wizards/sshHostWizard.tsx —— 新增/编辑一个 SSH 主机
|
|
4
|
-
* ------------------------------------------------------------
|
|
5
|
-
* 三阶段状态机(同 advisorWizard.tsx 的做法):基础信息 → 认证方式
|
|
6
|
-
* (分叉:密码 or 私钥)→ 高级选项,最后一步才真正落盘。alias 是主机的
|
|
7
|
-
* 唯一标识,编辑时不可改(同模型 id 的语义),所以编辑模式直接跳过第一
|
|
8
|
-
* 阶段的 alias 字段。
|
|
9
|
-
* ============================================================
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import React, { useState } from 'react';
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
addSshHost,
|
|
16
|
-
updateSshHost,
|
|
17
|
-
type SshHostConfig,
|
|
18
|
-
} from '../../context/sshHosts.js';
|
|
19
|
-
import { CONFIG_LIMITS, normalizeLimited } from '../../config/numericLimits.js';
|
|
20
|
-
import { FieldForm } from './fieldForm.js';
|
|
21
|
-
|
|
22
|
-
interface Props {
|
|
23
|
-
workspaceId: string;
|
|
24
|
-
existing?: SshHostConfig | null;
|
|
25
|
-
onDone: () => void;
|
|
26
|
-
onCancel: () => void;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
type Phase = 'basic' | 'auth' | 'auth-password' | 'auth-key' | 'advanced';
|
|
30
|
-
|
|
31
|
-
interface BasicDraft {
|
|
32
|
-
alias: string;
|
|
33
|
-
host: string;
|
|
34
|
-
port: string;
|
|
35
|
-
username: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function parsePort(value: unknown, fallback: number): number {
|
|
39
|
-
const parsed = Number.parseInt(String(value ?? ''), 10);
|
|
40
|
-
return Number.isFinite(parsed) ? parsed : fallback;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function SshHostWizard({ workspaceId, existing, onDone, onCancel }: Props): JSX.Element {
|
|
44
|
-
const [phase, setPhase] = useState<Phase>('basic');
|
|
45
|
-
const [basic, setBasic] = useState<BasicDraft | null>(
|
|
46
|
-
existing
|
|
47
|
-
? { alias: existing.alias, host: existing.host, port: String(existing.port), username: existing.username }
|
|
48
|
-
: null,
|
|
49
|
-
);
|
|
50
|
-
const [authMethod, setAuthMethod] = useState<'password' | 'privateKey'>(existing?.authMethod ?? 'password');
|
|
51
|
-
const [authDraft, setAuthDraft] = useState<{ password?: string; privateKeyPath?: string; passphrase?: string } | null>(
|
|
52
|
-
existing
|
|
53
|
-
? { password: existing.password, privateKeyPath: existing.privateKeyPath, passphrase: existing.passphrase }
|
|
54
|
-
: null,
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
if (phase === 'basic') {
|
|
58
|
-
return (
|
|
59
|
-
<FieldForm
|
|
60
|
-
title={existing ? `编辑 SSH 主机 "${existing.alias}"` : '新增 SSH 主机'}
|
|
61
|
-
fields={[
|
|
62
|
-
...(existing ? [] : [{
|
|
63
|
-
key: 'alias', label: '别名(模型/工具用它引用主机,不含空格)', type: 'text' as const, default: '',
|
|
64
|
-
hint: '例如 jetson、prod-server;只能是字母数字与 . _ -',
|
|
65
|
-
}]),
|
|
66
|
-
{ key: 'host', label: '主机地址(IP 或域名)', type: 'text' as const, default: existing?.host ?? '' },
|
|
67
|
-
{ key: 'port', label: '端口', type: 'text' as const, default: String(existing?.port ?? 22), optional: true },
|
|
68
|
-
{ key: 'username', label: '用户名', type: 'text' as const, default: existing?.username ?? '' },
|
|
69
|
-
]}
|
|
70
|
-
onSubmit={async (values) => {
|
|
71
|
-
const alias = existing?.alias ?? String(values.alias ?? '').trim();
|
|
72
|
-
const host = String(values.host ?? '').trim();
|
|
73
|
-
const username = String(values.username ?? '').trim();
|
|
74
|
-
if (!alias) throw new Error('别名不能为空');
|
|
75
|
-
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(alias)) throw new Error('别名格式不合法(只能是字母数字与 . _ -)');
|
|
76
|
-
if (!host) throw new Error('主机地址不能为空');
|
|
77
|
-
if (!username) throw new Error('用户名不能为空');
|
|
78
|
-
setBasic({
|
|
79
|
-
alias,
|
|
80
|
-
host,
|
|
81
|
-
port: String(normalizeLimited(parsePort(values.port, 22), CONFIG_LIMITS.sshPort, 22)),
|
|
82
|
-
username,
|
|
83
|
-
});
|
|
84
|
-
setPhase('auth');
|
|
85
|
-
}}
|
|
86
|
-
onCancel={onCancel}
|
|
87
|
-
/>
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (phase === 'auth') {
|
|
92
|
-
return (
|
|
93
|
-
<FieldForm
|
|
94
|
-
title="认证方式"
|
|
95
|
-
fields={[
|
|
96
|
-
{
|
|
97
|
-
key: 'authMethod',
|
|
98
|
-
label: '认证方式',
|
|
99
|
-
type: 'select',
|
|
100
|
-
options: ['password', 'privateKey'],
|
|
101
|
-
default: authMethod,
|
|
102
|
-
hint: 'password=密码认证(部分设备如 Jetson 只支持这种)· privateKey=私钥认证',
|
|
103
|
-
},
|
|
104
|
-
]}
|
|
105
|
-
onSubmit={async (values) => {
|
|
106
|
-
const method = values.authMethod === 'privateKey' ? 'privateKey' : 'password';
|
|
107
|
-
setAuthMethod(method);
|
|
108
|
-
setPhase(method === 'password' ? 'auth-password' : 'auth-key');
|
|
109
|
-
}}
|
|
110
|
-
onCancel={onCancel}
|
|
111
|
-
/>
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (phase === 'auth-password') {
|
|
116
|
-
// 只有「编辑且此前也是密码认证」才允许留空沿用旧值——从私钥切到密码认证时
|
|
117
|
-
// 旧密码根本不存在,必须强制录入,否则会存出一条没有真实凭据的主机记录。
|
|
118
|
-
const canKeepExisting = Boolean(existing && existing.authMethod === 'password');
|
|
119
|
-
return (
|
|
120
|
-
<FieldForm
|
|
121
|
-
title="密码认证"
|
|
122
|
-
fields={[{
|
|
123
|
-
key: 'password', label: '密码', type: 'password',
|
|
124
|
-
default: authDraft?.password ?? '',
|
|
125
|
-
optional: canKeepExisting,
|
|
126
|
-
hint: canKeepExisting ? '留空 = 保持现有密码不变' : undefined,
|
|
127
|
-
}]}
|
|
128
|
-
onSubmit={async (values) => {
|
|
129
|
-
const password = String(values.password ?? '').trim();
|
|
130
|
-
if (!password && !canKeepExisting) throw new Error('密码不能为空');
|
|
131
|
-
setAuthDraft({ password: password || existing?.password, privateKeyPath: undefined, passphrase: undefined });
|
|
132
|
-
setPhase('advanced');
|
|
133
|
-
}}
|
|
134
|
-
onCancel={onCancel}
|
|
135
|
-
/>
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (phase === 'auth-key') {
|
|
140
|
-
// 同上:只有此前也是私钥认证才允许留空沿用旧路径。
|
|
141
|
-
const canKeepExisting = Boolean(existing && existing.authMethod === 'privateKey');
|
|
142
|
-
return (
|
|
143
|
-
<FieldForm
|
|
144
|
-
title="私钥认证"
|
|
145
|
-
fields={[
|
|
146
|
-
{
|
|
147
|
-
key: 'privateKeyPath', label: '私钥文件路径', type: 'text',
|
|
148
|
-
default: authDraft?.privateKeyPath ?? '', optional: canKeepExisting,
|
|
149
|
-
hint: canKeepExisting ? '留空 = 保持现有私钥路径不变' : '本机绝对路径,例如 C:\\Users\\you\\.ssh\\id_ed25519',
|
|
150
|
-
},
|
|
151
|
-
{
|
|
152
|
-
key: 'passphrase', label: '私钥密码(没有则留空)', type: 'password',
|
|
153
|
-
default: authDraft?.passphrase ?? '', optional: true,
|
|
154
|
-
},
|
|
155
|
-
]}
|
|
156
|
-
onSubmit={async (values) => {
|
|
157
|
-
const privateKeyPath = String(values.privateKeyPath ?? '').trim();
|
|
158
|
-
if (!privateKeyPath && !canKeepExisting) throw new Error('私钥文件路径不能为空');
|
|
159
|
-
setAuthDraft({
|
|
160
|
-
password: undefined,
|
|
161
|
-
privateKeyPath: privateKeyPath || existing?.privateKeyPath,
|
|
162
|
-
passphrase: String(values.passphrase ?? '').trim() || undefined,
|
|
163
|
-
});
|
|
164
|
-
setPhase('advanced');
|
|
165
|
-
}}
|
|
166
|
-
onCancel={onCancel}
|
|
167
|
-
/>
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// phase === 'advanced'
|
|
172
|
-
return (
|
|
173
|
-
<FieldForm
|
|
174
|
-
title="高级选项"
|
|
175
|
-
fields={[
|
|
176
|
-
{
|
|
177
|
-
key: 'remoteShell', label: '远程 shell 类型', type: 'select',
|
|
178
|
-
options: ['bash', 'cmd', 'powershell'],
|
|
179
|
-
default: existing?.remoteShell ?? 'bash',
|
|
180
|
-
hint: '用于危险命令拦截判断(如 rm/del),必须与主机实际 shell 一致;Linux/树莓派/Jetson 选 bash',
|
|
181
|
-
},
|
|
182
|
-
{
|
|
183
|
-
key: 'timeoutMs', label: '默认操作超时(秒,留空用工具默认值)', type: 'text',
|
|
184
|
-
default: existing?.timeoutMs ? String(Math.round(existing.timeoutMs / 1000)) : '', optional: true,
|
|
185
|
-
},
|
|
186
|
-
]}
|
|
187
|
-
onSubmit={async (values) => {
|
|
188
|
-
if (!basic) throw new Error('缺少基础信息'); // 状态机不应该走到这一步却没有 basic
|
|
189
|
-
const remoteShell = (values.remoteShell === 'cmd' || values.remoteShell === 'powershell')
|
|
190
|
-
? values.remoteShell
|
|
191
|
-
: 'bash';
|
|
192
|
-
const timeoutSeconds = String(values.timeoutMs ?? '').trim();
|
|
193
|
-
const timeoutMs = timeoutSeconds
|
|
194
|
-
? normalizeLimited(Number.parseInt(timeoutSeconds, 10) * 1000, CONFIG_LIMITS.sshTimeoutMs, CONFIG_LIMITS.sshTimeoutMs.min)
|
|
195
|
-
: undefined;
|
|
196
|
-
const host: SshHostConfig = {
|
|
197
|
-
alias: basic.alias,
|
|
198
|
-
host: basic.host,
|
|
199
|
-
port: Number.parseInt(basic.port, 10),
|
|
200
|
-
username: basic.username,
|
|
201
|
-
authMethod,
|
|
202
|
-
...(authMethod === 'password' ? { password: authDraft?.password } : {}),
|
|
203
|
-
...(authMethod === 'privateKey' ? { privateKeyPath: authDraft?.privateKeyPath, passphrase: authDraft?.passphrase } : {}),
|
|
204
|
-
remoteShell,
|
|
205
|
-
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
206
|
-
};
|
|
207
|
-
if (existing) await updateSshHost(workspaceId, existing.alias, host);
|
|
208
|
-
else await addSshHost(workspaceId, host);
|
|
209
|
-
onDone();
|
|
210
|
-
}}
|
|
211
|
-
onCancel={onCancel}
|
|
212
|
-
/>
|
|
213
|
-
);
|
|
214
|
-
}
|
package/src/context/sshHosts.js
DELETED
|
@@ -1,290 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ============================================================
|
|
3
|
-
* src/context/sshHosts.ts —— 工作区级 SSH 主机配置存储
|
|
4
|
-
* ------------------------------------------------------------
|
|
5
|
-
* 每个工作区(workspaceId,见 workspaceIndex.ts)独立一份 SSH 主机列表,
|
|
6
|
-
* **不进全局 config.json**——绿色版的 config.json 是卖家预置件(U 盘出厂
|
|
7
|
-
* 即预配置好 API),不能承载终端用户自己录入的远程主机凭据(尤其是明文
|
|
8
|
-
* 密码:Jetson 类设备常常只支持密码认证,见 SshHostConfig.authMethod)。
|
|
9
|
-
*
|
|
10
|
-
* 落盘路径:agentStateDir()/workspaces/<workspaceId>/ssh-hosts.json
|
|
11
|
-
* 锁 + 原子写模式镜像 configFile.ts::acquireConfigLock(mkdir 锁目录 +
|
|
12
|
-
* owner.json + 陈旧锁检测),但这是独立文件独立锁,不与全局 config 锁
|
|
13
|
-
* 互相阻塞。
|
|
14
|
-
*
|
|
15
|
-
* workspaceId 缺失(临时/e2e/cron 工作目录,见 workspaceIndex.ts 的
|
|
16
|
-
* isTemporaryWorkspace)时,本模块的调用方(SSH/SSHUpload/SSHDownload
|
|
17
|
-
* 三个工具)必须 fail-closed 报错,不得静默回落到某个共享位置——这与
|
|
18
|
-
* memoryAccess 默认 denied 是同一个哲学:没有工作区身份就没有可信的
|
|
19
|
-
* 隔离边界。
|
|
20
|
-
* ============================================================
|
|
21
|
-
*/
|
|
22
|
-
import { randomUUID } from 'node:crypto';
|
|
23
|
-
import { chmod, mkdir, readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
24
|
-
import { dirname, join } from 'node:path';
|
|
25
|
-
import { agentStateDir } from './agentHome.js';
|
|
26
|
-
import { CONFIG_LIMITS, withinLimit } from '../config/numericLimits.js';
|
|
27
|
-
const EMPTY_DATA = { hosts: [], defaultHostAlias: null };
|
|
28
|
-
const LOCK_STALE_MS = 120_000;
|
|
29
|
-
const LOCK_WAIT_MS = 15_000;
|
|
30
|
-
const ALIAS_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
|
31
|
-
/** 标准 SSH 端口——唯一真值,向导默认值与 normalizeHost() 的兜底都从这里取。 */
|
|
32
|
-
export const DEFAULT_SSH_PORT = 22;
|
|
33
|
-
export function sshHostsFilePath(workspaceId) {
|
|
34
|
-
return join(agentStateDir(), 'workspaces', workspaceId, 'ssh-hosts.json');
|
|
35
|
-
}
|
|
36
|
-
function processIsAlive(pid) {
|
|
37
|
-
if (!Number.isInteger(pid) || pid <= 0)
|
|
38
|
-
return false;
|
|
39
|
-
try {
|
|
40
|
-
process.kill(pid, 0);
|
|
41
|
-
return true;
|
|
42
|
-
}
|
|
43
|
-
catch (error) {
|
|
44
|
-
return error.code === 'EPERM';
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
async function acquireLock(file) {
|
|
48
|
-
const lockDir = `${file}.lock`;
|
|
49
|
-
const ownerFile = join(lockDir, 'owner.json');
|
|
50
|
-
const startedAt = Date.now();
|
|
51
|
-
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
52
|
-
while (true) {
|
|
53
|
-
try {
|
|
54
|
-
await mkdir(lockDir, { mode: 0o700 });
|
|
55
|
-
await writeFile(ownerFile, JSON.stringify({ pid: process.pid, createdAt: Date.now() }), { mode: 0o600 });
|
|
56
|
-
return async () => { await rm(lockDir, { recursive: true, force: true }); };
|
|
57
|
-
}
|
|
58
|
-
catch (error) {
|
|
59
|
-
if (error.code !== 'EEXIST')
|
|
60
|
-
throw error;
|
|
61
|
-
let stale = false;
|
|
62
|
-
try {
|
|
63
|
-
const [ownerRaw, info] = await Promise.all([readFile(ownerFile, 'utf8'), stat(lockDir)]);
|
|
64
|
-
const owner = JSON.parse(ownerRaw);
|
|
65
|
-
stale = typeof owner.pid === 'number' ? !processIsAlive(owner.pid) : Date.now() - info.mtimeMs > LOCK_STALE_MS;
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
try {
|
|
69
|
-
stale = Date.now() - (await stat(lockDir)).mtimeMs > LOCK_STALE_MS;
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
stale = false;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
if (stale) {
|
|
76
|
-
await rm(lockDir, { recursive: true, force: true });
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
if (Date.now() - startedAt > LOCK_WAIT_MS)
|
|
80
|
-
throw new Error('等待 SSH 主机配置锁超时');
|
|
81
|
-
await new Promise((resolveWait) => setTimeout(resolveWait, 40));
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
function normalizeHost(raw) {
|
|
86
|
-
if (!raw || typeof raw !== 'object')
|
|
87
|
-
return null;
|
|
88
|
-
const value = raw;
|
|
89
|
-
if (typeof value.alias !== 'string' || !ALIAS_RE.test(value.alias))
|
|
90
|
-
return null;
|
|
91
|
-
if (typeof value.host !== 'string' || !value.host.trim())
|
|
92
|
-
return null;
|
|
93
|
-
if (typeof value.username !== 'string' || !value.username.trim())
|
|
94
|
-
return null;
|
|
95
|
-
if (value.authMethod !== 'password' && value.authMethod !== 'privateKey')
|
|
96
|
-
return null;
|
|
97
|
-
if (value.remoteShell !== 'bash' && value.remoteShell !== 'cmd' && value.remoteShell !== 'powershell')
|
|
98
|
-
return null;
|
|
99
|
-
const port = typeof value.port === 'number' ? value.port : DEFAULT_SSH_PORT;
|
|
100
|
-
if (!withinLimit(port, CONFIG_LIMITS.sshPort))
|
|
101
|
-
return null;
|
|
102
|
-
const timeoutMs = typeof value.timeoutMs === 'number' ? value.timeoutMs : undefined;
|
|
103
|
-
if (timeoutMs !== undefined && !withinLimit(timeoutMs, CONFIG_LIMITS.sshTimeoutMs))
|
|
104
|
-
return null;
|
|
105
|
-
return {
|
|
106
|
-
alias: value.alias,
|
|
107
|
-
host: value.host,
|
|
108
|
-
port,
|
|
109
|
-
username: value.username,
|
|
110
|
-
authMethod: value.authMethod,
|
|
111
|
-
...(typeof value.password === 'string' ? { password: value.password } : {}),
|
|
112
|
-
...(typeof value.privateKeyPath === 'string' ? { privateKeyPath: value.privateKeyPath } : {}),
|
|
113
|
-
...(typeof value.passphrase === 'string' ? { passphrase: value.passphrase } : {}),
|
|
114
|
-
remoteShell: value.remoteShell,
|
|
115
|
-
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
function normalizeFile(parsed) {
|
|
119
|
-
if (!parsed || typeof parsed !== 'object')
|
|
120
|
-
return null;
|
|
121
|
-
const raw = parsed;
|
|
122
|
-
if (raw.version !== 1 || !Array.isArray(raw.hosts))
|
|
123
|
-
return null;
|
|
124
|
-
const hosts = raw.hosts.flatMap((item) => {
|
|
125
|
-
const host = normalizeHost(item);
|
|
126
|
-
return host ? [host] : [];
|
|
127
|
-
});
|
|
128
|
-
const defaultHostAlias = typeof raw.defaultHostAlias === 'string' && hosts.some((h) => h.alias === raw.defaultHostAlias)
|
|
129
|
-
? raw.defaultHostAlias
|
|
130
|
-
: null;
|
|
131
|
-
return { hosts, defaultHostAlias };
|
|
132
|
-
}
|
|
133
|
-
/** 读路径 fail-open:坏文件被隔离到旁路而不是让整个工作区永远读不出主机列表。 */
|
|
134
|
-
async function quarantineCorruptFile(file, reason) {
|
|
135
|
-
const target = `${file}.corrupt-${Date.now()}`;
|
|
136
|
-
try {
|
|
137
|
-
await rename(file, target);
|
|
138
|
-
await writeFile(`${target}.reason.txt`, reason, { mode: 0o600 });
|
|
139
|
-
}
|
|
140
|
-
catch { /* 文件在读和隔离之间消失也无妨,视同不存在 */ }
|
|
141
|
-
}
|
|
142
|
-
async function readUnlocked(workspaceId) {
|
|
143
|
-
const file = sshHostsFilePath(workspaceId);
|
|
144
|
-
let raw;
|
|
145
|
-
try {
|
|
146
|
-
raw = await readFile(file, 'utf8');
|
|
147
|
-
}
|
|
148
|
-
catch (error) {
|
|
149
|
-
if (error.code === 'ENOENT')
|
|
150
|
-
return EMPTY_DATA;
|
|
151
|
-
throw error;
|
|
152
|
-
}
|
|
153
|
-
let parsed;
|
|
154
|
-
try {
|
|
155
|
-
parsed = JSON.parse(raw);
|
|
156
|
-
}
|
|
157
|
-
catch (error) {
|
|
158
|
-
await quarantineCorruptFile(file, `JSON 解析失败:${error instanceof Error ? error.message : String(error)}`);
|
|
159
|
-
return EMPTY_DATA;
|
|
160
|
-
}
|
|
161
|
-
const normalized = normalizeFile(parsed);
|
|
162
|
-
if (!normalized) {
|
|
163
|
-
await quarantineCorruptFile(file, 'SSH 主机配置文件结构不合法');
|
|
164
|
-
return EMPTY_DATA;
|
|
165
|
-
}
|
|
166
|
-
return normalized;
|
|
167
|
-
}
|
|
168
|
-
async function writeUnlocked(workspaceId, data) {
|
|
169
|
-
const file = sshHostsFilePath(workspaceId);
|
|
170
|
-
const payload = { version: 1, hosts: data.hosts, defaultHostAlias: data.defaultHostAlias };
|
|
171
|
-
const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
172
|
-
try {
|
|
173
|
-
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
174
|
-
await writeFile(tmp, JSON.stringify(payload, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
175
|
-
await rename(tmp, file);
|
|
176
|
-
await chmod(file, 0o600).catch(() => { });
|
|
177
|
-
}
|
|
178
|
-
finally {
|
|
179
|
-
await unlink(tmp).catch(() => { });
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
/** 写入前的形状校验——别名唯一、必填字段非空、数字边界,defaultHostAlias 必须指向存在的主机或为 null。 */
|
|
183
|
-
function assertValid(data) {
|
|
184
|
-
const seen = new Set();
|
|
185
|
-
for (const host of data.hosts) {
|
|
186
|
-
if (!ALIAS_RE.test(host.alias))
|
|
187
|
-
throw new Error(`主机别名不合法:${host.alias}`);
|
|
188
|
-
if (seen.has(host.alias))
|
|
189
|
-
throw new Error(`主机别名重复:${host.alias}`);
|
|
190
|
-
seen.add(host.alias);
|
|
191
|
-
if (!host.host.trim())
|
|
192
|
-
throw new Error(`主机地址不能为空(alias=${host.alias})`);
|
|
193
|
-
if (!host.username.trim())
|
|
194
|
-
throw new Error(`用户名不能为空(alias=${host.alias})`);
|
|
195
|
-
if (!withinLimit(host.port, CONFIG_LIMITS.sshPort))
|
|
196
|
-
throw new Error(`端口超出范围(alias=${host.alias})`);
|
|
197
|
-
if (host.timeoutMs !== undefined && !withinLimit(host.timeoutMs, CONFIG_LIMITS.sshTimeoutMs)) {
|
|
198
|
-
throw new Error(`超时时间超出范围(alias=${host.alias})`);
|
|
199
|
-
}
|
|
200
|
-
if (host.authMethod === 'password' && !host.password)
|
|
201
|
-
throw new Error(`密码认证缺少密码(alias=${host.alias})`);
|
|
202
|
-
if (host.authMethod === 'privateKey' && !host.privateKeyPath)
|
|
203
|
-
throw new Error(`密钥认证缺少私钥路径(alias=${host.alias})`);
|
|
204
|
-
}
|
|
205
|
-
if (data.defaultHostAlias !== null && !seen.has(data.defaultHostAlias)) {
|
|
206
|
-
throw new Error(`默认主机 "${data.defaultHostAlias}" 不在主机列表中`);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
/** 读-改-写的唯一入口:整个过程持锁,避免并发编辑丢更新。 */
|
|
210
|
-
async function mutate(workspaceId, mutator) {
|
|
211
|
-
const file = sshHostsFilePath(workspaceId);
|
|
212
|
-
const release = await acquireLock(file);
|
|
213
|
-
try {
|
|
214
|
-
const current = await readUnlocked(workspaceId);
|
|
215
|
-
const next = mutator(current);
|
|
216
|
-
assertValid(next);
|
|
217
|
-
await writeUnlocked(workspaceId, next);
|
|
218
|
-
return next;
|
|
219
|
-
}
|
|
220
|
-
finally {
|
|
221
|
-
await release();
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
export async function listSshHosts(workspaceId) {
|
|
225
|
-
return readUnlocked(workspaceId);
|
|
226
|
-
}
|
|
227
|
-
export async function addSshHost(workspaceId, host) {
|
|
228
|
-
return mutate(workspaceId, (current) => {
|
|
229
|
-
if (current.hosts.some((h) => h.alias === host.alias))
|
|
230
|
-
throw new Error(`主机别名已存在:${host.alias}`);
|
|
231
|
-
const hosts = [...current.hosts, host];
|
|
232
|
-
return { hosts, defaultHostAlias: current.defaultHostAlias ?? (hosts.length === 1 ? host.alias : null) };
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
export async function updateSshHost(workspaceId, alias, patch) {
|
|
236
|
-
return mutate(workspaceId, (current) => {
|
|
237
|
-
const index = current.hosts.findIndex((h) => h.alias === alias);
|
|
238
|
-
if (index < 0)
|
|
239
|
-
throw new Error(`主机不存在:${alias}`);
|
|
240
|
-
const hosts = [...current.hosts];
|
|
241
|
-
hosts[index] = { ...hosts[index], ...patch };
|
|
242
|
-
return { hosts, defaultHostAlias: current.defaultHostAlias };
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
export async function removeSshHost(workspaceId, alias) {
|
|
246
|
-
return mutate(workspaceId, (current) => {
|
|
247
|
-
const hosts = current.hosts.filter((h) => h.alias !== alias);
|
|
248
|
-
return {
|
|
249
|
-
hosts,
|
|
250
|
-
defaultHostAlias: current.defaultHostAlias === alias ? null : current.defaultHostAlias,
|
|
251
|
-
};
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
export async function setDefaultSshHost(workspaceId, alias) {
|
|
255
|
-
return mutate(workspaceId, (current) => ({ ...current, defaultHostAlias: alias }));
|
|
256
|
-
}
|
|
257
|
-
/**
|
|
258
|
-
* 供 SSH/SSHUpload/SSHDownload 三个工具调用的解析入口:
|
|
259
|
-
* 显式给了 alias 就按 alias 找;没给就退回该工作区的默认主机;
|
|
260
|
-
* 两者都没有就明确报错,不静默选第一个(选错主机执行远程命令后果比报错严重)。
|
|
261
|
-
*/
|
|
262
|
-
export async function resolveSshHost(workspaceId, aliasInput) {
|
|
263
|
-
const data = await listSshHosts(workspaceId);
|
|
264
|
-
if (aliasInput) {
|
|
265
|
-
const host = data.hosts.find((h) => h.alias === aliasInput);
|
|
266
|
-
if (!host) {
|
|
267
|
-
const known = data.hosts.map((h) => h.alias).join(', ') || '(无)';
|
|
268
|
-
return { ok: false, error: `未找到主机 "${aliasInput}"。已配置:${known}` };
|
|
269
|
-
}
|
|
270
|
-
return { ok: true, host };
|
|
271
|
-
}
|
|
272
|
-
if (data.defaultHostAlias) {
|
|
273
|
-
const host = data.hosts.find((h) => h.alias === data.defaultHostAlias);
|
|
274
|
-
if (host)
|
|
275
|
-
return { ok: true, host };
|
|
276
|
-
}
|
|
277
|
-
if (data.hosts.length === 0) {
|
|
278
|
-
return { ok: false, error: '当前工作区尚未配置任何 SSH 主机,请先在 /config → 远程主机 (SSH) 中添加。' };
|
|
279
|
-
}
|
|
280
|
-
return { ok: false, error: `未指定主机且未设置默认主机。已配置:${data.hosts.map((h) => h.alias).join(', ')}` };
|
|
281
|
-
}
|
|
282
|
-
/** 给工具 description 用的轻量列表——只暴露 alias/host/username,绝不带密码。 */
|
|
283
|
-
export async function describeSshHosts(workspaceId) {
|
|
284
|
-
const data = await listSshHosts(workspaceId);
|
|
285
|
-
if (data.hosts.length === 0)
|
|
286
|
-
return '(当前工作区尚未配置任何 SSH 主机)';
|
|
287
|
-
return data.hosts
|
|
288
|
-
.map((h) => `${h.alias}(${h.username}@${h.host}:${h.port})${h.alias === data.defaultHostAlias ? ' [默认]' : ''}`)
|
|
289
|
-
.join(', ');
|
|
290
|
-
}
|