@thincoder/core 0.10.8 → 0.10.9
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/agent/family-tools.mjs +2 -2
- package/agent/helpers.mjs +6 -6
- package/agent/post-turn.mjs +34 -0
- package/agent/run-stages.mjs +43 -9
- package/agent/setup.mjs +10 -2
- package/agent/turn-loop.mjs +6 -0
- package/agent-tools/audit-block.mjs +4 -2
- package/agent-tools/batch-gate.mjs +198 -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/context.mjs +28 -5
- package/agent-tools/design-slots.mjs +7 -33
- package/agent-tools/doc-check.mjs +44 -6
- 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 +18 -11
- package/agent-tools/subagent.mjs +13 -7
- package/agent-tools/timer.mjs +2 -2
- package/config-defaults.mjs +92 -0
- package/config-normalize.mjs +109 -0
- package/config.mjs +88 -197
- package/doc-check/anchors.mjs +54 -12
- package/doc-check/index.mjs +161 -12
- package/doc-check/version.mjs +84 -0
- package/doc-check/width.mjs +5 -4
- package/i18n.mjs +2 -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 +15 -1
- package/package.json +1 -1
- package/permission.mjs +7 -1
- package/prompts/advisor-design.md +21 -21
- package/prompts/advisor-round1.md +18 -18
- package/prompts/advisor-round2.md +22 -22
- package/prompts/advisor-round3.md +24 -24
- package/prompts/common.md +108 -93
- package/prompts/consult-base.md +8 -8
- package/prompts/discipline-engineering.md +68 -65
- package/prompts/discipline-normal.md +69 -68
- package/prompts/persona-coder.md +6 -6
- package/prompts/persona-eng-coder.md +19 -18
- package/prompts/persona-eng-designer.md +36 -32
- package/prompts/persona-engineering.md +114 -98
- package/prompts/persona-explore.md +2 -2
- package/prompts/persona-normal.md +16 -16
- 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 +30 -7
- package/tool-docs/fetch.md +3 -2
- package/tool-docs/ledger.md +7 -5
- package/tool-docs/ssh.md +23 -6
- package/tool-docs/subagent.md +6 -11
- 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
|
@@ -1,19 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* agent-tools/doc-check.mjs — `doc_check` 工具:文档纪律机检(产品内建 ·
|
|
2
|
+
* agent-tools/doc-check.mjs — `doc_check` 工具:文档纪律机检(产品内建 · 两种问法)。
|
|
3
3
|
*
|
|
4
|
-
* 设计 = `docs/core/design/DOC-DISCIPLINE.md` §3.20 条目 S
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 设计 = `docs/core/design/DOC-DISCIPLINE.md` §3.20 条目 S(判据 = 锚 ∥ 行宽 · 声明面驱动)。
|
|
5
|
+
* 工具形:**无动作枚举**——`mode` = 同一条判据的两种问法(`report` = 全域报告态 ∥ `gate` = 交付时点
|
|
6
|
+
* 的范围化裁定);**只读**(零副作用 · 免审批 · 可并行);参数 `target` / `mode` / `docs`
|
|
7
|
+
* (`target` 指向多项目工作区 ⇒ 报明候选并要求指名,同 `manifest` 工具面)。
|
|
7
8
|
* 判据参数读目标项目的 `PROJECT-MANIFEST.json` `checkConfig`——不硬编码任何本仓路径(换项目开箱可用)。
|
|
8
|
-
* 非门禁(硬):输出不得被读作拒执行的理由(用户令 > 机检——§2
|
|
9
|
+
* `report` 非门禁(硬):输出不得被读作拒执行的理由(用户令 > 机检——§2 总则族);
|
|
10
|
+
* `gate` 的强制面住交付协议 + 批次档 §6 前置,工具本体同样不阻断任何动作。
|
|
11
|
+
* gate 前置(fail-closed)= 引擎版本一致:本会话进程持启动时加载的 ESM 引擎,引擎档晚于启动 ⇒ ERROR
|
|
12
|
+
* (判据单源 = `doc-check/version.mjs`——与 `batch-gate.mjs` 共用一份清单,不各持一份)。
|
|
9
13
|
* 描述外置:模型可见文本单点 = `tool-docs/doc_check.md`(`DESC` 单一解析面)。
|
|
10
14
|
* 装配段 = `agent/family-tools.mjs` 三处(depth-0 / eng-coder / eng-designer)——写文档 / 设计档的角色面。
|
|
11
15
|
*/
|
|
12
16
|
import { resolve } from "node:path"
|
|
13
17
|
import { projectRootView } from "../manifest.mjs"
|
|
14
18
|
import { runDocCheck } from "../doc-check/index.mjs"
|
|
19
|
+
import { staleEngineFiles, gateEngineError, withEngineNote } from "../doc-check/version.mjs"
|
|
15
20
|
import { DESC } from "../tools/shared.mjs"
|
|
16
21
|
|
|
22
|
+
// 版本函数单源 = `doc-check/version.mjs`(两消费面:本档 gate 分支 ∥ `batch-gate.mjs` 两机械点)——
|
|
23
|
+
// 本档只转口,导出面不变(原五档清单已随之六档:`version.mjs` 自身入清单)。
|
|
24
|
+
export { staleEngineFiles, gateEngineError }
|
|
25
|
+
|
|
17
26
|
export const docCheckTool = {
|
|
18
27
|
name: "doc_check",
|
|
19
28
|
description: DESC("doc_check"),
|
|
@@ -21,17 +30,46 @@ export const docCheckTool = {
|
|
|
21
30
|
type: "object",
|
|
22
31
|
properties: {
|
|
23
32
|
target: { type: "string", description: "Explicit project root to check — defaults to the session anchor; relative paths resolve against it. A workspace target (several projects below it) is reported with the full candidate list — pass the project root you mean." },
|
|
33
|
+
mode: { type: "string", enum: ["report", "gate"], description: "How to ask the same two-criteria check. \"report\" (default): the whole declared domain, non-gating report. \"gate\": the delivery gate — only the documents this round touched, whole-file judged, first line a machine-readable verdict (DOC-CHECK GATE: PASS / REFUSED / ERROR)." },
|
|
34
|
+
docs: { type: "array", items: { type: "string" }, description: "Gate only: the documents this round touched (paths relative to the project root, or absolute). Omitted in gate mode ⇒ the documents this session wrote. Passing docs with mode \"report\" is refused, not silently ignored." },
|
|
24
35
|
},
|
|
25
36
|
},
|
|
26
37
|
readonly: true,
|
|
27
38
|
execute(args, ctx) {
|
|
28
39
|
const anchor = ctx?.agent?.cwd ?? ctx?.cwd ?? process.cwd()
|
|
29
40
|
const target = args?.target ? resolve(anchor, String(args.target)) : resolve(anchor)
|
|
41
|
+
const mode = args?.mode === undefined ? "report" : String(args.mode)
|
|
42
|
+
if (mode !== "report" && mode !== "gate") return `doc_check: unknown mode "${mode}" — use "report" (default: the whole declared domain) or "gate" (this round's documents).`
|
|
43
|
+
const explicit = Array.isArray(args?.docs) ? args.docs.map(String) : null
|
|
44
|
+
if (args?.docs !== undefined && !Array.isArray(args.docs)) {
|
|
45
|
+
return `doc_check: \`docs\` must be an array of paths (got ${typeof args.docs}) — a bare string is refused rather than read as a whole-domain request; pass ["<path>"].`
|
|
46
|
+
}
|
|
47
|
+
if (mode === "report" && explicit) {
|
|
48
|
+
return "doc_check: `docs` is only valid with mode:\"gate\" — report mode always checks the whole declared domain; pass mode:\"gate\" to scope the check to the documents this round touched."
|
|
49
|
+
}
|
|
30
50
|
const view = projectRootView(target)
|
|
31
51
|
if (view.state === "workspace") {
|
|
52
|
+
if (mode === "gate") {
|
|
53
|
+
return [
|
|
54
|
+
"DOC-CHECK GATE: ERROR(多项目工作区:请指名 target)",
|
|
55
|
+
`候选:${view.candidates.join(" ∥ ")}`,
|
|
56
|
+
"处置:指名项目根(`target`)后复跑",
|
|
57
|
+
].join("\n")
|
|
58
|
+
}
|
|
32
59
|
return `doc_check: ${target} is a workspace holding ${view.candidates.length} projects — pass the project root explicitly via target:\n` +
|
|
33
60
|
view.candidates.map((c) => `- ${c}`).join("\n")
|
|
34
61
|
}
|
|
35
|
-
|
|
62
|
+
const root = view.state === "ok" ? view.root : target
|
|
63
|
+
if (mode === "gate") {
|
|
64
|
+
// 引擎版本校验先于引擎(A-DD30 ①):stale 非空 ⇒ 直接 ERROR 串,不进引擎
|
|
65
|
+
const version = staleEngineFiles()
|
|
66
|
+
if (version.stale.length) return gateEngineError(version)
|
|
67
|
+
// 会话候选先取 `.md`(触达记账含代码档——代码档不是文档,不该进范围行);域求交归引擎单源
|
|
68
|
+
const docs = explicit ?? (Array.isArray(ctx?.agent?._touchedFiles) ? ctx.agent._touchedFiles : [])
|
|
69
|
+
.filter((p) => typeof p === "string" && p.toLowerCase().endsWith(".md"))
|
|
70
|
+
return runDocCheck(root, { mode: "gate", docs, docsSource: explicit ? "explicit" : "session" }).report
|
|
71
|
+
}
|
|
72
|
+
// 报告态读数标注(A-DD30 ①):引擎陈旧 ⇒ 首行前一行注记;无陈旧 ⇒ 报面逐字同改前
|
|
73
|
+
return withEngineNote(runDocCheck(root).report, staleEngineFiles())
|
|
36
74
|
},
|
|
37
75
|
}
|
|
@@ -1,52 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* spawn-gates.mjs — M5 委派与 spawn 门新门禁统一落点(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.3):
|
|
3
3
|
* 全部新增门禁逻辑集中于此——装配点(buildSpawnChild / normalizeFileList)只加 import 调用。
|
|
4
|
-
* 已实现:F2(round
|
|
4
|
+
* 已实现:F2(round 枚举 + 任务书参数校验/拼装——四项核心 + 第五项按角色)· F6(files 声明面拦截谓词)。
|
|
5
5
|
* 裁撤(2026-09-17 主 agent 裁定——不落代码):
|
|
6
|
-
* - F4 修复轮窄带:全量勘察散文 marker 不可机判(误拒风险不可接受)→ 由 F2
|
|
6
|
+
* - F4 修复轮窄带:全量勘察散文 marker 不可机判(误拒风险不可接受)→ 由 F2 的 round 参数承载轮次语义;
|
|
7
7
|
* - F5 advisor 角色:advisor 不入 spawn 通道(评审走 advisor 工具)——enum 收正为
|
|
8
8
|
* explore/eng-designer/eng-coder。
|
|
9
9
|
* 零依赖:本模块零 import(叶子——纯谓词层,无环、无反向依赖)。
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* F2 任务书五段文本字段(六强制字段中 round 之外的五段——ENGINEERING-MODE-V2-MODULE-DELEGATION §1.2 F2)。
|
|
14
|
-
* 判据 = 段标题在场(标题行或行内 marker——summaryEngTaskBook 同款手法:marker 正则命中
|
|
15
|
-
* task ∪ context 文本即可);内容够不够由执行者拒收兜底(需求 §1.14 #9 行为面——不做内容校验)。
|
|
16
|
-
* 双语 marker:中文标题(主 agent 派单形)与英文标题(交付报告格式模板形)同收。
|
|
17
|
-
*/
|
|
18
|
-
const TASK_BOOK_FIELDS = [
|
|
19
|
-
{ label: "目标与理由 (goal & why)", markers: [/目标与理由/, /goal\s*&\s*why/i, /goal\s+and\s+why/i] },
|
|
20
|
-
{ label: "已知事实 (known facts)", markers: [/已知事实/, /known\s+facts/i] },
|
|
21
|
-
{ label: "设计要点与禁止范围 (design points & forbidden scope)", markers: [/设计要点/, /design\s+points/i] },
|
|
22
|
-
{ label: "验收标准 (acceptance criteria)", markers: [/验收标准/, /acceptance\s+criteria/i] },
|
|
23
|
-
{ label: "交付报告格式 (delivery-report format)", markers: [/交付报告格式/, /交付报告/, /delivery[- ]report\s+format/i] },
|
|
24
|
-
]
|
|
25
|
-
|
|
26
12
|
/** F2 round 合法值(初始轮 / 修正轮——「派单必带」= 无默认值)。
|
|
27
13
|
* 单源:本档为唯一字面量处——schema 面(`agent-tools/subagent.mjs`)与谓词面(本档)同引用(D2)。
|
|
28
14
|
*/
|
|
29
15
|
export const ROUND_VALUES = ["initial", "fix"]
|
|
30
16
|
|
|
31
|
-
/**
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
17
|
+
/** 任务书样例内容(供人照抄;不是门的判据——门只判存在 / 类型 / 非空):核心四项一份,
|
|
18
|
+
* 第五项按角色各一份(工程角色 = `designPoints` ∥ explore / plan / coder = `scope`)。 */
|
|
19
|
+
const TASK_BOOK_CORE_MIN = {
|
|
20
|
+
goal: "实现批准设计(样例任务书——四项核心 + 角色第五项齐备)。",
|
|
21
|
+
knownFacts: "设计已评审通过、token 已签发、批次档在册。",
|
|
22
|
+
acceptance: "设计档的验收口径逐条可机验、项目验证链全绿。",
|
|
23
|
+
reportFormat: "交付表 + 文件路径 + 验证命令与结果。",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 工程角色样例(核心四项 + `designPoints`)。 */
|
|
27
|
+
export const ENG_TASK_BOOK_MIN = { ...TASK_BOOK_CORE_MIN, designPoints: "仅落设计档受影响文件清单;禁夹带新语义。" }
|
|
28
|
+
|
|
29
|
+
/** 非工程角色样例(核心四项 + `scope`)——explore / plan / coder。 */
|
|
30
|
+
export const PLAIN_TASK_BOOK_MIN = { ...TASK_BOOK_CORE_MIN, scope: "只读:相关源 / 设计档;不写任何文件、不碰 bench/。" }
|
|
42
31
|
|
|
43
32
|
/**
|
|
44
|
-
* F2
|
|
45
|
-
* round 枚举(initial|fix,必带集合 = eng-coder/eng-designer
|
|
46
|
-
*
|
|
47
|
-
* (context 是任务书背景段的携带参数——字段落在任一面均算在场)。
|
|
33
|
+
* F2 round 结构化参数校验(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.2):
|
|
34
|
+
* round 枚举(initial|fix,必带集合 = eng-coder/eng-designer)。
|
|
35
|
+
* 缺 / 非法 → 抛错(派单缺陷——spawn 机械拒)。
|
|
48
36
|
*/
|
|
49
|
-
export function
|
|
37
|
+
export function validateRoundParam(args) {
|
|
50
38
|
const a = args ?? {}
|
|
51
39
|
const missing = []
|
|
52
40
|
if (!ROUND_VALUES.includes(a.round)) {
|
|
@@ -54,17 +42,88 @@ export function validateTaskBookFields(args) {
|
|
|
54
42
|
? `round (${ROUND_VALUES.join("|")}) — the structured round parameter`
|
|
55
43
|
: `round ∈ {${ROUND_VALUES.join(", ")}} (got ${JSON.stringify(a.round)})`)
|
|
56
44
|
}
|
|
57
|
-
const text = [a.context, a.task].filter((s) => typeof s === "string").join("\n")
|
|
58
|
-
for (const f of TASK_BOOK_FIELDS) {
|
|
59
|
-
if (!f.markers.some((m) => m.test(text))) missing.push(f.label)
|
|
60
|
-
}
|
|
61
45
|
if (missing.length === 0) return
|
|
62
46
|
throw new Error(
|
|
63
|
-
`Engineering spawn
|
|
64
|
-
`The
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
47
|
+
`Engineering spawn round gate: ${missing.join(" · ")}. ` +
|
|
48
|
+
`The spawn contract requires the structured round parameter (initial|fix); ` +
|
|
49
|
+
`the five task-book parameters are enforced by a separate gate (validateTaskBookParams) — ` +
|
|
50
|
+
`a missing or invalid round is a dispatch defect and the spawn is refused.`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* F2 任务书名集(**所有 spawn 角色**):core = 四项核心(全员必带);fifth = 第五项两名
|
|
55
|
+
* ——工程角色 = `designPoints` ∥ explore / plan / coder = `scope`。
|
|
56
|
+
* 单源:本档为唯一名集处——校验 / 拼装 / 探针夹具校验一律经 `taskBookParamsFor(role)` 派生(D2)。
|
|
57
|
+
* 样例对象 = `ENG_TASK_BOOK_MIN` / `PLAIN_TASK_BOOK_MIN`(供人照抄)。
|
|
58
|
+
*/
|
|
59
|
+
export const TASK_BOOK_PARAMS = {
|
|
60
|
+
core: ["goal", "knownFacts", "acceptance", "reportFormat"],
|
|
61
|
+
fifth: { engineering: "designPoints", plain: "scope" },
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 角色 → 第五项类别(唯一判据;**与模式无关**——工程模式里的 explore 也取 plain)。
|
|
65
|
+
* 未知角色取 plain:spawn 路径上角色 enum 门先拒收(`agent-tools/subagent.mjs` 的 `ROLES`),到不了本门。 */
|
|
66
|
+
function fifthClassOf(role) {
|
|
67
|
+
return role === "eng-coder" || role === "eng-designer" ? "engineering" : "plain"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 角色 → 该角色的任务书名集(四项核心 + 第五项;序 = 拼装序:第五项插在 `knownFacts` 之后,
|
|
71
|
+
* 与 C 之前 eng 角色的拼装序逐字相同——审计摘要 marker `/^Design points/i` 不受影响)。 */
|
|
72
|
+
export function taskBookParamsFor(role) {
|
|
73
|
+
const core = TASK_BOOK_PARAMS.core
|
|
74
|
+
return [...core.slice(0, 2), TASK_BOOK_PARAMS.fifth[fifthClassOf(role)], ...core.slice(2)]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 拼装标签(不导出;仅词面,不是判据——无任何匹配读它)。 */
|
|
78
|
+
const TASK_BOOK_LABELS = {
|
|
79
|
+
goal: "Goal & why",
|
|
80
|
+
knownFacts: "Known facts",
|
|
81
|
+
designPoints: "Design points & forbidden scope",
|
|
82
|
+
scope: "Scope (what to read / what not to touch)",
|
|
83
|
+
acceptance: "Acceptance criteria",
|
|
84
|
+
reportFormat: "Delivery report format",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* F2 任务书参数校验(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.2)· **所有 spawn 角色**:
|
|
89
|
+
* 名集按角色取(`taskBookParamsFor(role)`)。判据只有三条——参数存在 ∧ typeof string ∧
|
|
90
|
+
* trim() 非空。不读内容:无正则、无标题匹配、无双语 marker。缺 / 空白 / 非字符串 → 合并抛,
|
|
91
|
+
* 文案逐项点名该参数;第五项缺失而另一名在场时,文案附一句指路(只作文案,不加判据)。
|
|
92
|
+
*/
|
|
93
|
+
export function validateTaskBookParams(args, role) {
|
|
94
|
+
const a = args ?? {}
|
|
95
|
+
const names = taskBookParamsFor(role)
|
|
96
|
+
const fifth = TASK_BOOK_PARAMS.fifth[fifthClassOf(role)]
|
|
97
|
+
const otherFifth = fifth === "designPoints" ? "scope" : "designPoints"
|
|
98
|
+
const failed = []
|
|
99
|
+
for (const name of names) {
|
|
100
|
+
const v = a[name]
|
|
101
|
+
if (v === undefined || v === null) {
|
|
102
|
+
const hint = name === fifth && a[otherFifth] !== undefined && a[otherFifth] !== null
|
|
103
|
+
? ` (got ${otherFifth} instead — that name belongs to the ${fifthClassOf(role) === "engineering" ? "explore/plan/coder" : "eng-coder/eng-designer"} spawns)`
|
|
104
|
+
: ""
|
|
105
|
+
failed.push(`${name} — missing (the task book is four core parameters + the role's fifth)${hint}`)
|
|
106
|
+
}
|
|
107
|
+
else if (typeof v !== "string") failed.push(`${name} — must be a string (got ${typeof v})`)
|
|
108
|
+
else if (v.trim() === "") failed.push(`${name} — blank (trim() is empty)`)
|
|
109
|
+
}
|
|
110
|
+
if (failed.length === 0) return
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Spawn task-book gate (role='${role}'): ${failed.join(" · ")}. ` +
|
|
113
|
+
`The task book is four core parameters (${TASK_BOOK_PARAMS.core.join(" / ")}) plus ${fifth} ` +
|
|
114
|
+
`(the fifth parameter for role='${role}'); a missing, blank or non-string value is a dispatch defect and the spawn is refused.`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 五参 → 子代理任务书文本(**非门禁**,纯拼装):按 `taskBookParamsFor(role)` 固定序 + 标签,
|
|
118
|
+
* 空值跳过,`\n\n` 分隔。拼装单源 = 本函数——子代理 input 与探针判官的「任务书全文」同源。 */
|
|
119
|
+
export function composeTaskBookBrief(args, role) {
|
|
120
|
+
const a = args ?? {}
|
|
121
|
+
const parts = []
|
|
122
|
+
for (const name of taskBookParamsFor(role)) {
|
|
123
|
+
const v = a[name]
|
|
124
|
+
if (typeof v === "string" && v.trim() !== "") parts.push(`${TASK_BOOK_LABELS[name]}: ${v.trim()}`)
|
|
125
|
+
}
|
|
126
|
+
return parts.join("\n\n")
|
|
68
127
|
}
|
|
69
128
|
|
|
70
129
|
/**
|
|
@@ -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",
|