@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 +3 -3
- package/app/api/connectors/route.ts +1 -1
- package/docs/forge-long-task-watch-design.md +210 -0
- package/docs/tp-automation-api.md +617 -0
- package/lib/browser-bridge-standalone.ts +13 -4
- package/lib/chat/agent-loop.ts +2 -2
- package/lib/chat/bridge-client.ts +2 -2
- package/lib/chat/protocols/ssh.ts +206 -0
- package/lib/chat/tool-dispatcher.ts +16 -1
- package/lib/connectors/types.ts +62 -2
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.22
|
|
2
2
|
|
|
3
3
|
Released: 2026-05-31
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.20
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
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 ${
|
|
125
|
-
},
|
|
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 });
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -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:
|
package/lib/connectors/types.ts
CHANGED
|
@@ -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
|
-
|
|
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