@aion0/forge 0.10.18 → 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 +4 -4
- package/app/api/agents/route.ts +4 -2
- package/app/api/connectors/route.ts +1 -1
- package/components/HelpTerminal.tsx +23 -6
- package/components/SettingsModal.tsx +3 -11
- package/docs/forge-long-task-watch-design.md +210 -0
- package/docs/tp-automation-api.md +617 -0
- package/lib/agents/index.ts +5 -4
- package/lib/agents/migrate.ts +8 -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 +59 -1
- package/lib/connectors/types.ts +62 -2
- package/lib/help-content.ts +51 -0
- package/lib/help-docs/01-settings.md +0 -1
- package/lib/settings.ts +1 -1
- package/lib/telegram-bot.ts +5 -5
- package/package.json +1 -1
|
@@ -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,
|
|
@@ -274,6 +275,30 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
274
275
|
});
|
|
275
276
|
return `Task dispatched: ${task.id} (project: ${project.name}, status: ${task.status}). Watch in the Tasks view.`;
|
|
276
277
|
},
|
|
278
|
+
|
|
279
|
+
// List Forge's own help/documentation files so the chat agent can answer
|
|
280
|
+
// questions about Forge itself (features, config, troubleshooting) without
|
|
281
|
+
// the user opening the separate Help AI. Pair with read_help_doc.
|
|
282
|
+
list_help_docs: async () => {
|
|
283
|
+
const { listHelpDocs } = await import('../help-content');
|
|
284
|
+
const docs = listHelpDocs();
|
|
285
|
+
if (docs.length === 0) return 'No Forge help docs found.';
|
|
286
|
+
return 'Forge help docs (read one with read_help_doc):\n' + docs.map((d) => `- ${d}`).join('\n');
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
// Read one Forge help doc in full. Use to answer questions about Forge's
|
|
290
|
+
// own features / settings / troubleshooting.
|
|
291
|
+
read_help_doc: async (input) => {
|
|
292
|
+
const { doc } = (input as { doc?: string } | undefined) || {};
|
|
293
|
+
if (!doc) return 'read_help_doc failed: doc is required (a filename from list_help_docs, e.g. "05-pipelines.md").';
|
|
294
|
+
const { readHelpDoc } = await import('../help-content');
|
|
295
|
+
const content = readHelpDoc(doc);
|
|
296
|
+
if (content == null) return `Help doc "${doc}" not found. Call list_help_docs to see available filenames.`;
|
|
297
|
+
const MAX = 30000;
|
|
298
|
+
return content.length > MAX
|
|
299
|
+
? content.slice(0, MAX) + `\n\n…[truncated — doc is ${content.length} chars]`
|
|
300
|
+
: content;
|
|
301
|
+
},
|
|
277
302
|
};
|
|
278
303
|
|
|
279
304
|
export interface BuiltinToolDef {
|
|
@@ -351,6 +376,25 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
351
376
|
required: ['prompt'],
|
|
352
377
|
},
|
|
353
378
|
},
|
|
379
|
+
{
|
|
380
|
+
name: 'list_help_docs',
|
|
381
|
+
description: "List Forge's own documentation files. Call this FIRST whenever the user asks how Forge itself works — its features, settings/config, setup, or troubleshooting (e.g. pipelines, schedules, connectors, telegram, tunnel, workspace/smiths, skills, crafts, usage/cost, agents/models). Returns doc filenames; then read the relevant one(s) with read_help_doc and answer from their content. No arguments.",
|
|
382
|
+
input_schema: { type: 'object', properties: {} },
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
name: 'read_help_doc',
|
|
386
|
+
description: 'Read one Forge help doc in full (filename from list_help_docs, e.g. "05-pipelines.md"). Use its content to answer the user\'s question about Forge. Read more than one doc if the question spans topics.',
|
|
387
|
+
input_schema: {
|
|
388
|
+
type: 'object',
|
|
389
|
+
properties: {
|
|
390
|
+
doc: {
|
|
391
|
+
type: 'string',
|
|
392
|
+
description: 'Help doc filename, e.g. "05-pipelines.md" or "10-troubleshooting.md". Get exact names from list_help_docs.',
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
required: ['doc'],
|
|
396
|
+
},
|
|
397
|
+
},
|
|
354
398
|
];
|
|
355
399
|
|
|
356
400
|
// ─── Connector dispatch ──────────────────────────────────
|
|
@@ -479,6 +523,18 @@ export async function dispatchTool(
|
|
|
479
523
|
const protocol = located.tool.protocol || 'browser';
|
|
480
524
|
const argInput = (call.input ?? {}) as Record<string, any>;
|
|
481
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
|
+
|
|
482
538
|
// Multi-instance overlay: when a connector's settings carry a
|
|
483
539
|
// `instances` array of `{name, ...}` objects, the tool's `instance`
|
|
484
540
|
// arg picks one and its fields are merged into the top-level settings
|
|
@@ -516,6 +572,8 @@ export async function dispatchTool(
|
|
|
516
572
|
return await runHttp({ tool: located.tool, settings: effectiveSettings, args: argInput, connectorAuth: def.auth, noTruncation: opts.noTruncation });
|
|
517
573
|
case 'shell':
|
|
518
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 });
|
|
519
577
|
case 'browser': {
|
|
520
578
|
// Hand the whole connector + tool spec + input + settings to the
|
|
521
579
|
// extension's runner.ts via the bridge. The extension keeps owning
|
|
@@ -527,7 +585,7 @@ export async function dispatchTool(
|
|
|
527
585
|
input: argInput,
|
|
528
586
|
connector,
|
|
529
587
|
settings: effectiveSettings,
|
|
530
|
-
})) as { content?: string; is_error?: boolean } | null;
|
|
588
|
+
}, located.tool.timeout_ms)) as { content?: string; is_error?: boolean } | null;
|
|
531
589
|
return { content: result?.content ?? '(no content returned)', is_error: !!result?.is_error };
|
|
532
590
|
}
|
|
533
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
|
/**
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Help-doc reader for the chat agent's read_help_doc / list_help_docs tools.
|
|
3
|
+
*
|
|
4
|
+
* Reads the markdown docs that ship under lib/help-docs/ (same set the Help AI
|
|
5
|
+
* terminal uses). Resolved relative to this module so it works regardless of
|
|
6
|
+
* whether the on-disk sync (ensureHelpDocs → <config>/help) has run yet, then
|
|
7
|
+
* falls back to the synced copy if the source tree isn't present (installed
|
|
8
|
+
* tarball layouts).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { getConfigDir } from './dirs';
|
|
15
|
+
|
|
16
|
+
// Candidate dirs, in priority order: source tree next to this module, then the
|
|
17
|
+
// synced runtime copy under ~/.forge/help.
|
|
18
|
+
function helpDirs(): string[] {
|
|
19
|
+
const dirs: string[] = [];
|
|
20
|
+
try {
|
|
21
|
+
const here = fileURLToPath(new URL('./help-docs', import.meta.url));
|
|
22
|
+
dirs.push(here);
|
|
23
|
+
} catch { /* import.meta.url unavailable (CJS bundle) — skip */ }
|
|
24
|
+
dirs.push(join(getConfigDir(), 'help'));
|
|
25
|
+
return dirs;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** List available help-doc filenames (e.g. ["00-overview.md", ...]), sorted. */
|
|
29
|
+
export function listHelpDocs(): string[] {
|
|
30
|
+
for (const dir of helpDirs()) {
|
|
31
|
+
try {
|
|
32
|
+
if (!existsSync(dir)) continue;
|
|
33
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
|
|
34
|
+
if (files.length) return files;
|
|
35
|
+
} catch { /* try next dir */ }
|
|
36
|
+
}
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read one help doc by filename. Returns null if not found. Path-traversal safe. */
|
|
41
|
+
export function readHelpDoc(name: string): string | null {
|
|
42
|
+
const base = (name || '').split(/[\\/]/).pop() || '';
|
|
43
|
+
if (!base.endsWith('.md')) return null;
|
|
44
|
+
for (const dir of helpDirs()) {
|
|
45
|
+
try {
|
|
46
|
+
const file = join(dir, base);
|
|
47
|
+
if (existsSync(file)) return readFileSync(file, 'utf-8');
|
|
48
|
+
} catch { /* try next dir */ }
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
@@ -46,7 +46,6 @@ Each agent entry in `settings.agents` supports:
|
|
|
46
46
|
| `enabled` | boolean | Whether this agent is available |
|
|
47
47
|
| `cliType` | string | CLI tool type: `claude-code`, `codex`, `aider`, `generic` |
|
|
48
48
|
| `taskFlags` | string | Flags for headless task execution (e.g. `-p --verbose`) |
|
|
49
|
-
| `interactiveCmd` | string | Command for interactive terminal sessions |
|
|
50
49
|
| `resumeFlag` | string | Flag to resume sessions (e.g. `-c` for claude) |
|
|
51
50
|
| `outputFormat` | string | Output format: `stream-json`, `text` |
|
|
52
51
|
| `skipPermissionsFlag` | string | Flag to skip permissions (e.g. `--dangerously-skip-permissions`) |
|
package/lib/settings.ts
CHANGED
|
@@ -23,7 +23,7 @@ const SETTINGS_FILE = join(DATA_DIR, 'settings.yaml');
|
|
|
23
23
|
export interface AgentEntry {
|
|
24
24
|
tool?: 'claude' | 'codex' | 'aider' | 'opencode'; // which CLI binary
|
|
25
25
|
path?: string; name?: string; enabled?: boolean;
|
|
26
|
-
flags?: string[]; taskFlags?: string;
|
|
26
|
+
flags?: string[]; taskFlags?: string; resumeFlag?: string; outputFormat?: string;
|
|
27
27
|
models?: { terminal?: string; task?: string; telegram?: string; help?: string; mobile?: string };
|
|
28
28
|
skipPermissionsFlag?: string;
|
|
29
29
|
requiresTTY?: boolean;
|
package/lib/telegram-bot.ts
CHANGED
|
@@ -1086,7 +1086,7 @@ function getSessionPreview(sessionName: string, maxLines: number = 1): string {
|
|
|
1086
1086
|
try {
|
|
1087
1087
|
const { execSync } = require('node:child_process');
|
|
1088
1088
|
// Capture last screen, strip ANSI escapes, find last non-empty lines
|
|
1089
|
-
const out = execSync(`tmux capture-pane -t "${sessionName}" -p -S -
|
|
1089
|
+
const out = execSync(`tmux capture-pane -t "${sessionName}" -p -S -100 2>/dev/null`, {
|
|
1090
1090
|
encoding: 'utf-8',
|
|
1091
1091
|
timeout: 2000,
|
|
1092
1092
|
}) as string;
|
|
@@ -1101,8 +1101,8 @@ function getSessionPreview(sessionName: string, maxLines: number = 1): string {
|
|
|
1101
1101
|
const last = tail[0];
|
|
1102
1102
|
return last.length > 30 ? last.slice(0, 30) + '…' : last;
|
|
1103
1103
|
}
|
|
1104
|
-
// Multi-line: truncate each line to
|
|
1105
|
-
return tail.map(l => l.length >
|
|
1104
|
+
// Multi-line: truncate each line to 160 chars
|
|
1105
|
+
return tail.map(l => l.length > 160 ? l.slice(0, 160) + '…' : l).join('\n');
|
|
1106
1106
|
} catch {
|
|
1107
1107
|
return '';
|
|
1108
1108
|
}
|
|
@@ -1185,8 +1185,8 @@ async function pickInjectTarget(chatId: number, numStr: string) {
|
|
|
1185
1185
|
scheduleInjectAutoClear(chatId);
|
|
1186
1186
|
const labelMap = getSessionLabels();
|
|
1187
1187
|
const display = labelMap[sessionName] || sessionName.replace(/^mw-?/, '');
|
|
1188
|
-
// Show last
|
|
1189
|
-
const context = getSessionPreview(sessionName,
|
|
1188
|
+
// Show last 16 lines of context so user knows what's in the terminal
|
|
1189
|
+
const context = getSessionPreview(sessionName, 16);
|
|
1190
1190
|
const contextBlock = context ? `\n\n📺 Last output:\n\`\`\`\n${context}\n\`\`\`` : '';
|
|
1191
1191
|
await send(chatId,
|
|
1192
1192
|
`🎯 Target: ${display}${contextBlock}\n\n` +
|
package/package.json
CHANGED