@thincoder/core 0.10.12 → 0.11.1
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 +42 -0
- package/advisor/messages.mjs +4 -2
- package/advisor/notice.mjs +1 -1
- package/advisor/project-context.mjs +121 -11
- package/agent/completion.mjs +13 -1
- package/agent/digest-account.mjs +73 -46
- package/agent/dispatch-run.mjs +1 -1
- package/agent/dispatch.mjs +29 -21
- package/agent/family-tools.mjs +9 -5
- package/agent/helpers.mjs +1 -94
- package/agent/offload.mjs +88 -0
- package/agent/record-results.mjs +1 -6
- package/agent/run-stages.mjs +74 -49
- package/agent/run-start.mjs +8 -8
- package/agent/setup.mjs +6 -11
- package/agent/suspension.mjs +1 -1
- package/agent/turn-loop.mjs +35 -3
- package/agent/write-face.mjs +99 -0
- package/agent/write-gate.mjs +354 -5
- package/agent-tools/advisor.mjs +127 -11
- package/agent-tools/async-settle.mjs +2 -2
- package/agent-tools/bash-async.mjs +8 -2
- package/agent-tools/batch-closeout.mjs +206 -0
- package/agent-tools/batch-gate.mjs +287 -73
- package/agent-tools/batch-ledger-bind.mjs +183 -0
- package/agent-tools/batch-ledger-link.mjs +73 -0
- package/agent-tools/batch-lifecycle.mjs +113 -354
- package/agent-tools/batch-paths.mjs +45 -40
- package/agent-tools/batch-read.mjs +1 -1
- package/agent-tools/batch-reopen.mjs +142 -0
- package/agent-tools/batch-skeleton.mjs +283 -80
- package/agent-tools/batch-unit-test-gate.mjs +107 -0
- package/agent-tools/batch.mjs +243 -50
- package/agent-tools/context.mjs +6 -3
- package/agent-tools/design-slots.mjs +24 -24
- package/agent-tools/design-token.mjs +96 -15
- package/agent-tools/doc-check.mjs +18 -2
- package/agent-tools/eng.mjs +1 -1
- package/agent-tools/escalate-async.mjs +3 -3
- package/agent-tools/manifest.mjs +8 -5
- package/agent-tools/read-history.mjs +5 -5
- package/agent-tools/review-facts.mjs +31 -13
- package/agent-tools/spawn-gates.mjs +10 -14
- package/agent-tools/ssh-session.mjs +120 -111
- package/agent-tools/ssh-transfer.mjs +210 -0
- package/agent-tools/ssh-transport.mjs +277 -46
- package/agent-tools/subagent-actions-query.mjs +1 -1
- package/agent-tools/subagent-actions.mjs +1 -1
- package/agent-tools/subagent-async.mjs +3 -2
- package/agent-tools/subagent-scheduler.mjs +43 -9
- package/agent-tools/subagent-spawn.mjs +68 -9
- package/agent-tools/subagent.mjs +16 -14
- package/agent.mjs +1 -0
- package/client-identity.mjs +70 -0
- package/config-presets.mjs +5 -3
- package/config.mjs +19 -13
- package/context.mjs +39 -8
- package/declaration.mjs +2 -2
- package/doc-check/anchors.mjs +128 -28
- package/doc-check/index.mjs +93 -27
- package/doc-check/line-refs.mjs +112 -0
- package/doc-check/paragraph.mjs +24 -8
- package/embedding.mjs +2 -0
- package/explore-distill.mjs +1 -1
- package/generate-title.mjs +3 -2
- package/i18n.mjs +1 -1
- package/index-discover.mjs +4 -1
- package/ledger-close-proof.mjs +62 -0
- package/ledger-cmd.mjs +79 -194
- package/ledger-db.mjs +29 -28
- package/ledger-migrate.mjs +4 -4
- package/ledger-read.mjs +10 -8
- package/ledger-surface.mjs +1 -1
- package/ledger-tools.mjs +10 -10
- package/ledger-variant-notice.mjs +1 -1
- package/ledger-write-ops.mjs +373 -0
- package/ledger.mjs +2 -2
- package/manifest-discovery.mjs +2 -2
- package/manifest-schema.mjs +4 -0
- package/memory/memory-tool.mjs +5 -2
- package/memory/schema.mjs +5 -26
- package/model-specs.mjs +6 -5
- package/package.json +1 -1
- package/path-resolve.mjs +217 -0
- package/permission.mjs +4 -0
- package/prompt-overlays.mjs +5 -0
- package/prompts/advisor-design.md +9 -6
- package/prompts/advisor-round1.md +4 -3
- package/prompts/advisor-round2.md +6 -5
- package/prompts/advisor-round3.md +6 -5
- package/prompts/common.md +23 -10
- package/prompts/consult-base.md +2 -1
- package/prompts/discipline-engineering.md +29 -48
- package/prompts/discipline-normal.md +10 -5
- package/prompts/persona-eng-coder.md +8 -0
- package/prompts/persona-eng-designer.md +22 -12
- package/prompts/persona-engineering.md +81 -66
- package/prompts/persona-plan.md +2 -1
- package/prompts/persona-tester.md +55 -0
- package/provider/anthropic.mjs +12 -7
- package/provider/core.mjs +21 -5
- package/provider/errors.mjs +41 -0
- package/provider/google.mjs +25 -9
- package/provider/list-models.mjs +4 -3
- package/provider/rate.mjs +11 -3
- package/provider/responses.mjs +17 -7
- package/provider/sse.mjs +15 -4
- package/session-identity.mjs +21 -0
- package/session-lifecycle.mjs +12 -4
- package/session-slots.mjs +9 -14
- package/session.mjs +5 -0
- package/skip-dirs.mjs +48 -0
- package/team-ledger.mjs +4 -4
- package/text-budget.mjs +4 -0
- package/token-window.mjs +107 -10
- package/tool-docs/advisor.md +2 -2
- package/tool-docs/apply_patch.md +2 -0
- package/tool-docs/bash.md +4 -0
- package/tool-docs/batch.md +85 -1
- package/tool-docs/browser.md +3 -1
- package/tool-docs/code_search.md +3 -1
- package/tool-docs/consult_start.md +6 -2
- package/tool-docs/consult_stop.md +3 -1
- package/tool-docs/delete.md +3 -0
- package/tool-docs/doc_check.md +43 -8
- package/tool-docs/doc_search.md +5 -1
- package/tool-docs/edit.md +22 -1
- package/tool-docs/execute.md +7 -1
- package/tool-docs/fetch.md +3 -1
- package/tool-docs/file_ops.md +3 -1
- package/tool-docs/git.md +9 -1
- package/tool-docs/goal.md +2 -0
- package/tool-docs/grep.md +1 -1
- package/tool-docs/hashline_edit.md +2 -0
- package/tool-docs/insert_after.md +3 -1
- package/tool-docs/ledger.md +5 -5
- package/tool-docs/lint.md +1 -0
- package/tool-docs/manifest-read.md +2 -1
- package/tool-docs/manifest.md +9 -2
- package/tool-docs/memory.md +11 -1
- package/tool-docs/notify_parent.md +1 -1
- package/tool-docs/plan.md +2 -0
- package/tool-docs/question.md +1 -1
- package/tool-docs/read_history.md +15 -3
- package/tool-docs/read_image.md +3 -1
- package/tool-docs/recent_changes.md +3 -1
- package/tool-docs/repo_outline.md +3 -1
- package/tool-docs/settings.md +10 -2
- package/tool-docs/skill.md +2 -0
- package/tool-docs/ssh.md +30 -17
- package/tool-docs/subagent.md +50 -13
- package/tool-docs/task.md +2 -0
- package/tool-docs/team_ledger.md +1 -1
- package/tool-docs/timer.md +7 -0
- package/tool-docs/verify.md +13 -1
- package/tool-docs/websearch.md +3 -1
- package/tool-docs/write.md +2 -0
- package/tools/bash.mjs +12 -2
- package/tools/edit-batch.mjs +40 -33
- package/tools/edit-diff.mjs +69 -1
- package/tools/execute.mjs +10 -4
- package/tools/file.mjs +17 -27
- package/tools/git.mjs +27 -1
- package/tools/ops.mjs +2 -1
- package/tools/patch.mjs +12 -3
- package/tools/process-doc-mark.mjs +139 -0
- package/tools/search.mjs +15 -6
- package/tools/shared.mjs +24 -1
- package/tools/ssh.mjs +48 -7
- package/tools/tree.mjs +4 -1
- package/tools/write-path.mjs +64 -3
- package/traces/trace-store.mjs +4 -3
- package/agent/light-round.mjs +0 -63
- package/agent/unbooked-pen.mjs +0 -62
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* ssh-session.mjs — SSH 会话面单点(池 ∥ 生命周期 ∥ 动作 ∥ 凭据窗 ∥ 非密预填)。
|
|
3
3
|
* 设计 = `docs/core/design/TOOLS.md` §6.22(D-TO17/D-TO18 · 台账 #1436);需求 = `docs/core/requirements/TOOLS.md` §4.8。
|
|
4
4
|
* 工具面 = `tools/ssh.mjs`(薄接线);传输面 = `agent-tools/ssh-transport.mjs`(askpass 链 / 沉降 / 遮蔽 / 帽
|
|
5
|
-
* ——拆档由 = 机制档触 500
|
|
5
|
+
* ——拆档由 = 机制档触 500 软线,§6.22 落点表「实施轮先审拆分」)。
|
|
6
6
|
*
|
|
7
7
|
* - 池:`_sshSessions`(照 `bash-async.mjs` 池先例——载体经 `carrierField` 吸收);帽 `SSH_SESSION_MAX = 4`
|
|
8
8
|
* 条存活会话;超限**显式拒**(零静默关旧)。
|
|
@@ -30,9 +30,9 @@ import {
|
|
|
30
30
|
} from "./async-settle.mjs"
|
|
31
31
|
import { assertPoolKeyFree, consumeSubagentToken, nextSubagentId } from "./subagent-scheduler.mjs"
|
|
32
32
|
import {
|
|
33
|
-
SSH_KEEPALIVE_MS, SSH_RETURN_CAP, SSH_SETTLE_MS, SSH_TIMEOUT_MS, bodyText,
|
|
34
|
-
failureText, idLabel, settleNote, sshConfigDir, spawnSession, statusLine, unlinkAskpass,
|
|
35
|
-
writeAskpassWrapper,
|
|
33
|
+
SSH_CONNECT_SETTLE_MS, SSH_KEEPALIVE_MS, SSH_RETURN_CAP, SSH_SETTLE_MS, SSH_TIMEOUT_MS, bodyText, capWindow, classifyFailure,
|
|
34
|
+
createMask, drain, escapeNote, failureText, idLabel, settleNote, sshConfigDir, spawnSession, statusLine, unlinkAskpass,
|
|
35
|
+
waitSettled, writeAskpassWrapper,
|
|
36
36
|
} from "./ssh-transport.mjs"
|
|
37
37
|
import { askCredential, deleteEntry, hostKey, resolveSecret, saveEntry, touchEntry, vaultAvailable } from "./ssh-vault.mjs"
|
|
38
38
|
|
|
@@ -232,10 +232,11 @@ export async function connectSsh(parent, ctx, { host, user, port, label, promptP
|
|
|
232
232
|
entry.askpassFile = writeAskpassWrapper(id)
|
|
233
233
|
entry.logStream = createWriteStream(entry.logPath, { flags: "a" })
|
|
234
234
|
entry.logStream.on("error", () => { /* 尽力面 */ })
|
|
235
|
-
/** 沉降等待(探连 ∥ 带密会话同径):返回 reason +
|
|
235
|
+
/** 沉降等待(探连 ∥ 带密会话同径):返回 reason + 已等时长。首屏 = `SSH_CONNECT_SETTLE_MS`(§6.22 ③
|
|
236
|
+
* 「适用范围」:60 s 只管 `send` / `read` / `secret_input`);`settleOnSilence` = 首屏沿用静默降级。 */
|
|
236
237
|
const settle = async () => {
|
|
237
238
|
const t0 = Date.now()
|
|
238
|
-
const reason = await waitSettled(entry, { signal: ctx?.signal ?? null })
|
|
239
|
+
const reason = await waitSettled(entry, { timeoutMs: SSH_CONNECT_SETTLE_MS, settleOnSilence: true, signal: ctx?.signal ?? null })
|
|
239
240
|
return { reason, waitedMs: Date.now() - t0 }
|
|
240
241
|
}
|
|
241
242
|
/** 起跑(探连 ∥ 带密会话同径;`-i` 两径同带——追加候选身份,不夺既有尝试序列)。
|
|
@@ -258,106 +259,48 @@ export async function connectSsh(parent, ctx, { host, user, port, label, promptP
|
|
|
258
259
|
userFromRecord ? `user "${userFromRecord}" came from the local ssh-hosts record` : "",
|
|
259
260
|
...extra,
|
|
260
261
|
dropped ? "in-memory buffer overflowed — full output in the log" : "",
|
|
261
|
-
|
|
262
|
-
|
|
262
|
+
escapeNote(entry), // 半条序列收场注记(§6.22 ③ flush②)——报告一次即清零
|
|
263
|
+
].filter(Boolean).map((s) => s.replace(/^\n— /u, ""))
|
|
264
|
+
return { text: `${statusLine(entry)}\n${bodyText(capWindow(entry, text, SSH_RETURN_CAP))}\n${notes.join("; ")}` }
|
|
263
265
|
}
|
|
264
|
-
/**
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
pool.set(String(id), entry)
|
|
271
|
-
spawn(effectiveUser, null) // 探连:全会话形 + NumberOfPasswordPrompts=0(不读保存件——铁序)
|
|
266
|
+
/** 一次尝试(探连 ∥ 带密会话同径):起跑 + 沉降 + 分类 ⇒ 归一判定(`ok` 假时 `failure` = 可读句)。
|
|
267
|
+
* `asUser` 缺省 = 本次有效 user(凭据窗可改);遮蔽先注册后写入(回显面零泄)。 */
|
|
268
|
+
const attempt = async (secret = null, asUser = effectiveUser) => {
|
|
269
|
+
entry.user = asUser ?? null
|
|
270
|
+
if (secret != null) entry.mask.add(secret)
|
|
271
|
+
spawn(entry.user, secret)
|
|
272
272
|
let { reason, waitedMs } = await settle()
|
|
273
273
|
// 「已就绪」判据要求进程尚在(静默窗与退出同刻的竞争面——升格一条死连接不成立)。
|
|
274
274
|
if ((reason === "prompt" || reason === "silence") && entry.exitInfo) reason = "exited"
|
|
275
|
-
if (reason === "
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (
|
|
296
|
-
|
|
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() })
|
|
328
|
-
if (!input) {
|
|
329
|
-
hardClose(entry, "closed", "consumed")
|
|
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)` : ""}` }
|
|
331
|
-
}
|
|
332
|
-
if (!String(input.password ?? "").length) {
|
|
333
|
-
hardClose(entry, "closed", "consumed")
|
|
334
|
-
return { error: "ssh connect cancelled — an empty password is never submitted (submit a password or cancel the window)" }
|
|
335
|
-
}
|
|
336
|
-
entry.user = input.user || effectiveUser || null
|
|
337
|
-
entry.mask.add(input.password) // 遮蔽先注册后写入(回显面零泄)
|
|
338
|
-
spawn(entry.user, input.password) // 单次带密会话(PP=1)
|
|
339
|
-
;({ reason, waitedMs } = await settle())
|
|
340
|
-
if ((reason === "prompt" || reason === "silence") && entry.exitInfo) reason = "exited"
|
|
341
|
-
if (reason !== "prompt" && reason !== "silence") {
|
|
342
|
-
const cls2 = entry.spawnError ? { message: "ssh client not available" } : classifyFailure(entry.buf)
|
|
343
|
-
const text = failureText(entry, cls2 ?? { message: "authentication rejected" }, " — the session was not established")
|
|
344
|
-
// 带密会话未就绪(aborted ∥ timeout)时子进程可能仍活——必走杀树径(否则出池后无句柄可收)。
|
|
345
|
-
hardClose(entry, "closed", "connect-failed")
|
|
346
|
-
return { error: text }
|
|
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)")
|
|
275
|
+
if (reason === "prompt" || reason === "silence") return { ok: true, reason, waitedMs }
|
|
276
|
+
const cls = entry.spawnError
|
|
277
|
+
? { kind: "spawn", message: `ssh client not available (${entry.spawnError}) — install system OpenSSH or check PATH` }
|
|
278
|
+
: classifyFailure(entry.buf)
|
|
279
|
+
const failure = secret == null
|
|
280
|
+
? failureText(entry, cls)
|
|
281
|
+
: failureText(entry, cls ?? { message: "authentication rejected" }, " — the session was not established")
|
|
282
|
+
return { ok: false, reason, waitedMs, auth: reason === "exited" && cls?.kind === "auth", failure }
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
assertPoolKeyFree(pool, id, "ssh")
|
|
286
|
+
pool.set(String(id), entry)
|
|
287
|
+
// 凭据判定单源(D-TO25):探连 → 密文库 → 凭据窗。本档只提供「一次尝试」,顺序归 resolveSessionSecret。
|
|
288
|
+
const out = await resolveSessionSecret({
|
|
289
|
+
ctx, host, port, user: effectiveUser, sessionId: id, offerSave: vaultAvailable(), attempt,
|
|
290
|
+
})
|
|
291
|
+
if (out.state !== "ok") {
|
|
292
|
+
const r = out.r ?? {}
|
|
293
|
+
const stopped = out.state === "cancel" || out.state === "empty" || (out.phase === "probe" && (r.reason === "aborted" || r.reason === "timeout"))
|
|
294
|
+
hardClose(entry, "closed", stopped ? "consumed" : "connect-failed")
|
|
295
|
+
if (out.state === "cancel") {
|
|
296
|
+
return { error: `ssh connect to ${entry.host} cancelled — no credential was provided (no connection was attempted with one)${out.failNote ? ` (${out.failNote}; the credential window was shown)` : ""}` }
|
|
356
297
|
}
|
|
357
|
-
if (
|
|
358
|
-
|
|
298
|
+
if (out.state === "empty") return { error: "ssh connect cancelled — an empty password is never submitted (submit a password or cancel the window)" }
|
|
299
|
+
if (r.reason === "aborted") return { error: `ssh connect interrupted before the session was ready (${entry.host})` }
|
|
300
|
+
if (r.reason === "timeout") return { error: `ssh connect did not settle within ${SSH_CONNECT_SETTLE_MS}ms (${entry.host}) — the connection is closed; retry or increase the wait${escapeNote(entry)}` }
|
|
301
|
+
return { error: r.failure }
|
|
359
302
|
}
|
|
360
|
-
return promote(reason, waitedMs, { auth, extra:
|
|
303
|
+
return promote(out.r.reason, out.r.waitedMs, { auth: out.source === "key" ? "key" : "password", extra: out.notes })
|
|
361
304
|
} catch (e) {
|
|
362
305
|
hardClose(entry, "closed", "connect-failed") // 异常径同走杀树(子进程可能仍在飞)
|
|
363
306
|
return { error: e?.noUi ? `Error: ${e.message}` : `ssh connect failed: ${e?.message ?? String(e)}` }
|
|
@@ -366,8 +309,8 @@ export async function connectSsh(parent, ctx, { host, user, port, label, promptP
|
|
|
366
309
|
}
|
|
367
310
|
}
|
|
368
311
|
|
|
369
|
-
/**
|
|
370
|
-
const droppedNote = (dropped) =>
|
|
312
|
+
/** 丢弃面注记(三动作同式——缓冲溢出 ∥ 半条序列收场;全量恒在 log)。 */
|
|
313
|
+
const droppedNote = (dropped, entry) => `${dropped ? "\n— in-memory buffer overflowed — full output in the log" : ""}${entry ? escapeNote(entry) : ""}`
|
|
371
314
|
|
|
372
315
|
/** `send`(§6.22 动作表):不结尾换行 ⇒ 补 `\r`(设备 Enter 语义);沉降到点返回该轮输出。 */
|
|
373
316
|
export async function sendSsh(parent, ctx, { id, data, settle = true, timeoutMs } = {}) {
|
|
@@ -389,11 +332,11 @@ export async function sendSsh(parent, ctx, { id, data, settle = true, timeoutMs
|
|
|
389
332
|
entry._notify = null
|
|
390
333
|
const { text, dropped } = drain(entry)
|
|
391
334
|
return {
|
|
392
|
-
text: `${statusLine(entry)}\n${bodyText(
|
|
335
|
+
text: `${statusLine(entry)}\n${bodyText(capWindow(entry, text, SSH_RETURN_CAP))}\n— ${settleNote(entry, reason, Date.now() - t0)}${droppedNote(dropped, entry)}`,
|
|
393
336
|
}
|
|
394
337
|
}
|
|
395
338
|
|
|
396
|
-
/** `read
|
|
339
|
+
/** `read`:上次读后新增输出(**存量即返**、结论 = 缓冲;无存量才等到门① ∥ 门② ∥ 硬超时——§6.22 ③/⑥)。 */
|
|
397
340
|
export async function readSsh(parent, ctx, { id, timeoutMs, maxChars } = {}) {
|
|
398
341
|
const entry = findEntry(parent, id)
|
|
399
342
|
if (!entry) return { error: missingEntry(parent, id) }
|
|
@@ -407,11 +350,11 @@ export async function readSsh(parent, ctx, { id, timeoutMs, maxChars } = {}) {
|
|
|
407
350
|
const reason = await waitSettled(entry, { timeoutMs, signal: ctx?.signal ?? null })
|
|
408
351
|
entry._notify = null
|
|
409
352
|
const { text, dropped } = drain(entry)
|
|
410
|
-
const body = text ? bodyText(
|
|
411
|
-
return { text: `${statusLine(entry)}\n${body}\n— ${settleNote(entry, reason, Date.now() - t0)}${droppedNote(dropped)}` }
|
|
353
|
+
const body = text ? bodyText(capWindow(entry, text, cap)) : "(no new output)"
|
|
354
|
+
return { text: `${statusLine(entry)}\n${body}\n— ${settleNote(entry, reason, Date.now() - t0)}${droppedNote(dropped, entry)}` }
|
|
412
355
|
}
|
|
413
356
|
const { text, dropped } = drain(entry)
|
|
414
|
-
return { text: `${statusLine(entry)}\n${bodyText(
|
|
357
|
+
return { text: `${statusLine(entry)}\n${bodyText(capWindow(entry, text, cap))}\n— buffered output since the last read${droppedNote(dropped, entry)}` }
|
|
415
358
|
}
|
|
416
359
|
|
|
417
360
|
/** `secret_input`(§6.22 动作表 + ⑦):凭据窗 → 掩码写入 + `\r`;值永不出现于返回。 */
|
|
@@ -441,7 +384,7 @@ export async function secretInputSsh(parent, ctx, { id, label } = {}) {
|
|
|
441
384
|
entry._notify = null
|
|
442
385
|
const { text, dropped } = drain(entry)
|
|
443
386
|
const tag = label ? ` (${escapeLite(label)})` : ""
|
|
444
|
-
return { text: `${statusLine(entry)}\nsecret written${tag} — value never echoed\n${bodyText(
|
|
387
|
+
return { text: `${statusLine(entry)}\nsecret written${tag} — value never echoed\n${bodyText(capWindow(entry, text, SSH_RETURN_CAP))}\n— ${settleNote(entry, reason, Date.now() - t0)}${droppedNote(dropped, entry)}` }
|
|
445
388
|
}
|
|
446
389
|
|
|
447
390
|
/** `close`:stdin 结束 → 1.5 s 宽限 → 杀树;返回末段输出 + 关闭确认。 */
|
|
@@ -452,7 +395,7 @@ export async function closeSsh(parent, ctx, { id } = {}) {
|
|
|
452
395
|
if (entry.state !== "ready") {
|
|
453
396
|
const was = entry.state
|
|
454
397
|
finalize(entry, { state: "closed", status: "consumed" })
|
|
455
|
-
return { text: `${statusLine(entry)}\n${bodyText(
|
|
398
|
+
return { text: `${statusLine(entry)}\n${bodyText(capWindow(entry, text, SSH_RETURN_CAP))}\n— closed (it was already ${was})${droppedNote(dropped, entry)}` }
|
|
456
399
|
}
|
|
457
400
|
entry.closing = true
|
|
458
401
|
try { entry.child.stdin.end() } catch { /* 进程可能已退出——宽限后杀树 */ }
|
|
@@ -462,7 +405,7 @@ export async function closeSsh(parent, ctx, { id } = {}) {
|
|
|
462
405
|
})
|
|
463
406
|
if (!exited) killProcessTree(entry.child)
|
|
464
407
|
finalize(entry, { state: "closed", status: "consumed" })
|
|
465
|
-
return { text: `${statusLine(entry)}\n${bodyText(
|
|
408
|
+
return { text: `${statusLine(entry)}\n${bodyText(capWindow(entry, text, SSH_RETURN_CAP))}\n— closed${exited ? "" : " (the remote did not exit within 1.5s — the process tree was killed)"}${droppedNote(dropped, entry)}` }
|
|
466
409
|
}
|
|
467
410
|
|
|
468
411
|
/** `list`:在活会话清单(id / host / user / 状态 / 空闲秒)——上下文压缩后的恢复路。 */
|
|
@@ -482,5 +425,71 @@ export { SSH_SETTLE_MS, SSH_TIMEOUT_MS, SSH_RETURN_CAP }
|
|
|
482
425
|
export { _setSshBinaryForTest, _setSshConfigDirForTest } from "./ssh-transport.mjs"
|
|
483
426
|
|
|
484
427
|
// 保存凭据管理面(§6.22 ⑫):`credentials` / `forget` 实体 = `ssh-vault.mjs`(核单点——本档只转口;
|
|
485
|
-
// 拆档由 = 机制档触 500
|
|
428
|
+
// 拆档由 = 机制档触 500 软线,§6.22 落点表候选拆分)。
|
|
486
429
|
export { credentialsSsh, forgetSsh } from "./ssh-vault.mjs"
|
|
430
|
+
// ─── 凭据判定单源(D-TO25:`connect` ∥ 传输面两处共用)─────────────────────────
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* 凭据判定(§6.22 ⑩ / ⑪ + 裁定 A 预探路)——`connect` 与 `put` / `get` 两处共用的**顺序**:
|
|
434
|
+
* 探连(密钥 ∥ agent)→ 密文库 → 凭据窗。保存件**恒不先于探连**(S6 / N6 / B7——密钥可用则零读取)。
|
|
435
|
+
* `attempt(secret, user)` = 调方的起跑 + 沉降 + 分类,回 `{ ok:true, … }` 或 `{ ok:false, auth, failure }`
|
|
436
|
+
* (`auth` = 服务端拒绝认证 ⇒ 才继续往下一条来源;`failure` = 该来源失败的可读句)。
|
|
437
|
+
* @returns {Promise<{state:"ok", secret:string|null, user:string|null, source:"key"|"vault"|"window", r:Object, notes:string[]}
|
|
438
|
+
* | {state:"fail", phase:"probe"|"vault"|"window", r:Object} | {state:"cancel"|"empty", failNote?:string}>}
|
|
439
|
+
*/
|
|
440
|
+
export async function resolveSessionSecret({ ctx, host, port, user, sessionId, offerSave = false, attempt }) {
|
|
441
|
+
const notes = []
|
|
442
|
+
let r = await attempt(null)
|
|
443
|
+
if (r.ok) return { state: "ok", secret: null, user: user ?? null, source: "key", r, notes }
|
|
444
|
+
if (!r.auth) return { state: "fail", phase: "probe", r }
|
|
445
|
+
// 认证类失败 ⇒ **先读保存件**(⑪;有效 user 缺 ⇒ 零消费零删除——直接落窗)。
|
|
446
|
+
let failNote = ""
|
|
447
|
+
if (user) {
|
|
448
|
+
const got = await resolveSecret(host, port, user)
|
|
449
|
+
if (got.state === "ok") {
|
|
450
|
+
r = await attempt(got.value, user)
|
|
451
|
+
if (r.ok) {
|
|
452
|
+
touchEntry(host, port) // lastUsedAt = 复用成功时更新(写失败 = 增益面静默)
|
|
453
|
+
return { state: "ok", secret: got.value, user, source: "vault", r, notes: ["saved credential used (no credential window)"] }
|
|
454
|
+
}
|
|
455
|
+
if (!r.auth) return { state: "fail", phase: "vault", r }
|
|
456
|
+
// 条目被服务端拒 ⇒ 删该条目(拒绝即值已失效——避免每次重试旧值)+ 回落窗;删失败时注记如实。
|
|
457
|
+
const del = deleteEntry(host, port)
|
|
458
|
+
failNote = del.ok
|
|
459
|
+
? "the saved credential was rejected by the server — the entry was deleted"
|
|
460
|
+
: "the saved credential was rejected by the server — the store could not be updated (the entry may still be there)"
|
|
461
|
+
} else if (got.state === "damaged") failNote = "the saved-credential store is damaged — it was left untouched"
|
|
462
|
+
else if (got.state === "undecryptable") failNote = "the saved credential could not be decrypted — the entry was kept"
|
|
463
|
+
else if (got.state === "unavailable") failNote = "saved credentials are not available on this platform"
|
|
464
|
+
// missing ⇒ 静默(正常首连态)
|
|
465
|
+
}
|
|
466
|
+
// 凭据窗(值只走回调返回路 + 子进程环境——NS4:零入参 ∥ 零入结果 ∥ 零落盘)。
|
|
467
|
+
const input = await askCredential(ctx, { host, user, port, sessionId, offerSave })
|
|
468
|
+
if (!input) return { state: "cancel", failNote }
|
|
469
|
+
if (!String(input.password ?? "").length) return { state: "empty" }
|
|
470
|
+
const asUser = input.user || user || null
|
|
471
|
+
r = await attempt(input.password, asUser)
|
|
472
|
+
if (!r.ok) return { state: "fail", phase: "window", r }
|
|
473
|
+
// 认证成功后才写(opt-in——严格布尔;写失败不影响连接:增益面语义)
|
|
474
|
+
if (input.save === true && asUser) {
|
|
475
|
+
const saved = await saveEntry(host, port, { user: asUser, value: input.password })
|
|
476
|
+
notes.push(saved.ok
|
|
477
|
+
? "the credential was saved for this host (encrypted with this Windows user account)"
|
|
478
|
+
: "the credential could not be saved (the connection is unaffected)")
|
|
479
|
+
} else if (input.save === true) {
|
|
480
|
+
notes.push("the credential was not saved (no login user was given for this host)")
|
|
481
|
+
}
|
|
482
|
+
if (failNote) notes.unshift(`${failNote}; the credential window was shown`)
|
|
483
|
+
return { state: "ok", secret: input.password, user: asUser, source: "window", r, notes }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** 传输面取会话记录(§6.22 ⑬ ②:主机 / 用户 / 端口 / 身份面全取会话记录值——S10「不另填主机 / 凭据」)。
|
|
487
|
+
* 池查询本档单点(传输件不碰池);身份面取 ⑨ 记录的 `identityFile`(与 `connect` 同一判定序)。
|
|
488
|
+
* `header` = 回执首行的会话冠行(⑬ ③)——形单源仍是 `ssh-transport.mjs` 的 `statusLine`,本档只取一次快照。 */
|
|
489
|
+
export function sessionForTransfer(parent, id) {
|
|
490
|
+
const entry = findEntry(parent, id)
|
|
491
|
+
if (!entry) return { error: missingEntry(parent, id) }
|
|
492
|
+
const rec = lookupHostRecord(entry.host, entry.port)
|
|
493
|
+
const header = statusLine(entry)
|
|
494
|
+
return { host: entry.host, user: entry.user ?? null, port: entry.port ?? null, identityFile: rec?.identityFile ?? null, header }
|
|
495
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssh-transfer.mjs — 传输面单点(`put` / `get` 单文件:系统 `scp` ∥ 凭据链 ∥ 硬超时 ∥ 回执句 ∥ 半截档清理)。
|
|
3
|
+
* 设计 = `docs/core/design/TOOLS.md` §6.22 ⑬(D-TO24–D-TO27 · 台账 #2026);需求 = 需求档 §4.8 S10 ∥ S11 ∥ NS8。
|
|
4
|
+
*
|
|
5
|
+
* - 执行体 = 系统 `scp`(D-TO24):缺省 `-O`(经典 SCP 协议——网络设备只实现它),`protocol:"sftp"` 去掉
|
|
6
|
+
* `-O`;不自动换协议重试(D-TO27:同笔重试把「协议不对」这个可诊断事实埋在两次尝试之后)。
|
|
7
|
+
* - 凭据 = 与会话面**同一段判定**(`ssh-session.mjs` 的 `resolveSessionSecret`:探连 → 密文库 → 凭据窗),
|
|
8
|
+
* 零新凭据通道(D-TO25);askpass 包装档 / 环境 / 失败分类族全取 `ssh-transport.mjs` 既有导出。
|
|
9
|
+
* - 内容零入上下文(NS8):文件字节只经磁盘;回执 = 会话冠行 + 一行状态句,失败时附 stderr 尾段(≤ 600 字符)。
|
|
10
|
+
* - 本地面(⑬ ②):本地路径展开 `~`(单源 `expand-home.mjs`)后解析成绝对路径 ⇒ `spawn` 的 `cwd` = 该档
|
|
11
|
+
* 所在目录、命令行只传**档名**——Windows 盘符的 `:` 与反斜杠都不进 scp 的参数位(scp 以首个 `:`
|
|
12
|
+
* 切分本地 / 远端);`get` 的父目录必须已在盘(**零建目录**:建目录即项目内一笔写,绕写门;落点也要
|
|
13
|
+
* 让人看得见)。
|
|
14
|
+
* - 回执首行 = 会话状态行冠行(⑬ ③;形单源 = `ssh-transport.mjs` 的 `statusLine`),其下才是状态句
|
|
15
|
+
* 字面——多设备会话里只给路径,读的人无法确认推到了哪台。
|
|
16
|
+
* - 不碰会话池:主机 / 用户 / 端口 / 身份面由 `ssh-session.mjs` 的 `sessionForTransfer` 供给。
|
|
17
|
+
* 模块图:→ ssh-session.mjs(凭据判定 ∥ 会话记录)· ssh-transport.mjs(askpass 链 ∥ 失败分类族 ∥ 冠行)·
|
|
18
|
+
* tools/process-tree.mjs(树杀)· expand-home.mjs(`~` 展开单源)——叶子向、无环
|
|
19
|
+
* (`ssh-session.mjs` 不 import 本档)。
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from "node:child_process"
|
|
22
|
+
import { existsSync, statSync, unlinkSync } from "node:fs"
|
|
23
|
+
import { basename, dirname, resolve } from "node:path"
|
|
24
|
+
import { killProcessTree } from "../tools/process-tree.mjs"
|
|
25
|
+
import { expandHome } from "../expand-home.mjs"
|
|
26
|
+
import {
|
|
27
|
+
SSH_KEEPALIVE_MS, buildSshEnv, classifyFailure, failureText, statusLine, unlinkAskpass, writeAskpassWrapper,
|
|
28
|
+
} from "./ssh-transport.mjs"
|
|
29
|
+
import { resolveSessionSecret, sessionForTransfer } from "./ssh-session.mjs"
|
|
30
|
+
|
|
31
|
+
/** 传输硬超时缺省(⑬ ②):600 000 ms = 选定策略值,非实测(真机读数不符 ⇒ 改这一个数)。 */
|
|
32
|
+
export const SSH_TRANSFER_TIMEOUT_MS = 600_000
|
|
33
|
+
|
|
34
|
+
/** 夹具缝(⑬「夹具缝」):`scp` 自有缝——会话侧 `_setSshBinaryForTest` 换不到 `scp`。
|
|
35
|
+
* 形同 `_setSshBinaryForTest`:setter + `??` 缺省回落 + 用例 `finally` 复位,生产行为零变。 */
|
|
36
|
+
let _scpBinary = null
|
|
37
|
+
let _scpArgPrefix = []
|
|
38
|
+
export function _setScpBinaryForTest(cmd, prefixArgs = []) {
|
|
39
|
+
_scpBinary = cmd
|
|
40
|
+
_scpArgPrefix = Array.isArray(prefixArgs) ? prefixArgs : []
|
|
41
|
+
}
|
|
42
|
+
export const scpBinary = () => _scpBinary ?? "scp"
|
|
43
|
+
|
|
44
|
+
// `~` 展开 = 单源 `thincoder-core/expand-home.mjs`(⑬ ② `localPath` 行;`config.mjs` 已 import 同一件)。
|
|
45
|
+
// 本档不持第二份:两份并存必漂移(D2)。
|
|
46
|
+
|
|
47
|
+
/** scp 命令形(⑬ ①/②):`port` / `identityFile` 未给 ⇒ 不下发该选项(让本机配置兜底)。
|
|
48
|
+
* **端口开关 = `-P`**(本机 scp 用法行 `[-P port]`);小写 `-p` 在 scp 是 preserve-times 且**不收值**
|
|
49
|
+
* ——与会话面 ssh 的 `-p` 不同,照抄会把这个数字丢成操作数。 */
|
|
50
|
+
function scpArgs({ action, protocol, host, user, port, identityFile, remotePath, localName, keepAliveMs = SSH_KEEPALIVE_MS }) {
|
|
51
|
+
const keepAliveS = Math.max(1, Math.round((Number(keepAliveMs) || SSH_KEEPALIVE_MS) / 1000))
|
|
52
|
+
const remote = `${user ? `${user}@` : ""}${host}:${remotePath}`
|
|
53
|
+
return [
|
|
54
|
+
...(protocol === "sftp" ? [] : ["-O"]),
|
|
55
|
+
"-o", "ConnectTimeout=10",
|
|
56
|
+
"-o", `ServerAliveInterval=${keepAliveS}`,
|
|
57
|
+
"-o", "ServerAliveCountMax=4",
|
|
58
|
+
"-o", "NumberOfPasswordPrompts=1",
|
|
59
|
+
// 端口开关 = `-P`(scp 原文用法行 `[-P port]`;小写 `-p` 在 scp 是 preserve-times 且**不收值**)——
|
|
60
|
+
// 与会话面 ssh 的 `-p` 不同,照抄会把这个数字丢成操作数。
|
|
61
|
+
...(port ? ["-P", String(port)] : []),
|
|
62
|
+
...(identityFile ? ["-i", expandHome(identityFile)] : []),
|
|
63
|
+
...(action === "put" ? [localName, remote] : [remote, localName]),
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 起跑一次 scp:stdin 关掉(口令只经 askpass 环境,scp 永不读终端);到点杀树并留兜底收场。 */
|
|
68
|
+
function runScp({ args, cwd, wrapper, secret, timeoutMs }) {
|
|
69
|
+
return new Promise((done) => {
|
|
70
|
+
let child
|
|
71
|
+
try {
|
|
72
|
+
child = spawn(scpBinary(), [..._scpArgPrefix, ...args], {
|
|
73
|
+
cwd, stdio: ["ignore", "pipe", "pipe"], windowsHide: true,
|
|
74
|
+
detached: process.platform !== "win32",
|
|
75
|
+
env: buildSshEnv({ wrapper, secret }),
|
|
76
|
+
})
|
|
77
|
+
} catch (e) {
|
|
78
|
+
done({ spawnError: e?.message ?? String(e) })
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
let out = ""
|
|
82
|
+
let err = ""
|
|
83
|
+
let settled = false
|
|
84
|
+
let timedOut = false
|
|
85
|
+
const finish = (r) => {
|
|
86
|
+
if (settled) return
|
|
87
|
+
settled = true
|
|
88
|
+
clearTimeout(timer)
|
|
89
|
+
done({ ...r, out, err, timedOut })
|
|
90
|
+
}
|
|
91
|
+
// 到点杀树;子进程不理会 kill 时兜底收场(判据已是 timedOut,不等内层 exit 事件)。
|
|
92
|
+
const timer = setTimeout(() => {
|
|
93
|
+
timedOut = true
|
|
94
|
+
try { killProcessTree(child) } catch { /* 尽力面 */ }
|
|
95
|
+
setTimeout(() => finish({ code: null, signal: "TIMEOUT" }), 1500).unref?.()
|
|
96
|
+
}, Math.max(1, timeoutMs))
|
|
97
|
+
timer.unref?.()
|
|
98
|
+
child.stdout.on("data", (d) => { out += d })
|
|
99
|
+
child.stderr.on("data", (d) => { err += d })
|
|
100
|
+
child.on("error", (e) => finish({ spawnError: e?.message ?? String(e) }))
|
|
101
|
+
child.on("exit", (code, signal) => finish({ code, signal }))
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const fileSize = (file) => {
|
|
106
|
+
try { return statSync(file).size } catch { return 0 }
|
|
107
|
+
}
|
|
108
|
+
const fileMtime = (file) => {
|
|
109
|
+
try { return statSync(file).mtimeMs } catch { return null }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 硬超时句(⑬ ⑤):杀树 + 可读句;**`get` 侧**未删则如实写「落点原样」(不谎称删了),
|
|
113
|
+
* **`put` 侧**只如实交底(清远端半截档要第二条通道——本批不做,边界句)。 */
|
|
114
|
+
function timeoutText({ action, label, host, ms, removed = false }) {
|
|
115
|
+
const secs = Math.round(ms / 100) / 10
|
|
116
|
+
const base = `Error: ssh transfer timed out after ${secs}s (${label} @ ${host}) — the process tree was killed`
|
|
117
|
+
if (action !== "get") {
|
|
118
|
+
return `${base}; the local source file is untouched, and the remote host may keep a partial file (this batch does not clean it)`
|
|
119
|
+
}
|
|
120
|
+
return removed
|
|
121
|
+
? `${base}; the partial local file was removed (partial local file removed)`
|
|
122
|
+
: `${base}; the local target was left as it stands (it was not modified since the transfer started)`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* `put` / `get` 单文件(§6.22 ⑬):在会话那台主机上跑一次 `scp`,字节只经磁盘。
|
|
127
|
+
* @returns {Promise<{ text?: string, error?: string }>}
|
|
128
|
+
*/
|
|
129
|
+
export async function transferSsh(parent, ctx, { action, id, localPath, remotePath, overwrite, protocol, timeoutMs } = {}) {
|
|
130
|
+
const session = sessionForTransfer(parent, id)
|
|
131
|
+
if (session.error) return { error: session.error }
|
|
132
|
+
const sid = String(id ?? "").trim().replace(/^ssh#/i, "")
|
|
133
|
+
const label = `ssh#${sid}`
|
|
134
|
+
// 本地面(⑬ ②):先展开 `~` 再解析成绝对路径——scp 只收档名(盘符 / 反斜杠不进参数位)。
|
|
135
|
+
const local = resolve(ctx?.cwd ?? process.cwd(), expandHome(localPath))
|
|
136
|
+
const dir = dirname(local)
|
|
137
|
+
if (action === "get") {
|
|
138
|
+
if (!existsSync(dir)) {
|
|
139
|
+
return { error: `Error: ssh transfer: local directory not found: ${dir} — create it first (this tool never creates one: a mistyped get would land where you cannot see it)` }
|
|
140
|
+
}
|
|
141
|
+
if (overwrite !== true && existsSync(local)) {
|
|
142
|
+
return { error: `Error: ssh transfer: target exists: ${local} — pass overwrite:true to replace it` }
|
|
143
|
+
}
|
|
144
|
+
} else if (!existsSync(local)) {
|
|
145
|
+
return { error: `Error: ssh transfer: local source not found: ${local}` }
|
|
146
|
+
}
|
|
147
|
+
const ms = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0 ? Math.floor(Number(timeoutMs)) : SSH_TRANSFER_TIMEOUT_MS
|
|
148
|
+
// 落点原状(超时清理的判据面):`null` = 本来没有;否则记字节数与 mtime——**任一变了即视为「本次真被写过」**
|
|
149
|
+
// (只看尺寸会漏掉「半截档恰好与原档同尺寸」那一支:mtime 一变就补上了)。
|
|
150
|
+
const pristine = action === "get" && existsSync(local) ? { size: fileSize(local), mtime: fileMtime(local) } : null
|
|
151
|
+
const wrapper = writeAskpassWrapper(`xfer-${Date.now()}`)
|
|
152
|
+
/** 一次尝试(凭据判定顺序归 `resolveSessionSecret`;本档只管起跑与判读)。 */
|
|
153
|
+
const attempt = async (secret, asUser = session.user) => {
|
|
154
|
+
const argv = scpArgs({
|
|
155
|
+
action, protocol, host: session.host, user: asUser, port: session.port,
|
|
156
|
+
identityFile: session.identityFile, remotePath, localName: basename(local),
|
|
157
|
+
})
|
|
158
|
+
const r = await runScp({ args: argv, cwd: dir, wrapper, secret, timeoutMs: ms })
|
|
159
|
+
if (r.spawnError) {
|
|
160
|
+
return { ok: false, auth: false, reason: "spawn", failure: `Error: scp not available (${r.spawnError}) — install system OpenSSH or check PATH` }
|
|
161
|
+
}
|
|
162
|
+
if (r.timedOut) return { ok: false, auth: false, reason: "timeout" }
|
|
163
|
+
if (r.code === 0) return { ok: true, reason: "ok" }
|
|
164
|
+
// 认证类失败 ⇒ 判定面继续往下一条凭据来源(密文库 → 凭据窗);其余失败一律就地交出。
|
|
165
|
+
const classified = classifyFailure(r.err)
|
|
166
|
+
const auth = classified?.kind === "auth"
|
|
167
|
+
const cls = classified ?? { message: `transfer failed (${r.code == null ? `signal ${r.signal ?? "unknown"}` : `exit ${r.code}`})` }
|
|
168
|
+
// 提示按现场分叉(⑬ ①「不做」行):已传 `sftp` ⇒ 不再点该参数(用户已用过,点它等于让人重试同一笔)。
|
|
169
|
+
const hint = protocol === "sftp"
|
|
170
|
+
? " — check the remote path; this host may not support file transfer either way"
|
|
171
|
+
: ' — if that host only speaks SFTP, retry once with protocol:"sftp"'
|
|
172
|
+
return { ok: false, auth, reason: "exited", failure: failureText({ id: sid, host: session.host, buf: r.err }, cls, hint) }
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
// 凭据窗传 `offerSave: false`:保存面(⑪ 窗内勾选 + `auth` 回写)只在 `connect` 面——传输是既有会话的
|
|
176
|
+
// 补连接,不是建链入口 ⇒ 传输窗只问 user / password(要存凭据先 `connect` 一次)。设计 ⑬「凭据」段已记此面,
|
|
177
|
+
// 用户可见后果由描述档写明;判定段仍是同一条(`resolveSessionSecret`)。
|
|
178
|
+
const out = await resolveSessionSecret({
|
|
179
|
+
ctx, host: session.host, port: session.port, user: session.user, sessionId: sid, offerSave: false, attempt,
|
|
180
|
+
})
|
|
181
|
+
if (out.state !== "ok") {
|
|
182
|
+
if (out.state === "cancel") return { error: `Error: ssh transfer cancelled — no credential was provided (${label} @ ${session.host})` }
|
|
183
|
+
if (out.state === "empty") return { error: `Error: ssh transfer cancelled — an empty password is never submitted (${label} @ ${session.host})` }
|
|
184
|
+
const r = out.r ?? {}
|
|
185
|
+
if (r.reason === "timeout") {
|
|
186
|
+
// 半截档清理只限 `get`(`put` 侧本地源档不动——⑬ ⑤ 硬超时行)。只删**半截档**:落点字节数与 mtime
|
|
187
|
+
// 都没变 ⇒ scp 连文件都没打开就超时,删它等于拿用户的旧档偿命(设计说的是删「半截档」,不是删落点上那个档)。
|
|
188
|
+
let removed = false
|
|
189
|
+
if (action === "get" && existsSync(local)) {
|
|
190
|
+
const touched = pristine === null || fileSize(local) !== pristine.size || fileMtime(local) !== pristine.mtime
|
|
191
|
+
if (touched) {
|
|
192
|
+
try { unlinkSync(local); removed = true } catch { /* 尽力面 */ }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { error: timeoutText({ action, label, host: session.host, ms, removed }) }
|
|
196
|
+
}
|
|
197
|
+
return { error: r.failure ?? `Error: ssh transfer failed (${label} @ ${session.host})` }
|
|
198
|
+
}
|
|
199
|
+
const bytes = fileSize(local)
|
|
200
|
+
const [from, to] = action === "put" ? [localPath, remotePath] : [remotePath, localPath]
|
|
201
|
+
// 首行 = 会话状态行冠行(⑬ ③);状态句字面 = 冠行之下那一行(字面单源也定在 ③)。
|
|
202
|
+
return { text: `${session.header}\n${action} ${from} → ${to} · ${bytes} bytes · exit 0` }
|
|
203
|
+
} catch (e) {
|
|
204
|
+
// 工具面契约(`tools/ssh.mjs` 档头):失败**返回**可读句,不 throw——`askCredential` 在无 UI 端抛
|
|
205
|
+
// `noUi`(headless / ACP),那条路必须落回回执面(与 `connectSsh` 的 catch 同形)。
|
|
206
|
+
return { error: e?.noUi ? `Error: ${e.message}` : `Error: ssh transfer failed: ${e?.message ?? String(e)}` }
|
|
207
|
+
} finally {
|
|
208
|
+
unlinkAskpass({ askpassFile: wrapper }) // 包装档内容零秘密——收尾尽力面
|
|
209
|
+
}
|
|
210
|
+
}
|