@aion0/forge 0.10.20 → 0.10.22

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/RELEASE_NOTES.md CHANGED
@@ -1,8 +1,8 @@
1
- # Forge v0.10.20
1
+ # Forge v0.10.22
2
2
 
3
3
  Released: 2026-05-31
4
4
 
5
- ## Changes since v0.10.19
5
+ ## Changes since v0.10.20
6
6
 
7
7
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.19...v0.10.20
8
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.20...v0.10.22
@@ -65,7 +65,7 @@ function deriveModeLabel(entries: ConnectorEntry[]): 'server-side' | 'browser-si
65
65
  }
66
66
  }
67
67
  if (ps.size === 0) return 'browser-side';
68
- const allServer = [...ps].every((p) => p === 'http' || p === 'shell');
68
+ const allServer = [...ps].every((p) => p === 'http' || p === 'shell' || p === 'ssh');
69
69
  const allBrowser = [...ps].every((p) => p === 'browser');
70
70
  return allServer ? 'server-side' : allBrowser ? 'browser-side' : 'mixed';
71
71
  }
@@ -0,0 +1,210 @@
1
+ # Forge — Long-Task Watch(轻量独立后台轮询 + 回调编排)设计方案
2
+
3
+ > 状态:**待审,未实现**。给 zliu 决策用。
4
+ > 一句话定性(zliu):watch = **在 chat 里支持的一个轻量化异步回调机制**。
5
+ > 消费者:TP 升级、**NAC 直连升级(nac.upgrade → 轮询 nac.get_version 直到
6
+ > build 匹配)**、pytest 跑——凡是"发起后要等、完成再回 chat"的都用它。
7
+ > 起因:TP `upgrade_lab`(NAC 升级)同步阻塞 5-10 分钟。连接器已用
8
+ > `detach` 让它不卡死,但"发起后盯到完成"这步若靠 AI 在对话里轮询,
9
+ > **不可靠**。需要一个**轻量、独立、纯后台**的机制:发起后自动定期 poll,
10
+ > 完成时**回调一个工具**(而非通知用户),全程不依赖 AI / 不依赖对话存活。
11
+
12
+ ## 0. 目标 & 非目标(按 zliu 最新意见收敛)
13
+
14
+ **目标**
15
+ - 发起即返回,不 hold 对话/标签页。
16
+ - **轻量、独立**:自带一个小 ticker + 一张表,**不挂靠 Schedules**(那是用户
17
+ 预定任务,语义不同),也不新增 standalone 进程。
18
+ - 完成/失败 → **回灌到发起的那个 chat 会话**:把 poll 结果作为一条
19
+ tool-result/系统事件喂回该 session,**让助手吐一条消息**(并可顺势继续判断/
20
+ 调用下一步工具)。这天然复用 chat:若该会话来自 telegram,消息就自然回到
21
+ telegram;来自 /chat 就在 /chat 冒出来——**不需要单独的 telegram/email 通道**。
22
+ - (可选)纯编排:也支持声明式 `on_done` 直接调一个工具做链式(升级完→自动
23
+ run_pytest),不吐消息。两种模式按 manifest 选。
24
+ - **每次 poll 可给轻量进度反馈,但不抢主对话**(zliu):进度走**独立的
25
+ 状态通道**(`watch_status` push),在 chat 里渲染成一个**会就地更新的小状态条/
26
+ chip**——每 tick 替换、不追加,**不进消息历史、不触发 LLM**,完成即消失。
27
+ 只有最终 `on_done`/`on_fail` 的终态消息才真正落进对话线程。即:
28
+ **过程 = 环境状态(ambient),结果 = 一条真消息**。
29
+ - **防死循环是一等需求**:后台自动调度最怕失控,必须有硬上限。
30
+ - **极简管理面**:不做常规 UI,但要**一个能看 active watch 列表 + 取消/删除**
31
+ 的地方,避免 watch 堆积、占资源、跑飞。
32
+
33
+ **非目标**
34
+ - 不做"独立于 chat 之外"的新通知通道——完成消息走发起会话本身的 chat 流。
35
+ - 不做复杂表达式引擎(done 判定走安全的点路径)。
36
+ - 不进 Schedules UI、不算一种 Schedule 类型。
37
+
38
+ ## 1. 形态:独立 `watch` 模块
39
+
40
+ ```
41
+ AI(在 session S)─call─> upgrade_lab ─(detach 25s, 返回 fired_at)─┐
42
+ ├─ dispatcher 见 async: registerWatch(..., session_id=S)
43
+ <── 立即返回 {dispatched, watch_id} ──────────────────┘
44
+ …………(对话可结束)…………
45
+ watch ticker(独立, ~30-60s)
46
+ └ 对每条 active watch,到点:
47
+ poll_tool ── done_path? ──┬─ 是 → 完成动作 → 终态 done
48
+ ├─ fail_path? → 失败动作 → 终态 failed
49
+ └─ 否 → polls++;超 max_polls / 过 timeout → 终态 timed_out(走失败动作)
50
+
51
+ 完成/失败动作(二选一,按 manifest):
52
+ • mode=chat(默认):把结果作为 tool-result 回灌 session S → resume 该会话 →
53
+ 助手吐消息(走 chat-standalone 现有的 resume + bridgePush 流;telegram 会话则到 telegram)。
54
+ • mode=tool:派发声明的 on_done.tool(链式编排,不吐消息)。
55
+ ```
56
+
57
+ - ticker 是一个 `setInterval`(随 **chat-standalone** 起,单实例守卫),**不是新
58
+ 进程**,也**不复用 Schedules tick**——逻辑独立、好推理、好限流。放 chat-standalone
59
+ 里还顺手:回灌 session 用的就是它自己的 resume 能力。
60
+ - registerWatch 记下 `session_id`(发起工具的那个 chat 会话),完成时回灌。
61
+ - watch 是**短生命周期**:到达终态(done/failed/timed_out/cancelled)即停轮询。
62
+
63
+ ## 2. Manifest 声明(连接器侧,新增 `async` 块)
64
+
65
+ ```yaml
66
+ upgrade_lab:
67
+ destructive: true
68
+ async:
69
+ poll: check_lab_upgrade_status # 同连接器内的查询工具
70
+ poll_args: # 用触发工具的 result/args 拼 poll 入参
71
+ deployinfo: "{args.deployinfo}"
72
+ lab: "{args.lab}"
73
+ since: "{result.fired_at}" # {result.*}=触发工具返回值;{args.*}=触发入参
74
+ # 完成判定:二选一。
75
+ done_path: done # (A) poll 结果该路径 truthy = 完成(TP:连接器自己比好返回 done:true)
76
+ # done_match: # (B) 值比较(NAC:get_version 返回 build 号,跟目标比)
77
+ # path: captured.build # poll 结果里的路径
78
+ # equals: "{args.target_build}" # equals / contains,期望值从触发 args/result 模板取
79
+ fail_path: any_failure # 可选:truthy = 失败
80
+ interval_sec: 60 # 轮询间隔(下限 30s)
81
+ timeout_sec: 1200 # 总时长上限(到点判 timed_out)
82
+ max_polls: 40 # 轮询次数硬上限(防失控)
83
+ on_done: # 完成动作
84
+ mode: chat # chat(默认,回灌发起会话让助手吐消息) | tool(链式) | none(仅落库)
85
+ message: "升级完成,核对结果。" # mode=chat 时注入会话的提示(可引用 {poll.*})
86
+ tool: "" # mode=tool 时要调的工具名
87
+ args: {} # mode=tool 入参,可引用 {poll.*}/{args.*}
88
+ on_fail: # 可选,同形(默认 mode=chat,把失败/超时也吐回会话)
89
+ mode: chat
90
+ message: "升级未在预期内完成,请手动核对。"
91
+ progress: # 每次 poll 的轻量反馈(不抢主对话)
92
+ show: true # 默认 true;false = 后台静默,只在终态出消息
93
+ message: "盯升级中… 第 {poll_count}/{max_polls} 次,build={poll.build}" # 可引用 {poll.*}/{poll_count}
94
+ ```
95
+
96
+ **两条输出通道(关键:进度不抢主对话)**
97
+ - **进度通道**:每 tick 通过独立 push topic `watch_status` 推一条
98
+ `{watch_id, state, poll_count, text}`。chat 端渲染成一个**就地更新的小状态条/
99
+ chip**(按 watch_id 去重替换,不追加),**不写入消息历史、不触发 LLM、不计入
100
+ 上下文**;watch 一到终态该状态条自动消失/收起。这样轮询再多也不污染对话。
101
+ - **结果通道**:只有 `on_done`/`on_fail`(mode=chat)产出**一条真消息**进对话
102
+ 线程。`progress.show:false` 则全程静默,只留终态。
103
+ - 实现:进度走 `bridgePush('watch_status', …)`(extension/web 各加一个轻量
104
+ status-bar 组件订阅);结果走已有的 session resume 回灌(§1)。
105
+
106
+ - **完成回灌 chat**:默认 `mode=chat`——把最后一次 poll 结果 + `message` 作为
107
+ tool-result 喂回发起的 session,助手据此吐一条消息(还能继续调用下一步)。
108
+ `mode=tool` 则纯链式派发工具、不吐消息;`mode=none` 只落库置终态(AI 下次
109
+ 需要时可查)。tool 模式走和 chat 同一条 dispatcher,可链式。
110
+ - **done 判定不用 eval**:`done_path`/`fail_path` 点路径取真值;`done_match`
111
+ 做 equals/contains 比较(期望值模板取),都不跑 eval、零注入面。
112
+
113
+ NAC 直连升级的 async 块(第二个消费者,用 done_match):
114
+ ```yaml
115
+ upgrade: # nac connector
116
+ async:
117
+ poll: get_version
118
+ poll_args: {} # get_version 无参,连 settings 里的 host 即可
119
+ done_match: { path: build, equals: "{args.target_build}" } # build6957 → "6957"
120
+ interval_sec: 60
121
+ timeout_sec: 900 # 含 reboot,放宽
122
+ on_done: { mode: chat, message: "NAC {settings.host} 已升级到 build {poll.build}。" }
123
+ ```
124
+ 注:`nac.upgrade` 目前参数没有 target_build——接 watch 时给它加一个(或从
125
+ 镜像文件名 `build(\d+)` 自动解析),传给 watch 做比较。
126
+
127
+ ## 3. 防死循环(纯后台调度的核心约束)
128
+
129
+ | 失控源 | 护栏 |
130
+ |---|---|
131
+ | 轮询永不停 | `max_polls`(默认 40)+ `timeout_sec`(默认 1200s),先到先终止 |
132
+ | 同一 watch 并发重入 | **单飞**:每条 watch 有 `running` 锁,上一次 poll 没回来不发下一次 |
133
+ | 回调再生回调(A→B→A) | 回调链 `chain_depth` 上限(默认 3)+ 已访问 watch 链路环检测 |
134
+ | 回调本身又是 async 工具 | 允许,但继承并递减 `chain_depth`;到 0 则只跑、不再登记新 watch |
135
+ | watch 越堆越多 | 全局 active watch 上限(默认 50);超了拒绝新登记并在触发工具结果里报错 |
136
+ | poll 报错刷屏 | 连续 N 次错误(默认 5)→ 终态 `errored`,不再重试 |
137
+ | 僵尸 watch | 任何 watch 硬性 `max_lifetime`(默认 2×timeout)兜底清理 |
138
+
139
+ 所有上限都有**默认值**且 manifest 可调小,不可调到无界。
140
+
141
+ ## 4. 重启不丢
142
+
143
+ - watch **持久化在 SQLite**(`connector_watches`),不是内存。
144
+ - Forge 重启/崩溃 → ticker 起来后扫 `state=active` 的 watch,凭存下的
145
+ `fired_at`/`next_poll_at`/`polls` 续轮询,**不靠 AI、不靠原对话**。
146
+ - 宕机超过 `max_lifetime` 的 watch → 标 `timed_out`、跑 `on_fail`(若有),
147
+ 绝不静默丢。
148
+
149
+ ## 5. 极简管理面(防资源占用 / 可取消)
150
+
151
+ 不做常规 UI,但提供**最小可观测+可控**:
152
+
153
+ - **API**:`GET /api/watches`(列 active+近期终态)、`POST /api/watches/:id/cancel`、
154
+ `DELETE /api/watches/:id`。
155
+ - **UI 落点**(二选一,倾向 A):
156
+ - **A. Settings → Monitor** 加一个 "Background Watches" 折叠区:每行显示
157
+ connector·poll_tool·polls/max·下次轮询·状态,带 Cancel/Delete。和已有
158
+ 进程监控同处,零新页面。
159
+ - **B.** /chat 侧栏一个折叠小列表。
160
+ - cancel = 立即置 `cancelled` 终态、停轮询;delete = 删行。
161
+
162
+ ## 6. 改动清单
163
+
164
+ | 在哪 | 改什么 | 量 |
165
+ |---|---|---|
166
+ | 类型 | `lib/connectors/types.ts`:`ConnectorTool.async?: AsyncWatchSpec`(poll/poll_args/done_path/done_match/fail_path/interval/timeout/max_polls/on_done/on_fail/**progress**) | 小 |
167
+ | 存储 | `lib/watch/watch-store.ts`(新):SQLite 表 + CRUD + 单实例守卫 | ~100 行 |
168
+ | 轮询 | `lib/watch/watch-runner.ts`(新):独立 ticker,单飞、护栏、终态机、调 poll/回调、**每 tick `bridgePush('watch_status',…)`** | ~170 行 |
169
+ | 派发 | `lib/chat/tool-dispatcher.ts`:跑完带 `async` 的工具 → `registerWatch`,返回附 `watch_id` | 小 |
170
+ | API | `app/api/watches/route.ts` + `[id]`:list / cancel / delete | 小 |
171
+ | UI(状态条) | extension + /chat 各加一个轻量组件,订阅 `watch_status` push → 渲染就地更新的 chip(不进消息流) | 小 |
172
+ | UI(管理面) | Settings Monitor 加 Background Watches 折叠区(只读+cancel/delete) | 小 |
173
+ | 连接器 | `upgrade_lab`/`upgrade_device` 加 `async:` 块 | 几行 |
174
+
175
+ **主体在 Forge**(类型+store+runner+API+UI);连接器只加声明块。**不复用
176
+ Schedules、不新增进程**——独立轻量,正合你要的形态。
177
+
178
+ ## 7. TP 落地体验(后台轮询 + 完成回灌 chat)
179
+
180
+ ```
181
+ 用户:升级 AT16_Combined_FSW 到 build6957
182
+ AI :upgrade_lab(command=…) → {dispatched, watch_id, fired_at}
183
+ AI :「已发起,后台盯到完成会在这里告诉你。」← 对话可结束/去忙别的
184
+ (后台 ticker 每 60s poll check_lab_upgrade_status(since=fired_at),无 AI 参与)
185
+ done:true → 回灌 session S → 助手在原会话吐:
186
+ 「✅ AT16_Combined_FSW 升级完成,FortiNAC(10.15.52.152)已到 build6957。」
187
+ (若该会话是 telegram,这条就到 telegram;是 /chat 就在 /chat 冒出来。
188
+ 若配了 on_done.mode=tool,则改为自动跑 run_pytest,不吐消息。)
189
+ ```
190
+
191
+ ## 8. 工作量
192
+
193
+ - Forge:store + runner + dispatcher 钩子 + API + Monitor 区 ≈ **1~1.5 天**
194
+ (护栏/单飞/重启续跑要写稳)。
195
+ - 连接器:几行。
196
+
197
+ ## 9. 待你决策
198
+
199
+ - [x] UI:不做常规 UI,但有**极简管理面**(列表+cancel/delete),落点倾向
200
+ Settings→Monitor 的折叠区。
201
+ - [x] 重启不丢:SQLite 持久化,续跑;超期回灌/回调,不静默丢。
202
+ - [x] 完成动作:默认 **mode=chat 回灌发起会话让助手吐消息**(telegram 会话→telegram,
203
+ /chat→/chat);可选 mode=tool 纯链式、mode=none 仅落库。**不**另起独立通知通道。
204
+ - [x] 进度反馈:每 tick 走独立 `watch_status` push,渲染成**就地更新的状态条/chip,
205
+ 不进消息流、不触发 LLM**;只有终态出一条真消息。`progress.show` 可关。
206
+ - [ ] **是否实现?**
207
+ - [ ] 管理面落点 A(Settings→Monitor)还是 B(/chat 侧栏)? 右上角菜单下有个 watch 按钮
208
+ - [ ] 回调链深度 / active 上限 / max_polls 默认值是否认可(3 / 50 / 40)?
209
+ - [ ] mode=chat 回灌时,助手是"只吐一条总结消息"还是"可继续自主调用工具"(后者更强但要纳入防循环上限)?
210
+ - 只回总结吧,终止,然后由 chat决定吧。不用做嵌套,因为本身就是异步跟踪了,就可以一直获取。如果真的需要嵌套就设置一个嵌套数量吧,
@@ -0,0 +1,617 @@
1
+ # TP `/automation` Page — API Reference
2
+
3
+ Reference for the HTTP endpoints used by TP's Automation page
4
+ (`/automation`, source: `frontend/src/pages/Automation/Automation.jsx`)
5
+ and the related upgrade / testbed workflows the page invokes through
6
+ shared infrastructure.
7
+
8
+ All endpoints are mounted under the `adc` Django app:
9
+
10
+ ```
11
+ <TP-base-url>/adc/<endpoint>
12
+ ```
13
+
14
+ `<TP-base-url>` examples:
15
+ - Production: `https://nac-tp.fortinet-us.com`
16
+ - Test: `http://10.15.33.25:8000`
17
+ - Dev (.11): `http://10.15.33.11:8000`
18
+
19
+ ## Authentication
20
+
21
+ Every endpoint requires a JWT in the `Authorization` header:
22
+
23
+ ```
24
+ Authorization: JWT <token>
25
+ ```
26
+
27
+ Mint a token:
28
+
29
+ ```bash
30
+ T=$(curl -s -X POST <TP-base-url>/token-auth/ \
31
+ -H 'Content-Type: application/json' \
32
+ -d '{"username":"<user>","password":"<pw>"}' | jq -r .token)
33
+ ```
34
+
35
+ In the examples below, `$T` stands for the JWT. Tokens are
36
+ short-lived; on 401 the frontend redirects to SSO, and scripts should
37
+ re-mint.
38
+
39
+ ---
40
+
41
+ ## Endpoints called by the `/automation` page
42
+
43
+ ### `GET /adc/automation-verion/`
44
+
45
+ Returns the FortiNAC versions the automation pipeline tracks.
46
+ Populates the version dropdown.
47
+
48
+ Response:
49
+ ```json
50
+ {"versions": ["7.4.6", "7.4.7", "7.6.5", "7.6.6"]}
51
+ ```
52
+
53
+ Backed by: `adc.views.dashboard.dashboardviews.get_automation_version`
54
+
55
+ ### `GET /adc/get_testcases`
56
+
57
+ Walks the cloned test-framework repo on TP, parses every Python test
58
+ file, and returns a hierarchical tree of modules → test cases.
59
+
60
+ **Side-effect:** pulls latest commits on the `main` branch of the local
61
+ clone before parsing. Calling from a script will rebase TP's local
62
+ repo on `main` — fine in normal use, but worth knowing if a developer
63
+ is hand-testing branches on the TP host.
64
+
65
+ Response shape (truncated):
66
+ ```json
67
+ {
68
+ "tests": {
69
+ "L2": {
70
+ "test_l2_radius.py": [
71
+ "test_basic_auth",
72
+ "test_radius_attributes"
73
+ ]
74
+ },
75
+ "L3": {}
76
+ }
77
+ }
78
+ ```
79
+
80
+ Backed by: `automationview.get_testcases`
81
+
82
+ ### `POST /adc/pytest_run`
83
+
84
+ Kicks off a pytest execution on the chosen automation testbed. Creates
85
+ a `PytestExecution` row and returns its id; the actual run is
86
+ asynchronous.
87
+
88
+ Body:
89
+ ```json
90
+ {
91
+ "user": "alice",
92
+ "lab": "L2Mode_7",
93
+ "testcase": [
94
+ "tests/L2/test_l2_radius.py::test_basic_auth",
95
+ "tests/L2/test_l2_radius.py::test_radius_attributes"
96
+ ],
97
+ "argument": "-k 'radius and not flaky' --tb=short -vv"
98
+ }
99
+ ```
100
+
101
+ | Field | Type | Notes |
102
+ |---|---|---|
103
+ | `user` | string | TP username of the caller. |
104
+ | `lab` | string | AT lab name from `AutomationTBUser` (the same names returned by `get_automation_lab`). Comma-separate multiple labs (`"L2Mode_7,L2Mode_9"`). The caller must already own or be a member of `usedby` on the lab, and no other execution can be `Running`/`Initiating` against it. |
105
+ | `testcase` | **list of strings** | Each entry is a pytest test-id path. The handler iterates the list, prefixes each with the test-framework repo path on TP, and joins them with spaces before invoking pytest. |
106
+ | `argument` | string | **Raw pytest CLI arguments**, injected verbatim between the testcase paths and the framework's `--html=...`/`--rack-file ...` flags. Use this for filters, verbosity, fail-fast, collect-only, marker expressions, etc. |
107
+
108
+ The handler also accepts any **extra** fields in the body — they're
109
+ preserved on the execution record. You can attach tracking metadata
110
+ (`mantis_id`, `jenkins_job`, etc.) without backend changes.
111
+
112
+ #### What gets executed on the testbed
113
+
114
+ The worker generates a shell script of the form:
115
+
116
+ ```bash
117
+ git pull origin main
118
+ cd <repo>
119
+ source venv/bin/activate
120
+ export PYTHONPATH=<test-framework>:<tests>
121
+ export DISPLAY=:99
122
+ pytest <testcase paths> <argument> --collect-only
123
+ pytest <testcase paths> <argument> --html=<report> --rack-file <rack> <pytest_options>
124
+ ```
125
+
126
+ So `argument` is literally the slot for any pytest flag. The worker
127
+ runs `--collect-only` first as a dry-run check, then the real run.
128
+
129
+ #### Examples
130
+
131
+ **Run one test:**
132
+ ```json
133
+ {
134
+ "user": "alice",
135
+ "lab": "L2Mode_7",
136
+ "testcase": ["tests/L2/test_l2_radius.py::test_basic_auth"],
137
+ "argument": ""
138
+ }
139
+ ```
140
+
141
+ **Run several specific tests, with verbose output and fail-fast:**
142
+ ```json
143
+ {
144
+ "user": "alice",
145
+ "lab": "L2Mode_7",
146
+ "testcase": [
147
+ "tests/L2/test_l2_radius.py::test_basic_auth",
148
+ "tests/L3/test_l3_vpn.py::test_vpn_login"
149
+ ],
150
+ "argument": "-vv -x"
151
+ }
152
+ ```
153
+
154
+ **Run every test in a file, filtered by keyword:**
155
+ ```json
156
+ {
157
+ "user": "alice",
158
+ "lab": "L2Mode_7",
159
+ "testcase": ["tests/L2/test_l2_radius.py"],
160
+ "argument": "-k 'radius and not flaky'"
161
+ }
162
+ ```
163
+
164
+ **Dry-run / list-only (no test bodies executed by the second pytest invocation):**
165
+ ```json
166
+ {
167
+ "user": "alice",
168
+ "lab": "L2Mode_7",
169
+ "testcase": ["tests/L2/"],
170
+ "argument": "--collect-only"
171
+ }
172
+ ```
173
+
174
+ (Note: `--collect-only` runs twice in this case — once by the worker's
175
+ hardcoded first-line check, once by your explicit flag. Functionally
176
+ identical to a normal collect-only.)
177
+
178
+ #### Response
179
+
180
+ ```json
181
+ {"exec_id": "53"}
182
+ ```
183
+ Or on error:
184
+ ```json
185
+ {"error": "..."}
186
+ ```
187
+
188
+ On lab conflict (someone else's run is in progress on that lab), the
189
+ response contains `conflict_labs` and `usable_labs` instead of
190
+ `exec_id`.
191
+
192
+ Backed by: `automationview.pytest_run` → `PytestAction.create_execution_entry`
193
+
194
+ ### `POST /adc/get_automation_lab`
195
+
196
+ Lists the automation testbeds the calling user can see (owned or
197
+ shared). Each entry includes a live `status` field computed from
198
+ in-progress executions.
199
+
200
+ Body:
201
+ ```json
202
+ {"user": "alice"}
203
+ ```
204
+
205
+ Response (truncated):
206
+ ```json
207
+ [
208
+ {
209
+ "name": "L2Mode_7",
210
+ "usedby": "alice,bob",
211
+ "status": "Available"
212
+ }
213
+ ]
214
+ ```
215
+
216
+ `status` is `"Running"` when any `PytestExecution` for this lab has
217
+ status `Running`, else `"Available"`.
218
+
219
+ ### `POST /adc/get_test_result`
220
+
221
+ Returns the calling user's recent test executions with computed
222
+ progress percentages.
223
+
224
+ Body:
225
+ ```json
226
+ {"user": "alice"}
227
+ ```
228
+
229
+ Response:
230
+ ```json
231
+ [
232
+ {
233
+ "id": 53,
234
+ "name": "test001",
235
+ "report_file_path": "media/automation/53/report.html",
236
+ "log_file_path": "media/automation/53/log.txt",
237
+ "status": "Running",
238
+ "progress": 42
239
+ }
240
+ ]
241
+ ```
242
+
243
+ `progress = (pass + fail + skip + error) / total * 100`, rounded.
244
+
245
+ `report_file_path` and `log_file_path` are server-relative; open them
246
+ by prepending `<TP-base-url>` and sending the JWT.
247
+
248
+ ### `POST /adc/get_test_execution_by_id`
249
+
250
+ Returns one execution's full record (steps, counts, environment,
251
+ timestamps) for the detail panel.
252
+
253
+ Body:
254
+ ```json
255
+ {"id": 53}
256
+ ```
257
+
258
+ Response: serialized `PytestExecution`. Returns 404 if not found.
259
+
260
+ ### `POST /adc/test_exec_kill`
261
+
262
+ Aborts a running execution. The row stays in the DB with a terminal
263
+ status; the underlying pytest subprocess is sent SIGTERM.
264
+
265
+ Body:
266
+ ```json
267
+ {"id": 53}
268
+ ```
269
+
270
+ ### `POST /adc/test_exec_delete`
271
+
272
+ Deletes one or many `PytestExecution` rows and their on-disk
273
+ artifacts. Accepts either a single id or a list.
274
+
275
+ Body (single):
276
+ ```json
277
+ {"id": 53}
278
+ ```
279
+
280
+ Body (bulk):
281
+ ```json
282
+ {"historyresults": [50, 51, 52]}
283
+ ```
284
+
285
+ ### `POST /adc/automation_lock`
286
+
287
+ Marks a testbed as in-use, or releases it. Used to serialize tests
288
+ that need exclusive access to shared hardware.
289
+
290
+ Body:
291
+ ```json
292
+ {"action": "lock", "user": "alice", "name": "L2Mode_7"}
293
+ ```
294
+
295
+ `action` is `"lock"` or `"unlock"`.
296
+
297
+ ### `POST /adc/automation_tb_set_viewers/`
298
+
299
+ Edits who can *see* a testbed (separate from ownership/usage).
300
+
301
+ Body:
302
+ ```json
303
+ {
304
+ "action": "add",
305
+ "tb_name": "L2Mode_7",
306
+ "viewers": ["bob", "carol"]
307
+ }
308
+ ```
309
+
310
+ `action` is `"add"`, `"remove"`, or `"set"`.
311
+
312
+ ### `GET /adc/status-check/`
313
+
314
+ Generic shared-lab status payload used by the status widget rendered
315
+ on the page. Not specific to automation but called from it.
316
+
317
+ Backed by: `adc.views.sharedlab.tbviews.ClassicCombinedStatus`
318
+
319
+ ### `GET /user/get_user/<username>`
320
+
321
+ Returns the calling user's profile (role, group, default project) so
322
+ the page can decide what controls to render. Note: outside the `adc`
323
+ namespace.
324
+
325
+ ---
326
+
327
+ ## NAC upgrade APIs
328
+
329
+ The `/automation` page does not call these directly, but every
330
+ automation run that targets a specific build relies on the testbed
331
+ having been upgraded first. There are **two modes** of upgrade:
332
+
333
+ | Mode | What it targets | Endpoints |
334
+ |---|---|---|
335
+ | **Device mode** | One specific `ip` | `nac-upgrade/`, `nac-upgrade-version-snapshot/`, `nac-upgrade-snapshot/`, `check-upgrade-status/` |
336
+ | **Testbed mode** | Every NAC/NCM device in a testbed (discovered from `deployinfo`) | `nac-upgrade-testbed/` |
337
+
338
+ Both modes share the same `upgrade_type` axis:
339
+
340
+ | `upgrade_type` | Required extra field | Meaning |
341
+ |---|---|---|
342
+ | `GA` | (none) | Pull the latest GA image from the build server |
343
+ | `build` | `build_number` | Download a specific build (e.g. `7.6.6.0123`) and install it |
344
+ | `file` | `file_uploaded` (multipart) | Install from an uploaded image |
345
+ | `command` | `command` | Run a raw upgrade CLI command (advanced/manual) |
346
+
347
+ Upgrade work is **asynchronous** in both modes — the call returns once
348
+ the job is queued. Use the device-mode status endpoint to poll.
349
+
350
+ ### Device mode
351
+
352
+ #### `POST /adc/nac-upgrade/`
353
+
354
+ Upgrade a single FortiNAC device. Accepts `multipart/form-data` so it
355
+ can carry the uploaded image file.
356
+
357
+ Form fields:
358
+ - `ip` — target device IP (required)
359
+ - `upgrade_type` — `GA` / `build` / `file` / `command`
360
+ - `build_number` — when `upgrade_type=build`
361
+ - `file_uploaded` — when `upgrade_type=file` (image file as multipart)
362
+ - `command` — when `upgrade_type=command`
363
+
364
+ Response:
365
+ ```json
366
+ {"status": "success", "message": "..."}
367
+ ```
368
+ HTTP `202` on success, `400` on failure.
369
+
370
+ Backed by: `nacviews.nac_upgrade`
371
+
372
+ #### `POST /adc/nac-upgrade-version-snapshot/`
373
+
374
+ Take a pre-upgrade snapshot of the device's current image so it can be
375
+ reverted later. Returns once the snapshot job has been queued.
376
+
377
+ Body:
378
+ ```json
379
+ {
380
+ "ip": "10.15.40.42",
381
+ "dev_name": "fortinac01",
382
+ "build_number": "7.6.6.0123",
383
+ "upgrade_type": "build",
384
+ "major_version": "7.6",
385
+ "minor_version": "6",
386
+ "rp_prefix": "rp",
387
+ "command": "<optional, when upgrade_type=command>"
388
+ }
389
+ ```
390
+
391
+ Backed by: `nacupgradeview.nac_upgrade_version_snapshot`
392
+
393
+ #### `POST /adc/nac-upgrade-snapshot/`
394
+
395
+ Upgrade *with* automatic snapshot/revert. Same payload as
396
+ `nac-upgrade-version-snapshot/` plus:
397
+
398
+ - `revert_snapshot` — `true` / `false`; when `true`, the device is
399
+ reverted to the snapshot if the upgrade fails.
400
+
401
+ Backed by: `nacupgradeview.nac_upgrade_snapshot`
402
+
403
+ #### `POST /adc/check-upgrade-status/`
404
+
405
+ Poll the upgrade status of a single device.
406
+
407
+ Body:
408
+ ```json
409
+ {"ip": "10.15.40.42"}
410
+ ```
411
+
412
+ Response: state of the most recent upgrade task for that IP. Shape:
413
+ ```json
414
+ {
415
+ "is_upgrading": false,
416
+ "last_task_status": "SUCCESS",
417
+ "last_task_type": "build",
418
+ "last_build_number": "7.6.6.0123",
419
+ "last_image_name": "FortiNAC-F-7.6.6.0123.out",
420
+ "last_major_version": "7.6",
421
+ "last_minor_version": "6",
422
+ "updated_at": "2026-05-28T11:42:01.123456+00:00",
423
+ "log": "...full log tail...",
424
+ "ip": "10.15.40.42"
425
+ }
426
+ ```
427
+
428
+ `is_upgrading` is `true` when `last_task_status` is `PENDING` or
429
+ `PROGRESS`. The endpoint works for both modes — see the table at the
430
+ top of the section for which testbed-mode `upgrade_type`s actually
431
+ write to this table — but read the polling caveats below carefully.
432
+
433
+ ##### Polling caveat — testbed-mode `build`
434
+
435
+ `run_download_async` (the worker that handles `upgrade_type=build` in
436
+ testbed mode) does **not** set `last_task_status` to `PENDING` or
437
+ `PROGRESS` while running. It only writes `SUCCESS` on completion.
438
+ Effect: while a testbed-mode build upgrade is in flight,
439
+ `is_upgrading` returns `false` even though the worker is actively
440
+ downloading + restoring on the device.
441
+
442
+ To get live progress mid-run for testbed-mode build upgrades, read
443
+ the `log` field instead — `run_download_async` appends to it
444
+ continuously (download start, restore start, VM CLI output, success).
445
+
446
+ > **Known bug (worth fixing as a separate task):** `run_download_async`
447
+ > in `backend/adc/views/sharedlab/nacviews.py` should set
448
+ > `last_task_status = "PROGRESS"` immediately after `get_or_create`
449
+ > at the start of the worker, and `"FAILURE"` in its `except` branch
450
+ > (it currently only writes `"SUCCESS"` on the happy path). With that
451
+ > fix, `check-upgrade-status/` would return an accurate `is_upgrading`
452
+ > flag for testbed-mode build upgrades and surface failures without
453
+ > requiring the caller to parse the log field.
454
+
455
+ ##### Polling caveat — testbed-mode `GA`
456
+
457
+ `upgrade_type=GA` in `nac_upgrade_testbed` is a stub — it returns
458
+ `{"upgrade_type": "GA"}` immediately and never touches `SingleDevice`.
459
+ This endpoint will report `"Device not found"` (404) for IPs that have
460
+ never been upgraded by another mode. There is no work to poll.
461
+
462
+ ##### Polling caveat — testbed-mode `file` and `command`
463
+
464
+ These branches run synchronously inside the HTTP request and only
465
+ write to `SingleDevice` *after* the work finishes. The request itself
466
+ blocks until then (file restore can take minutes), so polling
467
+ `check-upgrade-status/` for in-flight progress isn't useful for these
468
+ types — by the time you can issue a separate poll, the upgrade is
469
+ already done.
470
+
471
+ Backed by: `nacupgradeview.check_upgrade_status`
472
+
473
+ ### Testbed mode
474
+
475
+ #### `POST /adc/nac-upgrade-testbed/`
476
+
477
+ Upgrade every NAC/NCM device in a testbed at once. Accepts
478
+ `multipart/form-data` to carry an optional uploaded image.
479
+
480
+ The handler reads `deployinfo` (JSON), finds every key matching
481
+ `dev<N>` whose value contains `nac` or `ncm`, and pairs it with the
482
+ corresponding `ip<N>` to build the upgrade list — then runs the
483
+ chosen `upgrade_type` against each in parallel.
484
+
485
+ Form fields:
486
+ - `deployinfo` — JSON string with the testbed's device map. Shape:
487
+ `{"dev1":"fortinac01","ip1":"10.15.40.42","dev2":"ncm01","ip2":"10.15.40.43", ...}`
488
+ (same as `AutomationTBSerializer.deployinfo`)
489
+ - `upgrade_type` — `GA` / `build` / `file` / `command`
490
+ - `build_number` — when `upgrade_type=build`
491
+ - `file_uploaded` — when `upgrade_type=file`
492
+ - `command` — when `upgrade_type=command`
493
+
494
+ Response:
495
+ ```json
496
+ {"upgrade_type": "build"}
497
+ ```
498
+
499
+ To monitor progress, poll `check-upgrade-status/` per-IP for each NAC
500
+ in the testbed.
501
+
502
+ Backed by: `nacviews.nac_upgrade_testbed`
503
+
504
+ ### Diagnostic
505
+
506
+ #### `POST /adc/test_thread_db/`
507
+
508
+ Diagnostic helper that exercises threaded DB writes against the
509
+ `SingleDevice` model — used to verify upgrade-worker threading on a
510
+ host. Not used by the UI; do not call in production.
511
+
512
+ Backed by: `nacupgradeview.test_thread_db`
513
+
514
+ ---
515
+
516
+ ## Other related APIs the `/automation` workflow touches
517
+
518
+ These belong to other pages (Shared Lab, Automation Testbed) but are
519
+ part of the same domain. Listed here so the dev team can find them
520
+ without spelunking.
521
+
522
+ ### Automation testbed management — `adc.views.automation.automation_tbview`
523
+
524
+ | Endpoint | Method | Purpose |
525
+ |---|---|---|
526
+ | `/adc/create_automation_testbed/` | POST | Create a new automation testbed entry |
527
+ | `/adc/automationtb-list/` | POST | List testbeds by `category` + `keyword` filter |
528
+ | `/adc/automationtb-moduletitle/` | GET | Distinct module titles across testbeds (dropdown source) |
529
+ | `/adc/automationtb-update/` | POST | Update a testbed's metadata, `deployinfo`, or `new_name` |
530
+
531
+ ### Performance lab (parallel of automation, used by `/performance`)
532
+
533
+ Mirror endpoints exist for performance-test workflows; they share the
534
+ same shape as the automation ones above. Listed for completeness in
535
+ case the dev team needs to script performance runs the same way.
536
+
537
+ | Endpoint | Method | Mirrors |
538
+ |---|---|---|
539
+ | `/adc/get_perf_testcases/` | GET | `get_testcases` |
540
+ | `/adc/get_perf_testcases_module/` | GET | (module-level filter) |
541
+ | `/adc/perf_robot_run/` | POST | `pytest_run` (Robot Framework instead of pytest) |
542
+ | `/adc/get_perf_lab/` | POST | `get_automation_lab` |
543
+ | `/adc/get_perf_test_result/` | POST | `get_test_result` |
544
+ | `/adc/get_perf_test_execution_by_id/` | POST | `get_test_execution_by_id` |
545
+ | `/adc/performance_lock/` | POST | `automation_lock` |
546
+ | `/adc/perf_exec_kill/` | POST | `test_exec_kill` |
547
+ | `/adc/perf_exec_delete/` | POST | `test_exec_delete` |
548
+
549
+ ---
550
+
551
+ ## Quick smoke test from the command line
552
+
553
+ ```bash
554
+ T=$(curl -s -X POST http://10.15.33.11:8000/token-auth/ \
555
+ -H 'Content-Type: application/json' \
556
+ -d '{"username":"admin","password":"<your-pw>"}' | jq -r .token)
557
+
558
+ # 1. Pull versions
559
+ curl -s -H "Authorization: JWT $T" \
560
+ http://10.15.33.11:8000/adc/automation-verion/ | jq .
561
+
562
+ # 2. List your testbeds
563
+ curl -s -X POST -H "Authorization: JWT $T" \
564
+ -H 'Content-Type: application/json' \
565
+ -d '{"user":"admin"}' \
566
+ http://10.15.33.11:8000/adc/get_automation_lab | jq '.[] | {name, status}'
567
+
568
+ # 3. Fire a pytest run
569
+ EXEC_ID=$(curl -s -X POST -H "Authorization: JWT $T" \
570
+ -H 'Content-Type: application/json' \
571
+ -d '{
572
+ "user":"admin",
573
+ "lab":"L2Mode_7",
574
+ "testcase":["tests/L2/test_l2_radius.py::test_basic_auth"],
575
+ "argument":"-vv"
576
+ }' \
577
+ http://10.15.33.11:8000/adc/pytest_run | jq -r .exec_id)
578
+ echo "Started exec $EXEC_ID"
579
+
580
+ # 4. Poll progress
581
+ curl -s -X POST -H "Authorization: JWT $T" \
582
+ -H 'Content-Type: application/json' \
583
+ -d "{\"id\":$EXEC_ID}" \
584
+ http://10.15.33.11:8000/adc/get_test_execution_by_id | jq .status,.progress
585
+
586
+ # 5. Upgrade a NAC device to a specific build (out-of-band)
587
+ curl -s -X POST -H "Authorization: JWT $T" \
588
+ -F "ip=10.15.40.42" \
589
+ -F "upgrade_type=build" \
590
+ -F "build_number=7.6.6.0123" \
591
+ http://10.15.33.11:8000/adc/nac-upgrade/
592
+
593
+ # 6. Poll upgrade status
594
+ curl -s -X POST -H "Authorization: JWT $T" \
595
+ -H 'Content-Type: application/json' \
596
+ -d '{"ip":"10.15.40.42"}' \
597
+ http://10.15.33.11:8000/adc/check-upgrade-status/ | jq .
598
+ ```
599
+
600
+ ---
601
+
602
+ ## Source files
603
+
604
+ | Concern | File |
605
+ |---|---|
606
+ | URL routes | `backend/adc/urls.py` |
607
+ | Run / lab / lock | `backend/adc/views/automation/automationview.py` |
608
+ | Testbed management | `backend/adc/views/automation/automation_tbview.py` |
609
+ | FortiNAC upgrade (single device + testbed) | `backend/adc/views/sharedlab/nacviews.py` |
610
+ | Upgrade with snapshot / status polling | `backend/adc/views/sharedlab/nacupgradeview.py` |
611
+ | Status widget | `backend/adc/views/sharedlab/tbviews.py` |
612
+ | Version dropdown | `backend/adc/views/dashboard/dashboardviews.py` |
613
+ | Frontend page | `frontend/src/pages/Automation/Automation.jsx` |
614
+
615
+ When adding a new endpoint, follow the pattern: route in
616
+ `adc/urls.py` → handler in the appropriate view module → serializer
617
+ if returning a model → consumer in `Automation.jsx`.
@@ -48,6 +48,11 @@ import { randomUUID, createHash } from 'node:crypto';
48
48
  const PORT = Number(process.env.BRIDGE_PORT) || 8407;
49
49
  const FORGE_PORT = Number(process.env.PORT) || 8403;
50
50
  const RPC_TIMEOUT_MS = 60_000;
51
+ // Ceiling for per-call overrides. Kept just under undici's default 300s
52
+ // headersTimeout on the loopback fetch in bridge-client.ts — past that the
53
+ // client fetch dies first with an opaque error. Genuinely long backend work
54
+ // (multi-minute NAC upgrades) should fire-and-poll, not hold the RPC open.
55
+ const RPC_TIMEOUT_MAX_MS = 280_000;
51
56
  const TOKEN_CACHE_TTL_MS = 60_000;
52
57
 
53
58
  // ─── Forge-token validation (with short-lived cache) ──────
@@ -112,17 +117,21 @@ interface PendingRpc {
112
117
 
113
118
  const pendingRpcs = new Map<string, PendingRpc>(); // rpc_id → callbacks
114
119
 
115
- function callExtension(method: string, params: unknown): Promise<unknown> {
120
+ function callExtension(method: string, params: unknown, timeoutMs?: number): Promise<unknown> {
116
121
  const client = pickAnyClient();
117
122
  if (!client) {
118
123
  return Promise.reject(new Error('No extension connected to the bridge.'));
119
124
  }
120
125
  const id = randomUUID();
126
+ const effectiveTimeout = Math.min(
127
+ Math.max(1000, Number(timeoutMs) || RPC_TIMEOUT_MS),
128
+ RPC_TIMEOUT_MAX_MS,
129
+ );
121
130
  return new Promise<unknown>((resolve, reject) => {
122
131
  const timer = setTimeout(() => {
123
132
  pendingRpcs.delete(id);
124
- reject(new Error(`RPC ${method} timed out after ${RPC_TIMEOUT_MS / 1000}s`));
125
- }, RPC_TIMEOUT_MS);
133
+ reject(new Error(`RPC ${method} timed out after ${effectiveTimeout / 1000}s`));
134
+ }, effectiveTimeout);
126
135
  pendingRpcs.set(id, { resolve, reject, timer });
127
136
  client.ws.send(JSON.stringify({ type: 'rpc_request', id, method, params }));
128
137
  });
@@ -248,7 +257,7 @@ async function handleHttp(req: IncomingMessage, res: ServerResponse): Promise<vo
248
257
  if (req.method === 'POST' && url.pathname === '/api/rpc') {
249
258
  try {
250
259
  const body = JSON.parse(await readBody(req));
251
- const value = await callExtension(body.method, body.params);
260
+ const value = await callExtension(body.method, body.params, body.timeout_ms);
252
261
  return sendJson(res, 200, { ok: true, value });
253
262
  } catch (e) {
254
263
  return sendJson(res, 200, { ok: false, error: (e as Error).message });
@@ -308,9 +308,9 @@ function buildConnectorTools(): LlmTool[] {
308
308
  for (const entry of getConnectorEntries(def)) {
309
309
  for (const [toolName, tool] of Object.entries(entry.tools || {})) {
310
310
  // Executable if it has a script (browser protocol) OR a non-browser
311
- // protocol that runs server-side (http / shell).
311
+ // protocol that runs server-side (http / shell / ssh).
312
312
  const protocol = (tool as any).protocol;
313
- const isServerSide = protocol === 'http' || protocol === 'shell';
313
+ const isServerSide = protocol === 'http' || protocol === 'shell' || protocol === 'ssh';
314
314
  if (!tool.script && !isServerSide) continue;
315
315
  const properties: Record<string, unknown> = {};
316
316
  const required: string[] = [];
@@ -13,13 +13,13 @@ const BRIDGE_PORT = Number(process.env.BRIDGE_PORT) || 8407;
13
13
  interface BridgeRpcOk { ok: true; value: unknown }
14
14
  interface BridgeRpcErr { ok: false; error: string }
15
15
 
16
- export async function bridgeRpc(method: string, params: unknown): Promise<unknown> {
16
+ export async function bridgeRpc(method: string, params: unknown, timeoutMs?: number): Promise<unknown> {
17
17
  let res: Response;
18
18
  try {
19
19
  res = await fetch(`http://127.0.0.1:${BRIDGE_PORT}/api/rpc`, {
20
20
  method: 'POST',
21
21
  headers: { 'content-type': 'application/json' },
22
- body: JSON.stringify({ method, params }),
22
+ body: JSON.stringify({ method, params, ...(timeoutMs ? { timeout_ms: timeoutMs } : {}) }),
23
23
  });
24
24
  } catch (e) {
25
25
  throw new Error(`browser bridge unreachable on port ${BRIDGE_PORT}: ${(e as Error).message}`);
@@ -0,0 +1,206 @@
1
+ /**
2
+ * SSH protocol runtime for connector tools (`protocol: ssh`).
3
+ *
4
+ * Drives the system `ssh` binary through a PTY (node-pty) so it can
5
+ * handle interactive flows the plain `shell` protocol can't: password
6
+ * auth and mid-command confirmations like `(y/N)`. Built for network
7
+ * devices — e.g. FortiNAC `execute restore image scp …` which prompts
8
+ * twice for `y` then streams a multi-minute restore before rebooting.
9
+ *
10
+ * Declarative, expect-style: the manifest's `ssh` block says what to
11
+ * send, what to auto-answer, the success/failure markers, and which
12
+ * regexes to capture from the transcript. Nothing here is FortiNAC-
13
+ * specific.
14
+ *
15
+ * Safety: connectors are user-installed. An ssh-protocol tool can run
16
+ * arbitrary remote commands — review at install time. The password is
17
+ * fed silently (ssh doesn't echo it) so it never lands in the captured
18
+ * transcript; we also never log it.
19
+ */
20
+
21
+ import type { ConnectorTool, SshSpec } from '../../connectors/types';
22
+ import { expandAllTokens } from '../../plugins/templates';
23
+ import * as pty from 'node-pty';
24
+
25
+ export interface SshProtocolArgs {
26
+ tool: ConnectorTool;
27
+ settings: Record<string, any>;
28
+ args: Record<string, any>;
29
+ }
30
+
31
+ export interface SshProtocolResult {
32
+ content: string;
33
+ is_error?: boolean;
34
+ }
35
+
36
+ const DEFAULT_TIMEOUT_MS = 120_000;
37
+ const MAX_TIMEOUT_MS = 280_000;
38
+ const MAX_OUTPUT_BYTES = 24 * 1024;
39
+
40
+ function truncate(s: string): string {
41
+ const buf = Buffer.from(s, 'utf-8');
42
+ if (buf.byteLength <= MAX_OUTPUT_BYTES) return s;
43
+ // Keep the tail — the interesting markers (done/reboot) are at the end.
44
+ return `(…truncated, total ${buf.byteLength} bytes)\n` +
45
+ buf.subarray(buf.byteLength - MAX_OUTPUT_BYTES).toString('utf-8');
46
+ }
47
+
48
+ function rx(pattern: string | undefined): RegExp | null {
49
+ if (!pattern) return null;
50
+ try { return new RegExp(pattern, 'i'); } catch { return null; }
51
+ }
52
+
53
+ /**
54
+ * Resolve what to actually type for an auto-answer. If the rule's value is
55
+ * the intent `yes`/`no`, pick the token the prompt itself offers — `(yes/no)`
56
+ * → `yes`/`no`, otherwise `y`/`n`. We always send an EXPLICIT token (never
57
+ * rely on the prompt's default: `(y/N)` defaults to N, so "continue" must
58
+ * send `y` outright). Any other value is sent literally.
59
+ */
60
+ function resolveAnswer(send: string, promptChunk: string): string {
61
+ const intent = String(send || '').trim().toLowerCase();
62
+ if (intent !== 'yes' && intent !== 'no') return send; // literal passthrough
63
+ const offersWords = /\byes\s*\/\s*no\b/i.test(promptChunk);
64
+ if (intent === 'yes') return offersWords ? 'yes' : 'y';
65
+ return offersWords ? 'no' : 'n';
66
+ }
67
+
68
+ export async function runSsh({ tool, settings, args }: SshProtocolArgs): Promise<SshProtocolResult> {
69
+ const specRaw = tool.ssh;
70
+ if (!specRaw) return { content: 'ssh tool missing `ssh` block', is_error: true };
71
+
72
+ const exp = (s: string | undefined) => (s == null ? '' : expandAllTokens(String(s), settings, args));
73
+
74
+ const spec: SshSpec = specRaw;
75
+
76
+ // Resolve connection params: chat arg > connector setting > literal in
77
+ // the ssh block > built-in default. (IP comes from chat; port/user/
78
+ // password fall back to the connector's saved defaults.)
79
+ const pickConn = (
80
+ argKeys: string[], settingKey: string, specVal: unknown, dflt: string, secret: boolean,
81
+ ): string => {
82
+ for (const k of argKeys) {
83
+ const v = args?.[k];
84
+ if (v != null && String(v) !== '') return secret ? String(v) : String(v).trim();
85
+ }
86
+ const sv = settings?.[settingKey];
87
+ if (sv != null && String(sv) !== '') return secret ? String(sv) : String(sv).trim();
88
+ if (specVal != null && specVal !== '') {
89
+ const r = exp(String(specVal));
90
+ if (r && !r.includes('{')) return secret ? r : r.trim(); // skip unresolved templates
91
+ }
92
+ return dflt;
93
+ };
94
+ const host = pickConn(['host'], 'host', spec.host, '', false);
95
+ const port = pickConn(['port'], 'port', spec.port, '22', false);
96
+ const user = pickConn(['username', 'user'], 'username', spec.user, '', false);
97
+ const password = pickConn(['password'], 'password', spec.password, '', true);
98
+ if (!host) return { content: 'ssh: host is required (pass it from chat, e.g. host=10.15.52.152)', is_error: true };
99
+ if (!user) return { content: 'ssh: user is required (pass username, or set a connector default)', is_error: true };
100
+
101
+ const commands = (spec.commands || []).map((c) => exp(c));
102
+ const autoAnswer = (spec.auto_answer || []).map((r) => ({ re: rx(r.match), send: exp(r.send) }));
103
+ const promptRe = rx(spec.prompt_regex) || /[#$>]\s*$/;
104
+ const doneRe = rx(spec.done_when);
105
+ const failRe = rx(spec.fail_when);
106
+ const passwordRe = /password:\s*$/i;
107
+ const timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(2_000, Number(spec.timeout_sec || 0) * 1000 || DEFAULT_TIMEOUT_MS));
108
+
109
+ const sshArgs = [
110
+ '-tt', // force PTY for interactive prompts
111
+ '-p', port,
112
+ '-o', 'StrictHostKeyChecking=accept-new', // no yes/no host-key prompt
113
+ '-o', 'UserKnownHostsFile=/dev/null', // don't pollute known_hosts
114
+ '-o', 'GlobalKnownHostsFile=/dev/null',
115
+ '-o', 'ConnectTimeout=15',
116
+ '-o', 'NumberOfPasswordPrompts=2',
117
+ `${user}@${host}`,
118
+ ];
119
+
120
+ return new Promise<SshProtocolResult>((resolve) => {
121
+ let term: pty.IPty;
122
+ try {
123
+ term = pty.spawn('ssh', sshArgs, {
124
+ name: 'xterm-color',
125
+ cols: 200, rows: 50,
126
+ cwd: process.env.HOME || process.cwd(),
127
+ env: process.env as Record<string, string>,
128
+ });
129
+ } catch (e) {
130
+ return resolve({ content: `ssh spawn failed: ${(e as Error).message}`, is_error: true });
131
+ }
132
+
133
+ let full = '';
134
+ let cmdIndex = 0;
135
+ let pwSent = 0;
136
+ let settled = false;
137
+ const captured: Record<string, string> = {};
138
+
139
+ const finish = (is_error: boolean, note: string) => {
140
+ if (settled) return;
141
+ settled = true;
142
+ clearTimeout(timer);
143
+ try { term.kill(); } catch {}
144
+ // Run captures over the full transcript.
145
+ if (spec.capture) {
146
+ for (const [name, pat] of Object.entries(spec.capture)) {
147
+ const m = full.match(rx(pat) || /$^/);
148
+ if (m && m[1] != null) captured[name] = m[1];
149
+ }
150
+ }
151
+ const payload = {
152
+ ok: !is_error,
153
+ note,
154
+ ...(Object.keys(captured).length ? { captured } : {}),
155
+ output_tail: truncate(full).slice(-4000),
156
+ };
157
+ resolve({ content: JSON.stringify(payload), is_error });
158
+ };
159
+
160
+ const timer = setTimeout(() => finish(true, `timed out after ${timeoutMs / 1000}s`), timeoutMs);
161
+
162
+ term.onData((chunk: string) => {
163
+ full += chunk;
164
+ // 1) success / failure markers (check on a trailing window so a
165
+ // marker split across chunks still matches).
166
+ const tail = full.slice(-2000);
167
+ if (doneRe && doneRe.test(tail)) return finish(false, 'done marker matched');
168
+ if (failRe && failRe.test(tail)) return finish(true, 'failure marker matched');
169
+
170
+ // 2) password prompt → feed password silently.
171
+ if (password && passwordRe.test(chunk)) {
172
+ if (pwSent >= 2) return finish(true, 'authentication failed (password rejected)');
173
+ pwSent++;
174
+ term.write(`${password}\r`);
175
+ return;
176
+ }
177
+
178
+ // 3) interactive confirmations — resolve the correct token (y/yes/
179
+ // n/no) from THIS prompt's offered options (intent `yes`/`no`).
180
+ for (const rule of autoAnswer) {
181
+ if (rule.re && rule.re.test(chunk)) {
182
+ term.write(`${resolveAnswer(rule.send, chunk)}\r`);
183
+ return;
184
+ }
185
+ }
186
+
187
+ // 4) shell prompt → send the next queued command.
188
+ if (promptRe.test(chunk) && cmdIndex < commands.length) {
189
+ const next = commands[cmdIndex++];
190
+ term.write(`${next}\r`);
191
+ return;
192
+ }
193
+ });
194
+
195
+ term.onExit(({ exitCode }) => {
196
+ if (settled) return;
197
+ // Connection closed. Success only if explicitly allowed, or a done
198
+ // marker already landed (covered above). Otherwise treat as error.
199
+ if (spec.success_on_close && cmdIndex >= commands.length) {
200
+ return finish(false, `connection closed (exit ${exitCode})`);
201
+ }
202
+ const sawDone = doneRe ? doneRe.test(full) : false;
203
+ finish(!sawDone, sawDone ? 'done before close' : `connection closed unexpectedly (exit ${exitCode})`);
204
+ });
205
+ });
206
+ }
@@ -12,6 +12,7 @@
12
12
  import { bridgeRpc } from './bridge-client';
13
13
  import { runHttp } from './protocols/http';
14
14
  import { runShell } from './protocols/shell';
15
+ import { runSsh } from './protocols/ssh';
15
16
  import {
16
17
  getConnector,
17
18
  getInstalledConnector,
@@ -522,6 +523,18 @@ export async function dispatchTool(
522
523
  const protocol = located.tool.protocol || 'browser';
523
524
  const argInput = (call.input ?? {}) as Record<string, any>;
524
525
 
526
+ // Apply each parameter's `default` for keys the model omitted, so
527
+ // template tokens like {args.scp_host} resolve instead of staying
528
+ // literal. JSON-schema defaults are only advisory to the model — it
529
+ // routinely drops optional fields — so fill them here. Only sets
530
+ // missing/null; never overrides a value the model actually passed.
531
+ for (const [pname, pdef] of Object.entries(located.tool.parameters || {})) {
532
+ if (pdef && typeof pdef === 'object' && 'default' in (pdef as any)
533
+ && (argInput[pname] === undefined || argInput[pname] === null)) {
534
+ argInput[pname] = (pdef as any).default;
535
+ }
536
+ }
537
+
525
538
  // Multi-instance overlay: when a connector's settings carry a
526
539
  // `instances` array of `{name, ...}` objects, the tool's `instance`
527
540
  // arg picks one and its fields are merged into the top-level settings
@@ -559,6 +572,8 @@ export async function dispatchTool(
559
572
  return await runHttp({ tool: located.tool, settings: effectiveSettings, args: argInput, connectorAuth: def.auth, noTruncation: opts.noTruncation });
560
573
  case 'shell':
561
574
  return await runShell({ tool: located.tool, settings: effectiveSettings, args: argInput });
575
+ case 'ssh':
576
+ return await runSsh({ tool: located.tool, settings: effectiveSettings, args: argInput });
562
577
  case 'browser': {
563
578
  // Hand the whole connector + tool spec + input + settings to the
564
579
  // extension's runner.ts via the bridge. The extension keeps owning
@@ -570,7 +585,7 @@ export async function dispatchTool(
570
585
  input: argInput,
571
586
  connector,
572
587
  settings: effectiveSettings,
573
- })) as { content?: string; is_error?: boolean } | null;
588
+ }, located.tool.timeout_ms)) as { content?: string; is_error?: boolean } | null;
574
589
  return { content: result?.content ?? '(no content returned)', is_error: !!result?.is_error };
