@thincoder/core 0.10.8 → 0.10.10
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 +6 -0
- package/advisor/messages.mjs +4 -5
- package/advisor/project-context.mjs +69 -44
- package/agent/family-tools.mjs +11 -9
- package/agent/helpers.mjs +12 -6
- package/agent/post-turn.mjs +34 -0
- package/agent/run-stages.mjs +43 -9
- package/agent/run-start.mjs +1 -0
- package/agent/setup.mjs +10 -2
- package/agent/turn-loop.mjs +23 -13
- package/agent-tools/audit-block.mjs +4 -2
- package/agent-tools/batch-gate.mjs +197 -0
- package/agent-tools/batch-lifecycle.mjs +216 -36
- package/agent-tools/batch-skeleton.mjs +87 -0
- package/agent-tools/batch.mjs +11 -3
- package/agent-tools/checkpoint.mjs +11 -6
- package/agent-tools/context.mjs +28 -5
- package/agent-tools/design-slots.mjs +7 -33
- package/agent-tools/doc-check.mjs +50 -7
- package/agent-tools/manifest.mjs +44 -12
- package/agent-tools/spawn-gates.mjs +100 -41
- package/agent-tools/ssh-session.mjs +108 -61
- package/agent-tools/ssh-transport.mjs +17 -4
- package/agent-tools/ssh-vault.mjs +291 -0
- package/agent-tools/subagent-spawn.mjs +52 -30
- package/agent-tools/subagent.mjs +14 -8
- package/agent-tools/timer.mjs +2 -2
- package/agent-tools.mjs +1 -1
- package/config-defaults.mjs +92 -0
- package/config-normalize.mjs +109 -0
- package/config.mjs +88 -197
- package/declaration.mjs +5 -12
- package/doc-check/anchors.mjs +68 -34
- package/doc-check/index.mjs +143 -32
- package/doc-check/version.mjs +84 -0
- package/doc-check/width.mjs +10 -54
- package/i18n.mjs +7 -0
- package/ledger-cmd.mjs +314 -61
- package/ledger-db.mjs +22 -10
- package/ledger-migrate.mjs +15 -9
- package/ledger-tools.mjs +115 -69
- package/ledger.mjs +13 -10
- package/log.mjs +2 -2
- package/manifest-schema.mjs +7 -21
- package/manifest.mjs +4 -4
- package/memory/code-search.mjs +5 -14
- package/memory/docs.mjs +5 -14
- package/memory/read-gate.mjs +26 -0
- package/package.json +1 -1
- package/permission.mjs +7 -1
- package/prompts/advisor-design.md +95 -44
- package/prompts/advisor-round1.md +75 -27
- package/prompts/advisor-round2.md +68 -31
- package/prompts/advisor-round3.md +77 -33
- package/prompts/common.md +283 -113
- package/prompts/consult-base.md +16 -9
- package/prompts/discipline-engineering.md +213 -83
- package/prompts/discipline-normal.md +137 -85
- package/prompts/persona-coder.md +8 -6
- package/prompts/persona-eng-coder.md +42 -21
- package/prompts/persona-eng-designer.md +101 -43
- package/prompts/persona-engineering.md +376 -127
- package/prompts/persona-explore.md +3 -3
- package/prompts/persona-normal.md +29 -21
- package/prompts/persona-plan.md +7 -7
- package/prompts/subagent-base.md +4 -4
- package/provider/anthropic.mjs +4 -2
- package/provider/core.mjs +4 -2
- package/provider/google.mjs +5 -1
- package/provider/responses-request.mjs +3 -2
- package/session-lifecycle.mjs +6 -6
- package/team.mjs +2 -1
- package/token-window.mjs +1 -1
- package/tool-docs/batch.md +1 -1
- package/tool-docs/doc_check.md +47 -12
- package/tool-docs/fetch.md +3 -2
- package/tool-docs/ledger.md +7 -5
- package/tool-docs/manifest-read.md +8 -0
- package/tool-docs/manifest.md +9 -8
- package/tool-docs/ssh.md +23 -6
- package/tool-docs/subagent.md +7 -12
- package/tool-docs/websearch.md +3 -1
- package/tools/ssh.mjs +24 -11
- package/tools/web-deepseek.mjs +172 -0
- package/tools/web.mjs +29 -4
|
@@ -7,14 +7,17 @@
|
|
|
7
7
|
* - 池:`_sshSessions`(照 `bash-async.mjs` 池先例——载体经 `carrierField` 吸收);帽 `SSH_SESSION_MAX = 4`
|
|
8
8
|
* 条存活会话;超限**显式拒**(零静默关旧)。
|
|
9
9
|
* - 连接(预探路——父侧 2026-10-14 裁定 A):探连 = **全会话形** + `NumberOfPasswordPrompts=0`;探连成功
|
|
10
|
-
* ⇒ **该连接直接升格为会话**(密钥机全程恰 1 次连接);认证类失败 ⇒ 终止探连 →
|
|
11
|
-
*
|
|
10
|
+
* ⇒ **该连接直接升格为会话**(密钥机全程恰 1 次连接);认证类失败 ⇒ 终止探连 → **先读保存件**(⑪
|
|
11
|
+
* 命中 ⇒ 直接带密会话,零窗)→ 无 / 不可用 ⇒ 弹凭据窗 → **单次**带密会话(`NumberOfPasswordPrompts=1`);
|
|
12
|
+
* 指纹 / 不可达 / 连接超时 ⇒ **弹窗之前硬停**(E3 语义)。
|
|
13
|
+
* - 保存面(用户裁定 A+B · 台账 #1602):⑩ 身份面(`identityFile` / `auth` 随 ⑨ 记录)∥ ⑪ 保存件读写 /
|
|
14
|
+
* 解密 / `lastUsedAt` / fail-soft / 窗内勾选 ⇒ 单点 `agent-tools/ssh-vault.mjs`;⑫ `credentials` / `forget`。
|
|
12
15
|
* - 生命周期:闲置 600 s 自动关(digest 注记)· `close` = stdin 结束 → 1.5 s 宽限 → 杀树 · 回合中断存活
|
|
13
16
|
* (F2 豁免)· 会话中止逐条杀 + 清池(`agent-tools/async-discard.mjs` `SSH_SPEC`)· depth>0 排除
|
|
14
17
|
* (`agent/helpers.mjs` `SUBAGENT_TOOL_EXCLUSIONS`)。
|
|
15
|
-
* 模块图:→ ssh-transport.mjs(运行面)· agent/helpers.mjs(轮转清理)·
|
|
16
|
-
* context.mjs(pushReal)· async-settle.mjs(池 ∥ 载体 ∥ 墓碑 ∥ 控制器链)·
|
|
17
|
-
* (取号 ∥ 令牌 ∥ 入池守卫)——叶子向、无环。
|
|
18
|
+
* 模块图:→ ssh-transport.mjs(运行面)· ssh-vault.mjs(保存件单点)· agent/helpers.mjs(轮转清理)·
|
|
19
|
+
* tools/process-tree.mjs(树杀)· context.mjs(pushReal)· async-settle.mjs(池 ∥ 载体 ∥ 墓碑 ∥ 控制器链)·
|
|
20
|
+
* subagent-scheduler.mjs(取号 ∥ 令牌 ∥ 入池守卫)——叶子向、无环。
|
|
18
21
|
*/
|
|
19
22
|
import { createWriteStream, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
20
23
|
import { join } from "node:path"
|
|
@@ -27,10 +30,11 @@ import {
|
|
|
27
30
|
} from "./async-settle.mjs"
|
|
28
31
|
import { assertPoolKeyFree, consumeSubagentToken, nextSubagentId } from "./subagent-scheduler.mjs"
|
|
29
32
|
import {
|
|
30
|
-
SSH_RETURN_CAP, SSH_SETTLE_MS, SSH_TIMEOUT_MS, bodyText, capText, classifyFailure, createMask, drain,
|
|
33
|
+
SSH_KEEPALIVE_MS, SSH_RETURN_CAP, SSH_SETTLE_MS, SSH_TIMEOUT_MS, bodyText, capText, classifyFailure, createMask, drain,
|
|
31
34
|
failureText, idLabel, settleNote, sshConfigDir, spawnSession, statusLine, unlinkAskpass, waitSettled,
|
|
32
35
|
writeAskpassWrapper,
|
|
33
36
|
} from "./ssh-transport.mjs"
|
|
37
|
+
import { askCredential, deleteEntry, hostKey, resolveSecret, saveEntry, touchEntry, vaultAvailable } from "./ssh-vault.mjs"
|
|
34
38
|
|
|
35
39
|
/** 并存活会话帽(照 `BG_TASK_MAX` 族)。超限显式拒。 */
|
|
36
40
|
export const SSH_SESSION_MAX = 4
|
|
@@ -42,10 +46,16 @@ const CLOSE_GRACE_MS = 1500
|
|
|
42
46
|
/** 非密预填档(§6.22 ⑨):`configDir/ssh-hosts.json`——白名单字段,密码零字段。 */
|
|
43
47
|
const HOSTS_FILE = "ssh-hosts.json"
|
|
44
48
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
/** 测试态时间缝(B3 ∥ S2 ∥ S4 判据落形——§6.22 ④):闲置 `idleMs` ∥ 保活 `keepAliveMs`;
|
|
50
|
+
* 缺省取常量、传值 `??` 回落、`finally` 复位(`_resetSshTimeoutsForTest`);**不进用户参数面**。 */
|
|
51
|
+
let _timeouts = { idleMs: null, keepAliveMs: null }
|
|
52
|
+
export function _setSshTimeoutsForTest({ idleMs, keepAliveMs } = {}) {
|
|
53
|
+
if (idleMs !== undefined) _timeouts.idleMs = idleMs ?? null
|
|
54
|
+
if (keepAliveMs !== undefined) _timeouts.keepAliveMs = keepAliveMs ?? null
|
|
55
|
+
}
|
|
56
|
+
export function _resetSshTimeoutsForTest() { _timeouts = { idleMs: null, keepAliveMs: null } }
|
|
57
|
+
const idleMs = () => _timeouts.idleMs ?? SSH_IDLE_MS
|
|
58
|
+
const keepAliveMs = () => _timeouts.keepAliveMs ?? SSH_KEEPALIVE_MS
|
|
49
59
|
const toolResultsDir = () => join(sshConfigDir(), "tool-results")
|
|
50
60
|
|
|
51
61
|
/** 池(写侧:父字段优先 / 借用 history 载体——`ensureBgPool` 同式)。 */
|
|
@@ -155,8 +165,6 @@ export function killSshTree(entry) {
|
|
|
155
165
|
// ─── 非密预填档(§6.22 ⑨)──────────────────────────────────────────────────
|
|
156
166
|
|
|
157
167
|
const hostsFile = () => join(sshConfigDir(), HOSTS_FILE)
|
|
158
|
-
/** 读键 = `host[:port]`(缺省 22)。 */
|
|
159
|
-
const hostKey = (host, port) => (port && Number(port) !== 22 ? `${host}:${port}` : String(host))
|
|
160
168
|
|
|
161
169
|
function lookupHostRecord(host, port) {
|
|
162
170
|
try {
|
|
@@ -166,15 +174,20 @@ function lookupHostRecord(host, port) {
|
|
|
166
174
|
} catch { return null }
|
|
167
175
|
}
|
|
168
176
|
|
|
169
|
-
/** 写入 = **认证成功后**;白名单 = 非密字段(host / port / user / lastUsedAt
|
|
170
|
-
|
|
171
|
-
|
|
177
|
+
/** 写入 = **认证成功后**;白名单 = 非密字段(host / port / user / lastUsedAt / `identityFile` / `auth`——
|
|
178
|
+
* 密码零字段;⑩ 身份面)。无 `user` 但有 `identityFile` 时也写(身份面复用不依赖 user 预填)。 */
|
|
179
|
+
function saveHostRecord(host, port, user, { identity = null, auth = null } = {}) {
|
|
180
|
+
if (!user && !identity) return
|
|
172
181
|
try {
|
|
173
182
|
mkdirSync(sshConfigDir(), { recursive: true })
|
|
174
183
|
let all = {}
|
|
175
184
|
try { all = JSON.parse(readFileSync(hostsFile(), "utf8")) ?? {} } catch { all = {} }
|
|
176
185
|
if (typeof all !== "object" || all === null) all = {}
|
|
177
|
-
all[hostKey(host, port)] = {
|
|
186
|
+
all[hostKey(host, port)] = {
|
|
187
|
+
host, port: Number(port) || 22, user: user ?? null, lastUsedAt: new Date().toISOString(),
|
|
188
|
+
...(identity ? { identityFile: identity } : {}),
|
|
189
|
+
...(auth ? { auth } : {}),
|
|
190
|
+
}
|
|
178
191
|
writeFileSync(hostsFile(), JSON.stringify(all, null, 2), "utf8")
|
|
179
192
|
} catch { /* 预填 = 增益面——写失败不进连接语义 */ }
|
|
180
193
|
}
|
|
@@ -182,10 +195,12 @@ function saveHostRecord(host, port, user) {
|
|
|
182
195
|
// ─── 动作(工具面薄接线消费)────────────────────────────────────────────────
|
|
183
196
|
|
|
184
197
|
/**
|
|
185
|
-
* 连接(§6.22 ⑤ + 裁定 A
|
|
198
|
+
* 连接(§6.22 ⑤ + 裁定 A 预探路 + 保存面):探连 ⇒ 升格 ∥ 认证失败 ⇒ **先读保存件**(命中 ⇒ 带密
|
|
199
|
+
* 会话零窗)⇒ 无 / 不可用 / 被拒 ⇒ 凭据窗(窗内可勾选保存)⇒ 单次带密会话。
|
|
200
|
+
* 优先级铁序(⑩):保存件**恒不先于探连**——密钥可用 ⇒ 保存件零读取(S6/N6/B7)。
|
|
186
201
|
* @returns {{ text?: string, error?: string }}
|
|
187
202
|
*/
|
|
188
|
-
export async function connectSsh(parent, ctx, { host, user, port, label, promptPattern } = {}) {
|
|
203
|
+
export async function connectSsh(parent, ctx, { host, user, port, label, promptPattern, identityFile } = {}) {
|
|
189
204
|
if (!parent) return { error: "no agent context — ssh sessions need a session-level pool (depth-0 only)" }
|
|
190
205
|
if (typeof host !== "string" || !host.trim()) return { error: "connect requires host" }
|
|
191
206
|
const pool = sshPool(parent)
|
|
@@ -201,8 +216,9 @@ export async function connectSsh(parent, ctx, { host, user, port, label, promptP
|
|
|
201
216
|
try { promptRe = new RegExp(String(promptPattern)) } catch (e) { return { error: `promptPattern is not a valid regex: ${e.message}` } }
|
|
202
217
|
}
|
|
203
218
|
const record = lookupHostRecord(host, port)
|
|
204
|
-
const effectiveUser = user || record?.user || null
|
|
219
|
+
const effectiveUser = user || record?.user || null // 判定序:connect 参数 → ⑨ 记录 → 皆缺视同无
|
|
205
220
|
const userFromRecord = !user && record?.user ? record.user : null
|
|
221
|
+
const identity = identityFile || record?.identityFile || null // ⑩:参数 → ⑨ 记录
|
|
206
222
|
const id = nextSubagentId(parent)
|
|
207
223
|
consumeSubagentToken(parent, id, "ssh connect", "ssh")
|
|
208
224
|
await cleanupOldToolResults(toolResultsDir())
|
|
@@ -222,25 +238,37 @@ export async function connectSsh(parent, ctx, { host, user, port, label, promptP
|
|
|
222
238
|
const reason = await waitSettled(entry, { signal: ctx?.signal ?? null })
|
|
223
239
|
return { reason, waitedMs: Date.now() - t0 }
|
|
224
240
|
}
|
|
225
|
-
/**
|
|
226
|
-
|
|
241
|
+
/** 起跑(探连 ∥ 带密会话同径;`-i` 两径同带——追加候选身份,不夺既有尝试序列)。
|
|
242
|
+
* 每次起跑前清该轮缓冲:分类 / 沉降只看**本次连接**的输出(探连错误的残渣不得误判下一次尝试)。 */
|
|
243
|
+
const spawn = (sessionUser, secret) => {
|
|
244
|
+
entry.buf = ""
|
|
245
|
+
entry.bufDropped = false
|
|
246
|
+
return spawnSession(entry, { user: sessionUser, port, secret, identityFile: identity, keepAliveMs: keepAliveMs() })
|
|
247
|
+
}
|
|
248
|
+
/** 升格为会话(探连成功 ∥ 带密会话成功同径);⑩ 身份面(auth)随 ⑨ 记录回写。 */
|
|
249
|
+
const promote = (reason, waitedMs, { auth = "key", extra = [] } = {}) => {
|
|
227
250
|
entry.state = "ready"
|
|
228
251
|
entry.lastActivityAt = Date.now()
|
|
229
252
|
armIdle(entry)
|
|
230
|
-
saveHostRecord(entry.host, entry.port, entry.user)
|
|
253
|
+
saveHostRecord(entry.host, entry.port, entry.user, { identity, auth })
|
|
231
254
|
const { text, dropped } = drain(entry)
|
|
232
255
|
const notes = [
|
|
233
256
|
`— connected (${settleNote(entry, reason, waitedMs)})`,
|
|
234
257
|
`log: ${entry.logPath}`,
|
|
235
258
|
userFromRecord ? `user "${userFromRecord}" came from the local ssh-hosts record` : "",
|
|
259
|
+
...extra,
|
|
236
260
|
dropped ? "in-memory buffer overflowed — full output in the log" : "",
|
|
237
261
|
].filter(Boolean)
|
|
238
262
|
return { text: `${statusLine(entry)}\n${bodyText(capText(text, SSH_RETURN_CAP, entry))}\n${notes.join("; ")}` }
|
|
239
263
|
}
|
|
264
|
+
/** 认证类失败分类(spawn 错误 ∥ 输出判据——两径同式)。 */
|
|
265
|
+
const classify = () => (entry.spawnError
|
|
266
|
+
? { kind: "spawn", message: `ssh client not available (${entry.spawnError}) — install system OpenSSH or check PATH` }
|
|
267
|
+
: classifyFailure(entry.buf))
|
|
240
268
|
try {
|
|
241
269
|
assertPoolKeyFree(pool, id, "ssh")
|
|
242
270
|
pool.set(String(id), entry)
|
|
243
|
-
|
|
271
|
+
spawn(effectiveUser, null) // 探连:全会话形 + NumberOfPasswordPrompts=0(不读保存件——铁序)
|
|
244
272
|
let { reason, waitedMs } = await settle()
|
|
245
273
|
// 「已就绪」判据要求进程尚在(静默窗与退出同刻的竞争面——升格一条死连接不成立)。
|
|
246
274
|
if ((reason === "prompt" || reason === "silence") && entry.exitInfo) reason = "exited"
|
|
@@ -252,20 +280,54 @@ export async function connectSsh(parent, ctx, { host, user, port, label, promptP
|
|
|
252
280
|
hardClose(entry, "closed", "consumed")
|
|
253
281
|
return { error: `ssh connect did not settle within ${SSH_TIMEOUT_MS}ms (${entry.host}) — the connection is closed; retry or increase the wait` }
|
|
254
282
|
}
|
|
283
|
+
let auth = "key"
|
|
284
|
+
const flowNotes = []
|
|
255
285
|
if (reason === "exited") {
|
|
256
|
-
const cls =
|
|
257
|
-
? { kind: "spawn", message: `ssh client not available (${entry.spawnError}) — install system OpenSSH or check PATH` }
|
|
258
|
-
: classifyFailure(entry.buf)
|
|
286
|
+
const cls = classify()
|
|
259
287
|
if (!cls || cls.kind !== "auth") {
|
|
260
288
|
const text = failureText(entry, cls)
|
|
261
289
|
hardClose(entry, "closed", "connect-failed") // 杀树收尾(finalize 只出池——活子进程会成无句柄悬挂)
|
|
262
290
|
return { error: text }
|
|
263
291
|
}
|
|
264
|
-
// 认证类失败 ⇒
|
|
265
|
-
|
|
292
|
+
// 认证类失败 ⇒ **先读保存件**(⑪;有效 user 判定序缺 ⇒ 零消费零删除——直接落窗)。
|
|
293
|
+
let saved = null
|
|
294
|
+
let failNote = ""
|
|
295
|
+
if (effectiveUser) {
|
|
296
|
+
const got = await resolveSecret(host, port, effectiveUser)
|
|
297
|
+
if (got.state === "ok") saved = got
|
|
298
|
+
else if (got.state === "damaged") failNote = "the saved-credential store is damaged — it was left untouched"
|
|
299
|
+
else if (got.state === "undecryptable") failNote = "the saved credential could not be decrypted — the entry was kept"
|
|
300
|
+
else if (got.state === "unavailable") failNote = "saved credentials are not available on this platform"
|
|
301
|
+
// missing ⇒ 静默(正常首连态)
|
|
302
|
+
}
|
|
303
|
+
if (saved) {
|
|
304
|
+
entry.user = effectiveUser
|
|
305
|
+
entry.mask.add(saved.value) // 遮蔽先注册后写入(回显面零泄)
|
|
306
|
+
spawn(entry.user, saved.value) // 带密会话(PP=1)——命中保存件 ⇒ 零窗
|
|
307
|
+
;({ reason, waitedMs } = await settle())
|
|
308
|
+
if ((reason === "prompt" || reason === "silence") && entry.exitInfo) reason = "exited"
|
|
309
|
+
if (reason === "prompt" || reason === "silence") {
|
|
310
|
+
touchEntry(host, port) // lastUsedAt = 复用成功时更新(写失败 = 增益面静默)
|
|
311
|
+
return promote(reason, waitedMs, { auth: "password", extra: ["saved credential used (no credential window)"] })
|
|
312
|
+
}
|
|
313
|
+
const clsSaved = classify()
|
|
314
|
+
// 只有**本次进程正常退出且为认证类**才算「服务端拒绝」;超时 / 中断 / 不可达 ⇒ 条目保留、硬停。
|
|
315
|
+
if (reason !== "exited" || clsSaved?.kind !== "auth") {
|
|
316
|
+
const text = failureText(entry, clsSaved ?? { message: "authentication rejected" }, " — the session was not established")
|
|
317
|
+
hardClose(entry, "closed", "connect-failed")
|
|
318
|
+
return { error: text }
|
|
319
|
+
}
|
|
320
|
+
// 条目被服务端拒 ⇒ 删该条目(拒绝即值已失效——避免每次重试旧值)+ 回落窗;删失败时注记如实。
|
|
321
|
+
const del = deleteEntry(host, port)
|
|
322
|
+
failNote = del.ok
|
|
323
|
+
? "the saved credential was rejected by the server — the entry was deleted"
|
|
324
|
+
: "the saved credential was rejected by the server — the store could not be updated (the entry may still be there)"
|
|
325
|
+
}
|
|
326
|
+
// 凭据窗(值只走回调返回路 + 子进程环境——NS4:零入参 ∥ 零入结果 ∥ 零落盘)。
|
|
327
|
+
const input = await askCredential(ctx, { host, user: effectiveUser, port, sessionId: id, offerSave: vaultAvailable() })
|
|
266
328
|
if (!input) {
|
|
267
329
|
hardClose(entry, "closed", "consumed")
|
|
268
|
-
return { error: `ssh connect to ${entry.host} cancelled — no credential was provided (no connection was attempted with one)` }
|
|
330
|
+
return { error: `ssh connect to ${entry.host} cancelled — no credential was provided (no connection was attempted with one)${failNote ? ` (${failNote}; the credential window was shown)` : ""}` }
|
|
269
331
|
}
|
|
270
332
|
if (!String(input.password ?? "").length) {
|
|
271
333
|
hardClose(entry, "closed", "consumed")
|
|
@@ -273,7 +335,7 @@ export async function connectSsh(parent, ctx, { host, user, port, label, promptP
|
|
|
273
335
|
}
|
|
274
336
|
entry.user = input.user || effectiveUser || null
|
|
275
337
|
entry.mask.add(input.password) // 遮蔽先注册后写入(回显面零泄)
|
|
276
|
-
|
|
338
|
+
spawn(entry.user, input.password) // 单次带密会话(PP=1)
|
|
277
339
|
;({ reason, waitedMs } = await settle())
|
|
278
340
|
if ((reason === "prompt" || reason === "silence") && entry.exitInfo) reason = "exited"
|
|
279
341
|
if (reason !== "prompt" && reason !== "silence") {
|
|
@@ -283,8 +345,19 @@ export async function connectSsh(parent, ctx, { host, user, port, label, promptP
|
|
|
283
345
|
hardClose(entry, "closed", "connect-failed")
|
|
284
346
|
return { error: text }
|
|
285
347
|
}
|
|
348
|
+
// 认证成功后才写(opt-in——严格布尔;写失败不影响连接:增益面语义)
|
|
349
|
+
if (input.save === true && entry.user) {
|
|
350
|
+
const savedRes = await saveEntry(host, port, { user: entry.user, value: input.password })
|
|
351
|
+
flowNotes.push(savedRes.ok
|
|
352
|
+
? "the credential was saved for this host (encrypted with this Windows user account)"
|
|
353
|
+
: "the credential could not be saved (the connection is unaffected)")
|
|
354
|
+
} else if (input.save === true) {
|
|
355
|
+
flowNotes.push("the credential was not saved (no login user was given for this host)")
|
|
356
|
+
}
|
|
357
|
+
if (failNote) flowNotes.unshift(`${failNote}; the credential window was shown`)
|
|
358
|
+
auth = "password"
|
|
286
359
|
}
|
|
287
|
-
return promote(reason, waitedMs)
|
|
360
|
+
return promote(reason, waitedMs, { auth, extra: flowNotes })
|
|
288
361
|
} catch (e) {
|
|
289
362
|
hardClose(entry, "closed", "connect-failed") // 异常径同走杀树(子进程可能仍在飞)
|
|
290
363
|
return { error: e?.noUi ? `Error: ${e.message}` : `ssh connect failed: ${e?.message ?? String(e)}` }
|
|
@@ -293,36 +366,6 @@ export async function connectSsh(parent, ctx, { host, user, port, label, promptP
|
|
|
293
366
|
}
|
|
294
367
|
}
|
|
295
368
|
|
|
296
|
-
/** 凭据窗(§6.22 ⑧):无 UI ⇒ 可读错(不挂起、不静默——`question.mjs` 无 UI 抛例同旨);词面 = i18n 键。
|
|
297
|
-
* 端侧回调自身拒绝(CLI `refuseSecretInput()` 形:抛可读句)⇒ 同族直出(`noUi` 标记 = 失败句不另加前缀)。 */
|
|
298
|
-
async function askCredential(ctx, { host, user, port, sessionId }) {
|
|
299
|
-
if (typeof ctx?.onSecretInput !== "function") {
|
|
300
|
-
const err = new Error(t("ssh.no_ui"))
|
|
301
|
-
err.noUi = true // 可读句直出(catch 面不另加前缀——失败句单源 = 核字典)
|
|
302
|
-
throw err
|
|
303
|
-
}
|
|
304
|
-
let res
|
|
305
|
-
try {
|
|
306
|
-
res = await ctx.onSecretInput({
|
|
307
|
-
kind: "ssh-credential",
|
|
308
|
-
sessionId: sessionId != null ? `ssh#${sessionId}` : null,
|
|
309
|
-
host, user: user ?? null, port: port ?? null,
|
|
310
|
-
fields: [
|
|
311
|
-
{ name: "user", masked: false, required: false, prefill: user ?? "" },
|
|
312
|
-
{ name: "password", masked: true, required: true, prefill: "" },
|
|
313
|
-
],
|
|
314
|
-
keys: { title: "ssh.credentialTitle", prompt: "ssh.credentialPrompt" },
|
|
315
|
-
})
|
|
316
|
-
} catch (e) {
|
|
317
|
-
// 端侧拒绝(无交互面 / 载荷成形外)⇒ 同族可读句直出(不裹「connect failed」前缀)。
|
|
318
|
-
const err = new Error(e?.message ?? String(e))
|
|
319
|
-
err.noUi = true
|
|
320
|
-
throw err
|
|
321
|
-
}
|
|
322
|
-
if (!res || typeof res !== "object") return null
|
|
323
|
-
return res
|
|
324
|
-
}
|
|
325
|
-
|
|
326
369
|
/** 缓冲溢出注记(三动作同式——丢弃面显式化;全量恒在 log)。 */
|
|
327
370
|
const droppedNote = (dropped) => (dropped ? "\n— in-memory buffer overflowed — full output in the log" : "")
|
|
328
371
|
|
|
@@ -437,3 +480,7 @@ export function listSsh(parent) {
|
|
|
437
480
|
// 常量转口(工具面 / 测试档经本档取——机制档不重复导出)。
|
|
438
481
|
export { SSH_SETTLE_MS, SSH_TIMEOUT_MS, SSH_RETURN_CAP }
|
|
439
482
|
export { _setSshBinaryForTest, _setSshConfigDirForTest } from "./ssh-transport.mjs"
|
|
483
|
+
|
|
484
|
+
// 保存凭据管理面(§6.22 ⑫):`credentials` / `forget` 实体 = `ssh-vault.mjs`(核单点——本档只转口;
|
|
485
|
+
// 拆档由 = 机制档触 500 顾问线,§6.22 落点表候选拆分)。
|
|
486
|
+
export { credentialsSsh, forgetSsh } from "./ssh-vault.mjs"
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { chmodSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"
|
|
19
19
|
import { spawn } from "node:child_process"
|
|
20
|
+
import { homedir } from "node:os"
|
|
20
21
|
import { fileURLToPath } from "node:url"
|
|
21
22
|
import { configDir } from "../config.mjs"
|
|
22
23
|
import { makeDecoder, sanitizeOutput } from "../tools/shared.mjs"
|
|
@@ -28,6 +29,8 @@ export const SSH_SETTLE_MS = 800
|
|
|
28
29
|
export const SSH_TIMEOUT_MS = 10_000
|
|
29
30
|
/** 单次返回帽(§6.22 ⑥):保尾 + 截断注记;全量恒在 log。 */
|
|
30
31
|
export const SSH_RETURN_CAP = 8000
|
|
32
|
+
/** 保活缺省(§6.22 ④:`ServerAliveInterval=30`;测试态时间缝按 ms 传入后换算)。 */
|
|
33
|
+
export const SSH_KEEPALIVE_MS = 30_000
|
|
31
34
|
/** 秘密环境变量名(§6.22 ②「秘密传递」行)。 */
|
|
32
35
|
export const SECRET_ENV = "TC_SSH_SECRET"
|
|
33
36
|
/** 内存缓冲保留量(仅覆盖返回帽;全量恒在 log)。 */
|
|
@@ -81,18 +84,28 @@ export function buildSshEnv({ wrapper, secret }) {
|
|
|
81
84
|
return nodeChildEnv(env)
|
|
82
85
|
}
|
|
83
86
|
|
|
87
|
+
/** `~` 展开(`identityFile` 记录 / 参数——`~/.ssh/id_ed25519` 同式;非 `~` 开头原样)。 */
|
|
88
|
+
function expandHome(file) {
|
|
89
|
+
const raw = String(file ?? "")
|
|
90
|
+
if (!/^~(?=$|[\\/])/.test(raw)) return raw
|
|
91
|
+
return `${homedir()}${raw.slice(1)}`
|
|
92
|
+
}
|
|
93
|
+
|
|
84
94
|
/** 会话命令形(§6.22 ⑤):`-tt` 强制远端 PTY + 保活三件 + 单提示;`port`/`user` 未给 ⇒ 不下发。
|
|
95
|
+
* `identityFile` 显式给出 / 记录有值 ⇒ `-i <路径>`(追加候选身份,不夺本机配置与 agent 的既有尝试序列)。
|
|
85
96
|
* 预探 = `NumberOfPasswordPrompts=0`(password ∥ keyboard-interactive 两径同被短路——
|
|
86
97
|
* `sshconnect2.c` `userauth_passwd` / `userauth_kbdint` 两处同判 `attempt++ >= n`;私钥口令提示
|
|
87
98
|
* 亦被短路:`load_identity_file` 循环 `i <= number_of_password_prompts`);带密 = 1(单提示)。 */
|
|
88
|
-
export function buildSshArgs({ host, user, port, probe }) {
|
|
99
|
+
export function buildSshArgs({ host, user, port, probe, identityFile = null, keepAliveMs = SSH_KEEPALIVE_MS }) {
|
|
100
|
+
const keepAliveS = Math.max(1, Math.round((Number(keepAliveMs) || SSH_KEEPALIVE_MS) / 1000))
|
|
89
101
|
return [
|
|
90
102
|
"-tt",
|
|
91
103
|
"-o", "ConnectTimeout=10",
|
|
92
|
-
"-o",
|
|
104
|
+
"-o", `ServerAliveInterval=${keepAliveS}`,
|
|
93
105
|
"-o", "ServerAliveCountMax=4",
|
|
94
106
|
"-o", `NumberOfPasswordPrompts=${probe ? 0 : 1}`,
|
|
95
107
|
...(port ? ["-p", String(port)] : []),
|
|
108
|
+
...(identityFile ? ["-i", expandHome(identityFile)] : []),
|
|
96
109
|
user ? `${user}@${host}` : host,
|
|
97
110
|
]
|
|
98
111
|
}
|
|
@@ -184,10 +197,10 @@ export function attachChild(entry, child) {
|
|
|
184
197
|
|
|
185
198
|
/** 起跑一条 ssh(探连 ∥ 带密会话同径)。重起(探连 → 带密会话)⇒ 判定面复位:exitInfo ∥
|
|
186
199
|
* spawnError 属**上一次**进程(残留会让新连接的成功判定被误读成「已退出」)。 */
|
|
187
|
-
export function spawnSession(entry, { user, port, secret }) {
|
|
200
|
+
export function spawnSession(entry, { user, port, secret, identityFile = null, keepAliveMs = SSH_KEEPALIVE_MS }) {
|
|
188
201
|
entry.exitInfo = null
|
|
189
202
|
entry.spawnError = null
|
|
190
|
-
const child = spawn(sshBinary(), [...sshArgPrefix(), ...buildSshArgs({ host: entry.host, user, port, probe: secret == null })], {
|
|
203
|
+
const child = spawn(sshBinary(), [...sshArgPrefix(), ...buildSshArgs({ host: entry.host, user, port, probe: secret == null, identityFile, keepAliveMs })], {
|
|
191
204
|
stdio: ["pipe", "pipe", "pipe"],
|
|
192
205
|
windowsHide: true,
|
|
193
206
|
detached: process.platform !== "win32",
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssh-vault.mjs — SSH 保存凭据单点(保存件读写 ∥ 系统级加密后端 ∥ 清单 / 删除 ∥ 可用性判定 ∥ 凭据窗获取链)。
|
|
3
|
+
* 设计 = `docs/core/design/TOOLS.md` §6.22 ⑪(D-TO19 · 用户裁定 A+B);台账 #1602。
|
|
4
|
+
*
|
|
5
|
+
* 档:`configDir/ssh-credentials.json`(**私档**——用户域,仓库零落点;与 ⑨ 非密档 `ssh-hosts.json`
|
|
6
|
+
* 分档)。形 = `{ version: 1, entries: { "<host[:port]>": { user, scheme, blob, savedAt, lastUsedAt? } } }`
|
|
7
|
+
* ——值只以**密文 blob** 落盘(明文零落盘,NS6)。
|
|
8
|
+
*
|
|
9
|
+
* 加密 = Windows DPAPI(经系统 PowerShell 宿主):加密 `ConvertTo-SecureString -AsPlainText -Force`
|
|
10
|
+
* → `ConvertFrom-SecureString`(无 `-Key` ⇒ DPAPI;Microsoft 文档逐字);解密反之,Marshal 取明文。
|
|
11
|
+
* 通道纪律(§6.22 ⑦):脚本 = 常量字面(**值不入命令行**);值以 base64 封包经子进程 **stdin**,
|
|
12
|
+
* 密文 ∥ 解出明文经 **stdout**(hex ∥ base64——交换面全 ASCII);子进程 env 删 `TC_SSH_SECRET`;
|
|
13
|
+
* 超时有界(到点杀子进程 ⇒ 按解密失败走 fail-soft)。
|
|
14
|
+
*
|
|
15
|
+
* fail-soft(§6.22 ⑪,逐形分判):档缺失 ⇒ 视同无(静默);结构损坏(JSON 不可解析)⇒ 视同无 +
|
|
16
|
+
* **原档原位不动**(下次成功保存整体覆盖重建);不可解密 / 后端抛 ⇒ 条目保留(可能是暂时态)。
|
|
17
|
+
* 删除面只涉本档(⑨ 非密记录不随删)。
|
|
18
|
+
*
|
|
19
|
+
* 观测缝(A 面四格判据落形——S1/S6/N6/B7):模块级读次计数 = **读盘入口调用数**;
|
|
20
|
+
* `_vaultReadsForTest()` / `_resetVaultReadsForTest()`——缺省生产行为不变,用例 `finally` 复位
|
|
21
|
+
* (同族先例 = §6.14 测试态缝)。
|
|
22
|
+
* 模块图:→ ssh-transport.mjs(`sshConfigDir` 测试缝单点)· ../i18n.mjs(窗词面单源)·
|
|
23
|
+
* node:child_process / node:fs——叶子向、无环。
|
|
24
|
+
*/
|
|
25
|
+
import { spawn } from "node:child_process"
|
|
26
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
27
|
+
import { join } from "node:path"
|
|
28
|
+
import { t } from "../i18n.mjs"
|
|
29
|
+
import { sshConfigDir } from "./ssh-transport.mjs"
|
|
30
|
+
|
|
31
|
+
/** 保存件档名(与 ⑨ `ssh-hosts.json` 分档)。 */
|
|
32
|
+
export const VAULT_FILE = "ssh-credentials.json"
|
|
33
|
+
/** 档形版本(结构损坏判据之一)。 */
|
|
34
|
+
export const VAULT_VERSION = 1
|
|
35
|
+
/** 后端子进程超时(到点杀子进程 ⇒ 按解密失败走 fail-soft)。 */
|
|
36
|
+
export const VAULT_TIMEOUT_MS = 15_000
|
|
37
|
+
|
|
38
|
+
/** 加密脚本(常量字面——值经 stdin;`ConvertFrom-SecureString` 无 `-Key` ⇒ DPAPI)。 */
|
|
39
|
+
const PS_ENCRYPT = [
|
|
40
|
+
"$ErrorActionPreference = 'Stop'",
|
|
41
|
+
"$b64 = [Console]::In.ReadToEnd().Trim()",
|
|
42
|
+
"$plain = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b64))",
|
|
43
|
+
"$sec = ConvertTo-SecureString -String $plain -AsPlainText -Force",
|
|
44
|
+
"[Console]::Out.Write((ConvertFrom-SecureString -SecureString $sec))",
|
|
45
|
+
].join("\n")
|
|
46
|
+
/** 解密脚本(常量字面——hex 经 stdin;解出明文 base64 经 stdout)。 */
|
|
47
|
+
const PS_DECRYPT = [
|
|
48
|
+
"$ErrorActionPreference = 'Stop'",
|
|
49
|
+
"$hex = [Console]::In.ReadToEnd().Trim()",
|
|
50
|
+
"$sec = ConvertTo-SecureString -String $hex",
|
|
51
|
+
"$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)",
|
|
52
|
+
"try { $plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }",
|
|
53
|
+
"[Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($plain)))",
|
|
54
|
+
].join("\n")
|
|
55
|
+
|
|
56
|
+
// ─── 观测缝(读次 = 读盘入口调用数)──────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
let _reads = 0
|
|
59
|
+
export function _vaultReadsForTest() { return _reads }
|
|
60
|
+
export function _resetVaultReadsForTest() { _reads = 0 }
|
|
61
|
+
|
|
62
|
+
// ─── 后端(DPAPI 经系统 PowerShell;测试缝只换命令,生产缺省不变)────────────
|
|
63
|
+
|
|
64
|
+
let _backendCommand = null
|
|
65
|
+
let _backendPrefix = []
|
|
66
|
+
export function _setVaultBackendForTest(command, prefixArgs = []) {
|
|
67
|
+
_backendCommand = command
|
|
68
|
+
_backendPrefix = Array.isArray(prefixArgs) ? prefixArgs : []
|
|
69
|
+
}
|
|
70
|
+
export function _resetVaultBackendForTest() {
|
|
71
|
+
_backendCommand = null
|
|
72
|
+
_backendPrefix = []
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const backendCommand = () => _backendCommand ?? "powershell.exe"
|
|
76
|
+
/** 可用性单点(§6.22 裁决 ④:本批仅 Windows DPAPI;缺席 ⇒ 保存不可用,连接照常)。 */
|
|
77
|
+
export const vaultAvailable = () => _backendCommand !== null || process.platform === "win32"
|
|
78
|
+
|
|
79
|
+
/** 后端往返(脚本常量 + 输入 stdin → stdout;超时杀进程)。 */
|
|
80
|
+
function runBackend(script, input) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const env = { ...process.env }
|
|
83
|
+
delete env.TC_SSH_SECRET // 防残留(新增链路零 env;§6.22 ⑦)
|
|
84
|
+
const child = spawn(backendCommand(), [..._backendPrefix, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], {
|
|
85
|
+
stdio: ["pipe", "pipe", "pipe"], windowsHide: true, env,
|
|
86
|
+
})
|
|
87
|
+
let out = ""
|
|
88
|
+
let err = ""
|
|
89
|
+
let done = false
|
|
90
|
+
const finish = (fn, value) => { if (done) return; done = true; clearTimeout(timer); fn(value) }
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
try { child.kill() } catch { /* 尽力面 */ }
|
|
93
|
+
finish(reject, new Error("vault backend timed out"))
|
|
94
|
+
}, VAULT_TIMEOUT_MS)
|
|
95
|
+
timer.unref?.()
|
|
96
|
+
child.stdout.on("data", (d) => { out += d })
|
|
97
|
+
child.stderr.on("data", (d) => { err += d })
|
|
98
|
+
child.on("error", (e) => finish(reject, new Error(`vault backend unavailable: ${e?.message ?? String(e)}`)))
|
|
99
|
+
child.on("exit", (code) => {
|
|
100
|
+
if (code !== 0) return finish(reject, new Error(`vault backend failed (${code}): ${err.trim().slice(0, 200)}`))
|
|
101
|
+
finish(resolve, out.trim())
|
|
102
|
+
})
|
|
103
|
+
child.stdin.on("error", () => { /* 退出竞态——判定面 = exit code */ })
|
|
104
|
+
child.stdin.end(input)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 加密:值 → hex 密文(值只经 stdin)。 */
|
|
109
|
+
async function encryptValue(value) {
|
|
110
|
+
const b64 = Buffer.from(String(value), "utf8").toString("base64")
|
|
111
|
+
const hex = await runBackend(PS_ENCRYPT, b64)
|
|
112
|
+
if (!/^[0-9a-f]+$/i.test(hex)) throw new Error("vault backend returned an unexpected ciphertext")
|
|
113
|
+
return hex
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 解密:hex 密文 → 明文(坏密文 / 后端抛 ⇒ reject——调用面走 fail-soft)。 */
|
|
117
|
+
async function decryptBlob(blob) {
|
|
118
|
+
const b64 = await runBackend(PS_DECRYPT, String(blob ?? "").trim())
|
|
119
|
+
return Buffer.from(b64, "base64").toString("utf8")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── 档(读 / 写 / 结构判定)────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
const vaultFile = () => join(sshConfigDir(), VAULT_FILE)
|
|
125
|
+
/** 读键单源(`host[:port]`,缺省 22;⑨ 记录面同引用)。 */
|
|
126
|
+
export const hostKey = (host, port) => (port && Number(port) !== 22 ? `${host}:${port}` : String(host))
|
|
127
|
+
|
|
128
|
+
/** 读盘入口(**唯一读次计数点**)。三态 = ok ∥ missing(静默视同无)∥ damaged(视同无,原档不动)。 */
|
|
129
|
+
function readVaultFile() {
|
|
130
|
+
_reads += 1
|
|
131
|
+
let raw
|
|
132
|
+
try { raw = readFileSync(vaultFile(), "utf8") } catch (e) {
|
|
133
|
+
return e?.code === "ENOENT" ? { state: "missing" } : { state: "damaged", unreadable: true }
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const parsed = JSON.parse(raw)
|
|
137
|
+
const entries = parsed?.entries
|
|
138
|
+
if (parsed?.version !== VAULT_VERSION || !entries || typeof entries !== "object" || Array.isArray(entries)) {
|
|
139
|
+
return { state: "damaged" }
|
|
140
|
+
}
|
|
141
|
+
return { state: "ok", entries }
|
|
142
|
+
} catch { return { state: "damaged" } }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 写盘单点(结构损坏的重建 = 调用面给空表整体覆盖——原档在此之前不动)。 */
|
|
146
|
+
function writeVaultFile(entries) {
|
|
147
|
+
mkdirSync(sshConfigDir(), { recursive: true })
|
|
148
|
+
const body = JSON.stringify({ version: VAULT_VERSION, entries }, null, 2)
|
|
149
|
+
writeFileSync(vaultFile(), body, { encoding: "utf8", mode: 0o600 })
|
|
150
|
+
try { chmodSync(vaultFile(), 0o600) } catch { /* win32 无 POSIX mode——尽力面 */ }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 查条目(单次读盘):条目 `user` 与「本次有效 `user`」不符 ⇒ 视同无(⑪)。
|
|
155
|
+
* @returns {{ state: "ok", key: string, entry: object } | { state: "missing"|"damaged"|"unreadable" }}
|
|
156
|
+
*/
|
|
157
|
+
export function getEntry(host, port, user) {
|
|
158
|
+
const read = readVaultFile()
|
|
159
|
+
if (read.state !== "ok") return { state: read.state }
|
|
160
|
+
const key = hostKey(host, port)
|
|
161
|
+
const entry = read.entries[key]
|
|
162
|
+
if (!entry || typeof entry !== "object") return { state: "missing" }
|
|
163
|
+
if (!user || entry.user !== user) return { state: "missing" }
|
|
164
|
+
return { state: "ok", key, entry }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 复用终段:读条目 → 解密。失败形分判(调用面各自回落窗 / 可读句):
|
|
169
|
+
* `missing`(静默)· `damaged`(可读句)· `unavailable`(后端缺席)· `undecryptable`(条目保留)。
|
|
170
|
+
* @returns {Promise<{ state: "ok", key: string, value: string } | { state: string }>}
|
|
171
|
+
*/
|
|
172
|
+
export async function resolveSecret(host, port, user) {
|
|
173
|
+
const got = getEntry(host, port, user)
|
|
174
|
+
if (got.state !== "ok") return { state: got.state }
|
|
175
|
+
if (!vaultAvailable()) return { state: "unavailable" }
|
|
176
|
+
try {
|
|
177
|
+
const value = await decryptBlob(got.entry.blob)
|
|
178
|
+
if (!value.length) return { state: "undecryptable" } // 解出空值 = 不可用(不当空口令提交)
|
|
179
|
+
return { state: "ok", key: got.key, value }
|
|
180
|
+
} catch { return { state: "undecryptable" } }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 保存(窗内勾选且认证成功后调用):加密 → 整体写档(结构损坏的旧档在此被覆盖重建)。
|
|
185
|
+
* @returns {Promise<{ ok: true, key: string } | { ok: false, reason: string }>}
|
|
186
|
+
*/
|
|
187
|
+
export async function saveEntry(host, port, { user, value } = {}) {
|
|
188
|
+
if (!vaultAvailable()) return { ok: false, reason: "unavailable" }
|
|
189
|
+
if (!String(value ?? "").length) return { ok: false, reason: "empty" }
|
|
190
|
+
let blob
|
|
191
|
+
try { blob = await encryptValue(value) } catch { return { ok: false, reason: "backend" } }
|
|
192
|
+
try {
|
|
193
|
+
const read = readVaultFile()
|
|
194
|
+
const entries = read.state === "ok" ? { ...read.entries } : {}
|
|
195
|
+
const key = hostKey(host, port)
|
|
196
|
+
entries[key] = { user: user ?? null, scheme: "dpapi", blob, savedAt: new Date().toISOString() }
|
|
197
|
+
writeVaultFile(entries)
|
|
198
|
+
return { ok: true, key }
|
|
199
|
+
} catch { return { ok: false, reason: "write" } }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 删除(`forget`):条目不存在 ⇒ 幂等(existed:false);结构损坏 ⇒ 原档不动(可读句)。 */
|
|
203
|
+
export function deleteEntry(host, port) {
|
|
204
|
+
const key = hostKey(host, port)
|
|
205
|
+
const read = readVaultFile()
|
|
206
|
+
if (read.state === "missing") return { ok: true, existed: false }
|
|
207
|
+
if (read.state !== "ok") return { ok: false, reason: "damaged" }
|
|
208
|
+
if (!Object.hasOwn(read.entries, key)) return { ok: true, existed: false }
|
|
209
|
+
const entries = { ...read.entries }
|
|
210
|
+
delete entries[key]
|
|
211
|
+
try { writeVaultFile(entries) } catch { return { ok: false, reason: "write" } }
|
|
212
|
+
return { ok: true, existed: true }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** `lastUsedAt` = 复用成功时更新;写失败 = 增益面静默(⑪)。 */
|
|
216
|
+
export function touchEntry(host, port) {
|
|
217
|
+
const read = readVaultFile()
|
|
218
|
+
if (read.state !== "ok") return
|
|
219
|
+
const key = hostKey(host, port)
|
|
220
|
+
const entry = read.entries[key]
|
|
221
|
+
if (!entry || typeof entry !== "object") return
|
|
222
|
+
try { writeVaultFile({ ...read.entries, [key]: { ...entry, lastUsedAt: new Date().toISOString() } }) } catch { /* 增益面静默 */ }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── 凭据获取链(§6.22 ⑧:`ctx.onSecretInput` 窗)────────────────────────────
|
|
226
|
+
|
|
227
|
+
/** 凭据窗(§6.22 ⑧):无 UI ⇒ 可读错(不挂起、不静默——`question.mjs` 无 UI 抛例同旨);词面 = i18n 键。
|
|
228
|
+
* 端侧回调自身拒绝(CLI `refuseSecretInput()` 形:抛可读句)⇒ 同族直出(`noUi` 标记 = 失败句不另加前缀)。
|
|
229
|
+
* `offerSave` ⇒ `fields` += `save`(`checkbox: true`——缺省不勾(opt-in);提交值 = 布尔;核侧严格判 `=== true`)。
|
|
230
|
+
* 拆档由 = 会话面超 500 顾问线(拆前 ≈513 行;设计 490 再拆线 ⇒ 按落点表候选拆分「凭据获取链迁入 `ssh-vault.mjs`」)。 */
|
|
231
|
+
export async function askCredential(ctx, { host, user, port, sessionId, offerSave = false }) {
|
|
232
|
+
if (typeof ctx?.onSecretInput !== "function") {
|
|
233
|
+
const err = new Error(t("ssh.no_ui"))
|
|
234
|
+
err.noUi = true // 可读句直出(catch 面不另加前缀——失败句单源 = 核字典)
|
|
235
|
+
throw err
|
|
236
|
+
}
|
|
237
|
+
const fields = [
|
|
238
|
+
{ name: "user", masked: false, required: false, prefill: user ?? "" },
|
|
239
|
+
{ name: "password", masked: true, required: true, prefill: "" },
|
|
240
|
+
]
|
|
241
|
+
if (offerSave) fields.push({ name: "save", masked: false, required: false, prefill: false, checkbox: true })
|
|
242
|
+
let res
|
|
243
|
+
try {
|
|
244
|
+
res = await ctx.onSecretInput({
|
|
245
|
+
kind: "ssh-credential",
|
|
246
|
+
sessionId: sessionId != null ? `ssh#${sessionId}` : null,
|
|
247
|
+
host, user: user ?? null, port: port ?? null,
|
|
248
|
+
fields,
|
|
249
|
+
keys: { title: "ssh.credentialTitle", prompt: "ssh.credentialPrompt" },
|
|
250
|
+
})
|
|
251
|
+
} catch (e) {
|
|
252
|
+
// 端侧拒绝(无交互面 / 载荷成形外)⇒ 同族可读句直出(不裹「connect failed」前缀)。
|
|
253
|
+
const err = new Error(e?.message ?? String(e))
|
|
254
|
+
err.noUi = true
|
|
255
|
+
throw err
|
|
256
|
+
}
|
|
257
|
+
if (!res || typeof res !== "object") return null
|
|
258
|
+
return res
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ─── 保存凭据管理面(§6.22 ⑫:`credentials` 只读 ∥ `forget` 写——核单点,三端同源)────────────
|
|
262
|
+
|
|
263
|
+
/** `credentials`(只读免审):host / user / 保存时间 / 最近使用——**值零出现**(清单不含 blob)。 */
|
|
264
|
+
export function credentialsSsh() {
|
|
265
|
+
const read = readVaultFile()
|
|
266
|
+
if (read.state === "missing") return { text: "(no saved ssh credentials)" }
|
|
267
|
+
if (read.state !== "ok") return { text: "saved ssh credentials: the store is damaged — the file was left untouched; delete it manually to reset the store" }
|
|
268
|
+
const rows = Object.entries(read.entries)
|
|
269
|
+
.map(([key, e]) => ({ key, user: e?.user ?? null, savedAt: e?.savedAt ?? null, lastUsedAt: e?.lastUsedAt ?? null }))
|
|
270
|
+
.sort((a, b) => a.key.localeCompare(b.key))
|
|
271
|
+
if (rows.length === 0) return { text: "(no saved ssh credentials)" }
|
|
272
|
+
const head = vaultAvailable()
|
|
273
|
+
? "saved ssh credentials (encrypted with this Windows user account):"
|
|
274
|
+
: "saved ssh credentials (the credential store is not available on this platform):"
|
|
275
|
+
const lines = rows.map((r) => {
|
|
276
|
+
const saved = r.savedAt ? ` \u00b7 saved ${r.savedAt}` : ""
|
|
277
|
+
const used = r.lastUsedAt ? ` \u00b7 last used ${r.lastUsedAt}` : ""
|
|
278
|
+
return `[${r.key}]${r.user ? ` user ${r.user}` : ""}${saved}${used}`
|
|
279
|
+
})
|
|
280
|
+
return { text: `${head}\n${lines.join("\n")}` }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** `forget`(写动作——过既有审批门):按 `host` + 可选 `port` 删条目;幂等;值零出现。
|
|
284
|
+
* 删除 ⇒ 下次连接回落凭据窗(S8);只涉保存档(⑨ 非密记录不随删)。 */
|
|
285
|
+
export function forgetSsh({ host, port } = {}) {
|
|
286
|
+
const key = hostKey(host, port)
|
|
287
|
+
const res = deleteEntry(host, port)
|
|
288
|
+
if (!res.ok) return { error: `could not update the saved-credential store (${res.reason}) — the file was left untouched` }
|
|
289
|
+
if (!res.existed) return { text: `no saved credential for ${key} — nothing to forget (the call is idempotent)` }
|
|
290
|
+
return { text: `saved credential for ${key} deleted — the next connect asks in the credential window` }
|
|
291
|
+
}
|