575
590
  }
576
591
  default:
@@ -14,7 +14,56 @@
14
14
  export type ConnectorRunner = 'main' | 'isolated';
15
15
 
16
16
  /** Where a tool's execution lives. */
17
- export type ConnectorProtocol = 'browser' | 'http' | 'shell';
17
+ export type ConnectorProtocol = 'browser' | 'http' | 'shell' | 'ssh';
18
+
19
+ /** One expect rule for `protocol: ssh`: when output matches `match`
20
+ * (a regex, tested per output chunk), send `send` + Enter. Used to
21
+ * auto-answer interactive prompts like `(y/N)`.
22
+ *
23
+ * `send` may be the INTENT `yes`/`no` — the runner then picks the token
24
+ * the prompt actually offers (`(y/N)` → `y`/`n`, `(yes/no)` → `yes`/`no`)
25
+ * and always sends it explicitly (never relies on the prompt's default).
26
+ * Any other value is sent literally. */
27
+ export interface SshExpectRule {
28
+ match: string;
29
+ send: string;
30
+ }
31
+
32
+ /**
33
+ * `protocol: ssh` spec — drives an interactive SSH session via a PTY
34
+ * (the system `ssh` binary). Built for devices whose CLI needs a
35
+ * password + interactive confirmations (e.g. FortiNAC firmware restore).
36
+ * All string fields are templated with {settings.*}/{args.*}.
37
+ */
38
+ export interface SshSpec {
39
+ // Connection params are resolved by the runner with this precedence:
40
+ // tool arg (host/port/username/password) > connector setting
41
+ // (host/port/username/password) > the literal here > built-in default.
42
+ // So chat can pass them per-call and the connector holds defaults; the
43
+ // IP typically comes from chat only (no setting). All optional here.
44
+ host?: string;
45
+ /** Default 22. */
46
+ port?: string | number;
47
+ user?: string;
48
+ /** Password fed when a `password:` prompt appears (sent silently). */
49
+ password?: string;
50
+ /** Commands sent one-per-shell-prompt, in order (e.g. the upgrade cmd, then exit). */
51
+ commands?: string[];
52
+ /** Auto-answers applied throughout the session (e.g. `(y/N)` → `y`). */
53
+ auto_answer?: SshExpectRule[];
54
+ /** Shell-prompt regex that gates sending the next command. Default `[#$>]\s*$`. */
55
+ prompt_regex?: string;
56
+ /** Success marker regex — when seen, the session resolves ok and ssh is closed. */
57
+ done_when?: string;
58
+ /** Failure marker regex — when seen, resolves is_error. */
59
+ fail_when?: string;
60
+ /** name → regex(1 capture group) pulled from the full transcript into the result. */
61
+ capture?: Record<string, string>;
62
+ /** Overall timeout. Default 120s, max 280s. */
63
+ timeout_sec?: number;
64
+ /** Treat the remote closing the connection as success (e.g. after `exit`). */
65
+ success_on_close?: boolean;
66
+ }
18
67
 
19
68
  /** Schema for one settings or parameter field. */
20
69
  export interface ConnectorFieldSchema {
@@ -170,7 +219,18 @@ export interface ConnectorTool {
170
219
  /** Extra env vars (values templated). */
171
220
  env?: Record<string, string>;
172
221
 
173
- /** shell/http: timeout in milliseconds. Default 30000, max 300000. */
222
+ // ── protocol: 'ssh' ───────────────────────────────────────
223
+ /** Interactive SSH session spec (PTY-driven). See SshSpec. */
224
+ ssh?: SshSpec;
225
+
226
+ /**
227
+ * Timeout in milliseconds.
228
+ * - shell/http: request timeout (default 30000, max 300000).
229
+ * - browser: how long the bridge waits for the extension to return the
230
+ * RPC result (default 60000, capped at 900000). Raise it for tools
231
+ * whose script issues a long synchronous backend call (e.g. a NAC
232
+ * upgrade that blocks for minutes).
233
+ */
174
234
  timeout_ms?: number;
175
235
 
176
236
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.20",
3
+ "version": "0.10.22",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {