@aion0/forge 0.10.44 → 0.10.46
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 +43 -5
- package/app/api/auth/gitlab-2fa/route.ts +98 -0
- package/app/api/auth/idp-credentials/route.ts +41 -0
- package/app/api/auth/idp-login/route.ts +22 -0
- package/app/api/enterprise-keys/route.ts +11 -1
- package/app/api/scratch/[...path]/route.ts +78 -0
- package/bin/forge-server.mjs +30 -3
- package/components/Dashboard.tsx +43 -50
- package/components/EnterpriseBadge.tsx +390 -1
- package/install.sh +6 -0
- package/lib/auth/gitlab-2fa.ts +443 -0
- package/lib/auth/idp-login.ts +221 -0
- package/lib/auth/terminal-keystroke.ts +245 -0
- package/lib/chat/link-patterns.ts +11 -0
- package/lib/chat/session-store.ts +5 -2
- package/lib/chat/tool-dispatcher.ts +90 -18
- package/lib/connectors/migration.ts +55 -0
- package/lib/connectors/registry.ts +20 -2
- package/lib/connectors/sync.ts +21 -1
- package/lib/crypto.ts +1 -1
- package/lib/enterprise.ts +5 -1
- package/lib/init.ts +9 -1
- package/lib/scratch-cleanup.ts +64 -0
- package/lib/settings.ts +20 -0
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic "type a command into the user's frontmost macOS Terminal,
|
|
3
|
+
* optionally feed an OTP at the prompt, scrape outcome" runner.
|
|
4
|
+
*
|
|
5
|
+
* The original use case is GitLab SSH 2FA refresh, but the mechanism
|
|
6
|
+
* is fully tenant-configurable via the wizard template's
|
|
7
|
+
* `_idp.post_login_terminal` array. Each entry is a TerminalSpec.
|
|
8
|
+
*
|
|
9
|
+
* Why this approach exists: corp VPNs (FortiClient) gate the tunnel
|
|
10
|
+
* by PID. Only the user's manually-launched Terminal session is
|
|
11
|
+
* whitelisted, so commands run by Forge-spawned processes don't
|
|
12
|
+
* reach internal hosts. Typing into the frontmost Terminal via
|
|
13
|
+
* AppleScript+System Events makes the command a direct child of
|
|
14
|
+
* that whitelisted PID.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
|
|
19
|
+
export interface TerminalSpec {
|
|
20
|
+
name: string;
|
|
21
|
+
command: string;
|
|
22
|
+
needs_otp?: boolean;
|
|
23
|
+
/** Regexes to detect the OTP prompt in fresh Terminal output. */
|
|
24
|
+
prompt_markers?: string[];
|
|
25
|
+
/** Validity window (informational only, surfaced to UI). */
|
|
26
|
+
valid_minutes?: number;
|
|
27
|
+
/** Regex (case-insensitive). If transcript matches, force verdict=fail
|
|
28
|
+
* even when exit code is 0. Needed when the command prints failure
|
|
29
|
+
* text to stdout without setting a non-zero exit code (e.g. GitLab
|
|
30
|
+
* `ssh ... 2fa_verify` on bad OTP). */
|
|
31
|
+
failure_match?: string;
|
|
32
|
+
/** Regex (case-insensitive). If specified and transcript does NOT match
|
|
33
|
+
* on exit 0, force verdict=fail. Stronger than failure_match — use
|
|
34
|
+
* when you know the exact success phrase. */
|
|
35
|
+
success_match?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TerminalResult {
|
|
39
|
+
name: string;
|
|
40
|
+
ok: boolean;
|
|
41
|
+
/** Resolved command — surfaced so UI can offer "run this manually" on failure. */
|
|
42
|
+
command: string;
|
|
43
|
+
error?: string;
|
|
44
|
+
transcript?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_PROMPT_MARKERS = ['OTP', 'Token', 'verification', 'code:'];
|
|
48
|
+
|
|
49
|
+
interface AppleScriptResult { stdout: string; stderr: string; code: number | null }
|
|
50
|
+
|
|
51
|
+
function runAppleScript(script: string, timeoutMs = 60_000): Promise<AppleScriptResult> {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const p = spawn('osascript', [], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
54
|
+
let stdout = '', stderr = '';
|
|
55
|
+
p.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
56
|
+
p.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
57
|
+
const timer = setTimeout(() => { try { p.kill(); } catch {} }, timeoutMs);
|
|
58
|
+
p.on('close', (code) => { clearTimeout(timer); resolve({ stdout, stderr, code }); });
|
|
59
|
+
p.stdin.write(script);
|
|
60
|
+
p.stdin.end();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Type `spec.command` into the frontmost Terminal window, optionally
|
|
66
|
+
* paste `otp` when the prompt appears, and look for OK/FAIL marker.
|
|
67
|
+
* Strings are pasted via clipboard (not keystroke char-by-char) to
|
|
68
|
+
* avoid macOS's well-known character-drop bug on long strings.
|
|
69
|
+
*/
|
|
70
|
+
export async function runTerminalKeystroke(
|
|
71
|
+
spec: TerminalSpec,
|
|
72
|
+
otp?: string,
|
|
73
|
+
): Promise<TerminalResult> {
|
|
74
|
+
if (process.platform !== 'darwin') {
|
|
75
|
+
return { name: spec.name, command: spec.command, ok: false, error: 'Keystroke mode is macOS-only.' };
|
|
76
|
+
}
|
|
77
|
+
if (spec.needs_otp) {
|
|
78
|
+
const cleaned = String(otp || '').replace(/\s+/g, '');
|
|
79
|
+
if (!/^\d{6,8}$/.test(cleaned)) {
|
|
80
|
+
return { name: spec.name, command: spec.command, ok: false, error: 'OTP must be 6–8 digits' };
|
|
81
|
+
}
|
|
82
|
+
otp = cleaned;
|
|
83
|
+
}
|
|
84
|
+
const tag = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
85
|
+
const markOk = `FORGE_TERM_OK_${tag}`;
|
|
86
|
+
const markFail = `FORGE_TERM_FAIL_${tag}`;
|
|
87
|
+
// Leading space → HIST_IGNORE_SPACE keeps it out of shell history.
|
|
88
|
+
// Two atomic markers avoid the partial-render race we hit earlier.
|
|
89
|
+
const cmd = ` ${spec.command} && echo "${markOk}" || echo "${markFail}"`;
|
|
90
|
+
|
|
91
|
+
const esc = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
92
|
+
const markers = (spec.prompt_markers && spec.prompt_markers.length
|
|
93
|
+
? spec.prompt_markers
|
|
94
|
+
: DEFAULT_PROMPT_MARKERS
|
|
95
|
+
).map(esc);
|
|
96
|
+
const promptCheck = markers.map((m) => `fresh contains "${m}"`).join(' or ');
|
|
97
|
+
|
|
98
|
+
const lines: string[] = [
|
|
99
|
+
'set savedClip to ""',
|
|
100
|
+
'try',
|
|
101
|
+
' set savedClip to (the clipboard as text)',
|
|
102
|
+
'end try',
|
|
103
|
+
'tell application "Terminal" to activate',
|
|
104
|
+
'delay 0.4',
|
|
105
|
+
'tell application "Terminal"',
|
|
106
|
+
' set baseline to (contents of front window as text)',
|
|
107
|
+
'end tell',
|
|
108
|
+
'set baseLen to count of baseline',
|
|
109
|
+
`set the clipboard to "${esc(cmd)}"`,
|
|
110
|
+
'delay 0.1',
|
|
111
|
+
'tell application "System Events"',
|
|
112
|
+
' keystroke "v" using {command down}',
|
|
113
|
+
' delay 0.15',
|
|
114
|
+
' key code 36',
|
|
115
|
+
'end tell',
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
if (spec.needs_otp) {
|
|
119
|
+
lines.push(
|
|
120
|
+
'set promptDeadline to (current date) + 20',
|
|
121
|
+
'set sawPrompt to false',
|
|
122
|
+
'repeat',
|
|
123
|
+
' if (current date) > promptDeadline then exit repeat',
|
|
124
|
+
' delay 0.4',
|
|
125
|
+
' try',
|
|
126
|
+
' tell application "Terminal"',
|
|
127
|
+
' set txt to contents of front window as text',
|
|
128
|
+
' end tell',
|
|
129
|
+
' set fresh to txt',
|
|
130
|
+
' if (count of txt) > baseLen then',
|
|
131
|
+
' try',
|
|
132
|
+
' set fresh to text (baseLen + 1) thru -1 of txt',
|
|
133
|
+
' end try',
|
|
134
|
+
' end if',
|
|
135
|
+
` if ${promptCheck} then`,
|
|
136
|
+
' set sawPrompt to true',
|
|
137
|
+
' exit repeat',
|
|
138
|
+
' end if',
|
|
139
|
+
' end try',
|
|
140
|
+
'end repeat',
|
|
141
|
+
'if not sawPrompt then',
|
|
142
|
+
' set the clipboard to savedClip',
|
|
143
|
+
' return "noprompt|no prompt detected after 20s"',
|
|
144
|
+
'end if',
|
|
145
|
+
'delay 0.3',
|
|
146
|
+
`set the clipboard to "${esc(otp!)}"`,
|
|
147
|
+
'delay 0.1',
|
|
148
|
+
'tell application "System Events"',
|
|
149
|
+
' keystroke "v" using {command down}',
|
|
150
|
+
' delay 0.15',
|
|
151
|
+
' key code 36',
|
|
152
|
+
'end tell',
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
lines.push(
|
|
157
|
+
'set t0 to current date',
|
|
158
|
+
'set verdict to "timeout"',
|
|
159
|
+
'set tail to ""',
|
|
160
|
+
'repeat',
|
|
161
|
+
' if ((current date) - t0) > 30 then exit repeat',
|
|
162
|
+
' delay 0.4',
|
|
163
|
+
' try',
|
|
164
|
+
' tell application "Terminal"',
|
|
165
|
+
' set txt to contents of front window as text',
|
|
166
|
+
' end tell',
|
|
167
|
+
` if txt contains "${markOk}" then`,
|
|
168
|
+
' set verdict to "ok"',
|
|
169
|
+
// Capture tail on OK branch too — caller may regex-check it to
|
|
170
|
+
// override exit-code-based verdict (e.g. GitLab 2fa_verify exits
|
|
171
|
+
// 0 but prints failure text on bad OTP).
|
|
172
|
+
' if (length of txt) > 400 then',
|
|
173
|
+
' set tail to text -400 thru -1 of txt',
|
|
174
|
+
' else',
|
|
175
|
+
' set tail to txt',
|
|
176
|
+
' end if',
|
|
177
|
+
' exit repeat',
|
|
178
|
+
` else if txt contains "${markFail}" then`,
|
|
179
|
+
' set verdict to "fail"',
|
|
180
|
+
' if (length of txt) > 400 then',
|
|
181
|
+
' set tail to text -400 thru -1 of txt',
|
|
182
|
+
' else',
|
|
183
|
+
' set tail to txt',
|
|
184
|
+
' end if',
|
|
185
|
+
' exit repeat',
|
|
186
|
+
' end if',
|
|
187
|
+
' on error errMsg',
|
|
188
|
+
' set the clipboard to savedClip',
|
|
189
|
+
' return "error:" & errMsg',
|
|
190
|
+
' end try',
|
|
191
|
+
'end repeat',
|
|
192
|
+
'set the clipboard to savedClip',
|
|
193
|
+
'return verdict & "|" & tail',
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const r = await runAppleScript(lines.join('\n'), 60_000);
|
|
197
|
+
const out = (r.stdout || '').trim();
|
|
198
|
+
if (r.code !== 0) {
|
|
199
|
+
const err = (r.stderr || '').trim();
|
|
200
|
+
if (/not authorized.*assistive|accessibility|tcc/i.test(err)) {
|
|
201
|
+
return {
|
|
202
|
+
name: spec.name,
|
|
203
|
+
command: spec.command,
|
|
204
|
+
ok: false,
|
|
205
|
+
error:
|
|
206
|
+
'Accessibility permission required. Open System Settings → ' +
|
|
207
|
+
'Privacy & Security → Accessibility, add node/Forge and enable it.',
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (/-1719|no window|application isn.t running/i.test(err)) {
|
|
211
|
+
return {
|
|
212
|
+
name: spec.name,
|
|
213
|
+
command: spec.command,
|
|
214
|
+
ok: false,
|
|
215
|
+
error: 'No Terminal window open. Open one and retry.',
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return { name: spec.name, command: spec.command, ok: false, error: `osascript failed (code ${r.code}): ${err || out}` };
|
|
219
|
+
}
|
|
220
|
+
const [verdict, ...rest] = out.split('|');
|
|
221
|
+
const tail = rest.join('|');
|
|
222
|
+
if (verdict === 'ok') {
|
|
223
|
+
// Transcript-based override: some commands report failure via stdout
|
|
224
|
+
// text instead of non-zero exit (e.g. GitLab 2fa_verify).
|
|
225
|
+
if (spec.failure_match) {
|
|
226
|
+
try {
|
|
227
|
+
if (new RegExp(spec.failure_match, 'i').test(tail)) {
|
|
228
|
+
return { name: spec.name, command: spec.command, ok: false, error: 'transcript matched failure pattern', transcript: tail };
|
|
229
|
+
}
|
|
230
|
+
} catch { /* invalid regex — skip */ }
|
|
231
|
+
}
|
|
232
|
+
if (spec.success_match) {
|
|
233
|
+
try {
|
|
234
|
+
if (!new RegExp(spec.success_match, 'i').test(tail)) {
|
|
235
|
+
return { name: spec.name, command: spec.command, ok: false, error: 'transcript did not match success pattern', transcript: tail };
|
|
236
|
+
}
|
|
237
|
+
} catch { /* invalid regex — skip */ }
|
|
238
|
+
}
|
|
239
|
+
return { name: spec.name, command: spec.command, ok: true, transcript: tail };
|
|
240
|
+
}
|
|
241
|
+
if (verdict === 'fail') return { name: spec.name, command: spec.command, ok: false, error: 'command exited non-zero', transcript: tail };
|
|
242
|
+
if (verdict === 'noprompt') return { name: spec.name, command: spec.command, ok: false, error: tail || 'No prompt appeared' };
|
|
243
|
+
if (verdict.startsWith('error:')) return { name: spec.name, command: spec.command, ok: false, error: verdict.slice(6) };
|
|
244
|
+
return { name: spec.name, command: spec.command, ok: false, error: 'timed out reading Terminal' };
|
|
245
|
+
}
|
|
@@ -81,6 +81,17 @@ export const LINK_PATTERNS: LinkPattern[] = [
|
|
|
81
81
|
url: 'https://nvd.nist.gov/vuln/detail/{1}',
|
|
82
82
|
label: '{1}',
|
|
83
83
|
},
|
|
84
|
+
// Forge scratch-dir files. LLMs frequently emit paths like
|
|
85
|
+
// `scratch/foo.md` when they write reports during chat-launched tasks.
|
|
86
|
+
// Turn them into clickable links served by /api/scratch/<path>.
|
|
87
|
+
// Match path segments + filename with extension; bound to a known set
|
|
88
|
+
// of extensions to avoid linkifying noise like `scratch/notes`.
|
|
89
|
+
{
|
|
90
|
+
id: 'scratch-file',
|
|
91
|
+
regex: /\bscratch\/([\w\-./]+?\.(?:md|txt|json|yaml|yml|csv|log|html|pdf|png|jpg|jpeg|gif|svg))\b/gi,
|
|
92
|
+
url: '/api/scratch/{1}',
|
|
93
|
+
label: 'scratch/{1}',
|
|
94
|
+
},
|
|
84
95
|
];
|
|
85
96
|
|
|
86
97
|
export interface CompiledPattern {
|
|
@@ -278,11 +278,14 @@ export function listMessages(session_id: string, opts?: { limit?: number; after_
|
|
|
278
278
|
ensureSchema();
|
|
279
279
|
const limit = opts?.limit ?? 500;
|
|
280
280
|
const after = opts?.after_ts ?? 0;
|
|
281
|
+
// Take the LAST N matching (DESC + LIMIT) then reverse to chronological
|
|
282
|
+
// order for the caller. ASC + LIMIT was returning the OLDEST N, so a
|
|
283
|
+
// session with >limit messages looked frozen at message #N's timestamp.
|
|
281
284
|
const rows = db().prepare(`
|
|
282
285
|
SELECT * FROM chat_messages WHERE session_id = ? AND ts > ?
|
|
283
|
-
ORDER BY ts
|
|
286
|
+
ORDER BY ts DESC LIMIT ?
|
|
284
287
|
`).all(session_id, after, limit) as MessageRow[];
|
|
285
|
-
return rows.map(rowToMessage);
|
|
288
|
+
return rows.map(rowToMessage).reverse();
|
|
286
289
|
}
|
|
287
290
|
|
|
288
291
|
/** Last N messages in chronological order — used by agent-loop to cap LLM context. */
|
|
@@ -305,6 +305,39 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
305
305
|
return out.join('\n');
|
|
306
306
|
},
|
|
307
307
|
|
|
308
|
+
// Save raw content to <dataDir>/scratch/<filename>. The chat UI auto-links
|
|
309
|
+
// any prose mention of `scratch/<filename>` (link-patterns.ts) so the user
|
|
310
|
+
// gets a download link in the response. Prefer this over dispatch_task for
|
|
311
|
+
// "save / create / export a file" asks — the LLM already has the content
|
|
312
|
+
// in context, no need to spawn a CLI task that may write elsewhere.
|
|
313
|
+
save_scratch_file: async (input) => {
|
|
314
|
+
const params = (input as { filename?: string; content?: string } | undefined) || {};
|
|
315
|
+
const filename = (params.filename || '').trim();
|
|
316
|
+
const content = params.content == null ? '' : String(params.content);
|
|
317
|
+
if (!filename) return JSON.stringify({ ok: false, error: 'filename is required' });
|
|
318
|
+
if (filename.includes('..') || filename.startsWith('/') || filename.includes('\0')) {
|
|
319
|
+
return JSON.stringify({ ok: false, error: 'filename must be a bare relative name (no .. / leading /)' });
|
|
320
|
+
}
|
|
321
|
+
const { getDataDir } = await import('../dirs');
|
|
322
|
+
const { join, resolve } = await import('node:path');
|
|
323
|
+
const { mkdir, writeFile } = await import('node:fs/promises');
|
|
324
|
+
const scratchRoot = join(getDataDir(), 'scratch');
|
|
325
|
+
const target = resolve(scratchRoot, filename);
|
|
326
|
+
if (!target.startsWith(scratchRoot + '/') && target !== scratchRoot) {
|
|
327
|
+
return JSON.stringify({ ok: false, error: 'resolved path escapes scratch dir' });
|
|
328
|
+
}
|
|
329
|
+
const { dirname } = await import('node:path');
|
|
330
|
+
await mkdir(dirname(target), { recursive: true });
|
|
331
|
+
await writeFile(target, content, 'utf8');
|
|
332
|
+
return JSON.stringify({
|
|
333
|
+
ok: true,
|
|
334
|
+
path: `scratch/${filename}`,
|
|
335
|
+
url: `/api/scratch/${filename}`,
|
|
336
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
337
|
+
hint: 'Tell the user the file is ready and reference it inline as `scratch/' + filename + '` — chat will render a download link automatically.',
|
|
338
|
+
});
|
|
339
|
+
},
|
|
340
|
+
|
|
308
341
|
// Dispatch a one-shot background task. Agent + skills optional; project is
|
|
309
342
|
// required (defaults to 'scratch' if not given). Returns the task id; the
|
|
310
343
|
// caller can ask "what's the status of task <id>?" later — we don't block.
|
|
@@ -617,6 +650,24 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
617
650
|
required: ['prompt'],
|
|
618
651
|
},
|
|
619
652
|
},
|
|
653
|
+
{
|
|
654
|
+
name: 'save_scratch_file',
|
|
655
|
+
description: 'Save text content to a file under <dataDir>/scratch/ and return a clickable download URL. USE THIS — not dispatch_task — when the user says "save this to a file", "create a file with X", "export the results", "give me a downloadable copy". The LLM provides the content directly (you already have it in context); no CLI task needed. Chat will auto-link any `scratch/<filename>` mention in your reply as a download link, so just mention the returned path in prose. Scratch is EPHEMERAL — files are auto-deleted after settings.scratchRetentionDays (default 7 days); tell the user this when handing over the link. Filename must be a bare relative name (no .. or /). Allowed extensions for the auto-link: .md .txt .json .yaml .yml .csv .log .html .pdf .png .jpg .jpeg .gif .svg.',
|
|
656
|
+
input_schema: {
|
|
657
|
+
type: 'object',
|
|
658
|
+
properties: {
|
|
659
|
+
filename: {
|
|
660
|
+
type: 'string',
|
|
661
|
+
description: 'Bare filename like "top5_bugs_20260608.md" — relative, no path traversal. Prefer descriptive names with a date stamp.',
|
|
662
|
+
},
|
|
663
|
+
content: {
|
|
664
|
+
type: 'string',
|
|
665
|
+
description: 'Full file content as a UTF-8 string.',
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
required: ['filename', 'content'],
|
|
669
|
+
},
|
|
670
|
+
},
|
|
620
671
|
{
|
|
621
672
|
name: 'get_task_status',
|
|
622
673
|
description: "Check a dispatched Forge task's status + result by id. Pass task_id (returned by dispatch_task). Returns JSON: {id, status: 'queued'|'running'|'done'|'failed'|'cancelled', terminal: bool, project, result_summary?, error?, completed_at?}. For start_watch, use done_path=\"terminal\" (fires on done/failed/cancelled) or done_match={path:\"status\",equals:\"done\"}.",
|
|
@@ -987,26 +1038,47 @@ export async function dispatchTool(
|
|
|
987
1038
|
// can say "build fortinac" without spelling out the job path every
|
|
988
1039
|
// time. Strict: only string args, only when missing or blank, only
|
|
989
1040
|
// when the default is non-empty.
|
|
990
|
-
//
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
// the safety net.
|
|
1041
|
+
//
|
|
1042
|
+
// Two-pass with consumed-default tracking. Exact `default_<pname>`
|
|
1043
|
+
// wins (e.g. `default_project_name` → `project_name` arg). Stem
|
|
1044
|
+
// match is the safety net (e.g. `default_project_path` → `project_id`
|
|
1045
|
+
// arg, since GitLab accepts URL-encoded paths as :id). The consumed
|
|
1046
|
+
// set keeps a default value from polluting MULTIPLE sibling params —
|
|
1047
|
+
// before this, both `project_id` AND `project_name` would receive
|
|
1048
|
+
// the same value from `default_project`, breaking Mantis where
|
|
1049
|
+
// project_id must be numeric.
|
|
994
1050
|
const stem = (n: string) => n.replace(/_(id|path|name|key|slug)$/, '');
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1051
|
+
const paramNames = Object.keys(located.tool.parameters || {});
|
|
1052
|
+
const isMissing = (pname: string) => {
|
|
1053
|
+
const v = argInput[pname];
|
|
1054
|
+
return v == null || (typeof v === 'string' && v.trim() === '');
|
|
1055
|
+
};
|
|
1056
|
+
const consumed = new Set<string>();
|
|
1057
|
+
// Pass 1 — exact default_<pname> matches.
|
|
1058
|
+
for (const pname of paramNames) {
|
|
1059
|
+
if (!isMissing(pname)) continue;
|
|
1060
|
+
const key = `default_${pname}`;
|
|
1061
|
+
const v = (effectiveSettings as any)?.[key];
|
|
1062
|
+
if (typeof v === 'string' && v.trim() !== '') {
|
|
1063
|
+
argInput[pname] = v;
|
|
1064
|
+
consumed.add(key);
|
|
1007
1065
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1066
|
+
}
|
|
1067
|
+
// Pass 2 — stem-match for still-unfilled params, skipping defaults
|
|
1068
|
+
// already consumed by pass 1.
|
|
1069
|
+
for (const pname of paramNames) {
|
|
1070
|
+
if (!isMissing(pname)) continue;
|
|
1071
|
+
const want = stem(pname);
|
|
1072
|
+
for (const k of Object.keys(effectiveSettings || {})) {
|
|
1073
|
+
if (consumed.has(k)) continue;
|
|
1074
|
+
if (!k.startsWith('default_')) continue;
|
|
1075
|
+
if (stem(k.slice('default_'.length)) !== want) continue;
|
|
1076
|
+
const v = (effectiveSettings as any)[k];
|
|
1077
|
+
if (typeof v === 'string' && v.trim() !== '') {
|
|
1078
|
+
argInput[pname] = v;
|
|
1079
|
+
consumed.add(k);
|
|
1080
|
+
break;
|
|
1081
|
+
}
|
|
1010
1082
|
}
|
|
1011
1083
|
}
|
|
1012
1084
|
|
|
@@ -108,3 +108,58 @@ export function migrateConnectorConfigs(): { migrated: number; skipped: number }
|
|
|
108
108
|
|
|
109
109
|
return { migrated, skipped };
|
|
110
110
|
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* One-shot setting-key renames inside connector-configs.json. Add a
|
|
114
|
+
* row when a manifest renames a setting field — the rename is applied
|
|
115
|
+
* on disk so the new schema's UI fields stay populated for upgrading
|
|
116
|
+
* users. Running getInstalledConnector's read-time alias can't do this
|
|
117
|
+
* alone: if the user opens the settings UI between an alias-aware READ
|
|
118
|
+
* and a save, they save an empty value back over the legacy key (Forge
|
|
119
|
+
* settings GET/POST round-trips against the NEW manifest schema and
|
|
120
|
+
* the old key disappears entirely).
|
|
121
|
+
*
|
|
122
|
+
* Idempotent: once renamed, the legacy key is gone and the loop is a
|
|
123
|
+
* no-op on subsequent runs.
|
|
124
|
+
*/
|
|
125
|
+
const CONNECTOR_KEY_RENAMES: Array<{ id: string; from: string; to: string }> = [
|
|
126
|
+
// mantis v0.27.0 (Forge v0.10.45): rename so the dispatcher's exact-
|
|
127
|
+
// match default-fill targets project_name and stops polluting
|
|
128
|
+
// project_id (a number param) with a string value. See
|
|
129
|
+
// applyLegacyConfigAliases in registry.ts for the read-time safety net.
|
|
130
|
+
{ id: 'mantis', from: 'default_project', to: 'default_project_name' },
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
export function migrateConnectorSettingKeys(): { renamed: number } {
|
|
134
|
+
const newPath = connectorConfigsPath();
|
|
135
|
+
if (!existsSync(newPath)) return { renamed: 0 };
|
|
136
|
+
let store: Record<string, ConnectorConfigRow>;
|
|
137
|
+
try { store = JSON.parse(readFileSync(newPath, 'utf-8')); }
|
|
138
|
+
catch { return { renamed: 0 }; }
|
|
139
|
+
let renamed = 0;
|
|
140
|
+
for (const rule of CONNECTOR_KEY_RENAMES) {
|
|
141
|
+
const row = store[rule.id];
|
|
142
|
+
if (!row?.config) continue;
|
|
143
|
+
const cfg = row.config as Record<string, unknown>;
|
|
144
|
+
const legacy = cfg[rule.from];
|
|
145
|
+
const current = cfg[rule.to];
|
|
146
|
+
// Only act when the LEGACY key has a real value AND the new key is
|
|
147
|
+
// either absent or blank. If the user has already set the new key
|
|
148
|
+
// (even by re-saving the form), respect that.
|
|
149
|
+
const hasLegacy = typeof legacy === 'string' ? legacy.trim() !== '' : legacy != null;
|
|
150
|
+
const hasCurrent = typeof current === 'string' ? current.trim() !== '' : current != null;
|
|
151
|
+
if (hasLegacy && !hasCurrent) {
|
|
152
|
+
cfg[rule.to] = legacy;
|
|
153
|
+
delete cfg[rule.from];
|
|
154
|
+
renamed += 1;
|
|
155
|
+
console.log(`[connectors] migration: ${rule.id}.${rule.from} → ${rule.id}.${rule.to}`);
|
|
156
|
+
} else if (hasLegacy && hasCurrent) {
|
|
157
|
+
// New key already set — drop the legacy key so it doesn't shadow.
|
|
158
|
+
delete cfg[rule.from];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (renamed > 0) {
|
|
162
|
+
writeFileSync(newPath, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
163
|
+
}
|
|
164
|
+
return { renamed };
|
|
165
|
+
}
|
|
@@ -317,7 +317,7 @@ export function listInstalledConnectors(): InstalledConnector[] {
|
|
|
317
317
|
if (!row) continue; // manifest on disk but no config row → not installed
|
|
318
318
|
out.push({
|
|
319
319
|
definition: def,
|
|
320
|
-
config: row.config || {},
|
|
320
|
+
config: applyLegacyConfigAliases(id, row.config || {}),
|
|
321
321
|
installed_version: row.installed_version || def.version,
|
|
322
322
|
enabled: row.enabled !== false,
|
|
323
323
|
installed_source_id: row.installed_source_id,
|
|
@@ -332,9 +332,10 @@ export function getInstalledConnector(id: string): InstalledConnector | null {
|
|
|
332
332
|
if (!def) return null;
|
|
333
333
|
const row = loadStore()[id];
|
|
334
334
|
if (!row) return null;
|
|
335
|
+
const config = applyLegacyConfigAliases(id, row.config || {});
|
|
335
336
|
return {
|
|
336
337
|
definition: def,
|
|
337
|
-
config
|
|
338
|
+
config,
|
|
338
339
|
installed_version: row.installed_version || def.version,
|
|
339
340
|
enabled: row.enabled !== false,
|
|
340
341
|
installed_source_id: row.installed_source_id,
|
|
@@ -342,6 +343,23 @@ export function getInstalledConnector(id: string): InstalledConnector | null {
|
|
|
342
343
|
};
|
|
343
344
|
}
|
|
344
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Per-connector backwards-compat for setting renames. Lets us evolve
|
|
348
|
+
* default_<param> keys (e.g. `default_project` → `default_project_name`
|
|
349
|
+
* for mantis v0.27.0) without forcing every existing user to re-fill
|
|
350
|
+
* the wizard. New name wins when both are set.
|
|
351
|
+
*/
|
|
352
|
+
function applyLegacyConfigAliases(id: string, config: Record<string, unknown>): Record<string, unknown> {
|
|
353
|
+
if (id === 'mantis' && config.default_project && !config.default_project_name) {
|
|
354
|
+
// Rename (not duplicate): leaving the legacy key around lets the
|
|
355
|
+
// dispatcher's pass-2 stem-match still fire it into project_id,
|
|
356
|
+
// which is a number param Mantis can't resolve from a string.
|
|
357
|
+
const { default_project, ...rest } = config;
|
|
358
|
+
return { ...rest, default_project_name: default_project };
|
|
359
|
+
}
|
|
360
|
+
return config;
|
|
361
|
+
}
|
|
362
|
+
|
|
345
363
|
/**
|
|
346
364
|
* Install (or upgrade) a connector by writing its manifest YAML and
|
|
347
365
|
* recording the version in the config store. Existing user config is
|
package/lib/connectors/sync.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
28
28
|
import { join } from 'node:path';
|
|
29
29
|
import { loadSettings } from '../settings';
|
|
30
30
|
import { getDataDir } from '../dirs';
|
|
31
|
-
import { listEnterpriseSources, type EnterpriseSource } from '../enterprise';
|
|
31
|
+
import { listEnterpriseSources, parseKey, type EnterpriseSource } from '../enterprise';
|
|
32
32
|
import { getConnector, installConnector, listConfigOnlyIds, listInstalledConnectors } from './registry';
|
|
33
33
|
import type { ConnectorMarketEntry } from './types';
|
|
34
34
|
|
|
@@ -286,6 +286,26 @@ function cacheBust(): string {
|
|
|
286
286
|
return `?_t=${Date.now()}`;
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Validate an enterprise key by fetching registry.json from its repo
|
|
291
|
+
* before persisting. Catches: bad format, expired/wrong PAT (401/403),
|
|
292
|
+
* repo not granted (404), network unreachable. Pure probe — no writes.
|
|
293
|
+
*/
|
|
294
|
+
export async function probeEnterpriseKey(
|
|
295
|
+
rawKey: string,
|
|
296
|
+
): Promise<{ ok: true; tenant_id: string } | { ok: false; error: string }> {
|
|
297
|
+
const parsed = parseKey(rawKey.trim(), 0);
|
|
298
|
+
if ('error' in parsed) return { ok: false, error: parsed.error };
|
|
299
|
+
const source = enterpriseToSource(parsed, 0);
|
|
300
|
+
try {
|
|
301
|
+
await fetchSourceFile(source, 'registry.json');
|
|
302
|
+
return { ok: true, tenant_id: parsed.tenant_id };
|
|
303
|
+
} catch (err) {
|
|
304
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
305
|
+
return { ok: false, error: msg };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
289
309
|
/**
|
|
290
310
|
* Enterprise sources only, in configured priority order. Workflow-
|
|
291
311
|
* marketplace shares these — one enterprise repo serves connectors
|
package/lib/crypto.ts
CHANGED
|
@@ -63,5 +63,5 @@ export function hashSecret(value: string): string {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/** Secret field names in settings */
|
|
66
|
-
export const SECRET_FIELDS = ['telegramBotToken', 'telegramTunnelPassword', 'temperKey', 'smtpPassword'] as const;
|
|
66
|
+
export const SECRET_FIELDS = ['telegramBotToken', 'telegramTunnelPassword', 'temperKey', 'smtpPassword', 'idpSavedUsername', 'idpSavedPassword'] as const;
|
|
67
67
|
export type SecretField = typeof SECRET_FIELDS[number];
|
package/lib/enterprise.ts
CHANGED
|
@@ -38,7 +38,11 @@ function preview(key: string): string {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export function parseKey(key: string, priority: number): EnterpriseSource | { error: string } {
|
|
41
|
-
|
|
41
|
+
// Strip every whitespace + zero-width char — pastes from Teams/email/
|
|
42
|
+
// wiki often carry trailing \r\n, line-wraps, or invisible separators.
|
|
43
|
+
// Valid keys have no internal whitespace (PAT alphanumeric + '_',
|
|
44
|
+
// repo URL has no spaces).
|
|
45
|
+
const trimmed = key.replace(/[\s\u200B-\u200F\u202A-\u202E\u2060\uFEFF]/g, '');
|
|
42
46
|
if (!trimmed) return { error: 'empty' };
|
|
43
47
|
|
|
44
48
|
const longMatch = trimmed.match(LONG_RE);
|
package/lib/init.ts
CHANGED
|
@@ -110,6 +110,10 @@ export function ensureInitialized() {
|
|
|
110
110
|
} catch (e) { console.warn('[init] migratePluginSecrets failed:', (e as Error).message); }
|
|
111
111
|
});
|
|
112
112
|
time('cleanupNotifications', () => { try { const { cleanupNotifications } = require('./notifications'); cleanupNotifications(); } catch {} });
|
|
113
|
+
time('startScratchCleanup', () => {
|
|
114
|
+
try { const { startScratchCleanup } = require('./scratch-cleanup'); startScratchCleanup(); }
|
|
115
|
+
catch (e) { console.warn('[init] startScratchCleanup failed:', (e as Error).message); }
|
|
116
|
+
});
|
|
113
117
|
time('autoDetectAgents', autoDetectAgents);
|
|
114
118
|
time('logToolStatus', () => {
|
|
115
119
|
try { const { logToolStatus } = require('./health'); logToolStatus(); }
|
|
@@ -156,8 +160,12 @@ export function ensureInitialized() {
|
|
|
156
160
|
// installed_version to decide what to refresh).
|
|
157
161
|
try {
|
|
158
162
|
time('migrateConnectorConfigs', () => {
|
|
159
|
-
const { migrateConnectorConfigs } = require('./connectors/migration');
|
|
163
|
+
const { migrateConnectorConfigs, migrateConnectorSettingKeys } = require('./connectors/migration');
|
|
160
164
|
migrateConnectorConfigs();
|
|
165
|
+
// Setting-key renames (e.g. mantis default_project → default_project_name).
|
|
166
|
+
// Run after the file-level migration so we touch the canonical
|
|
167
|
+
// connector-configs.json, not the legacy plugin-configs.json.
|
|
168
|
+
migrateConnectorSettingKeys();
|
|
161
169
|
});
|
|
162
170
|
} catch (err) {
|
|
163
171
|
console.warn('[connectors] migration failed:', err);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scratch directory janitor — deletes stale files under <dataDir>/scratch/.
|
|
3
|
+
*
|
|
4
|
+
* The save_scratch_file chat tool drops generated reports here so the user
|
|
5
|
+
* can download them; without periodic sweeping the directory grows forever.
|
|
6
|
+
* Retention is settings.scratchRetentionDays (default 7).
|
|
7
|
+
*
|
|
8
|
+
* Conservative scope: only sweeps PLAIN FILES at the top level of scratch/.
|
|
9
|
+
* - Skips dotfiles (.mcp.json, .claude/, .forge/) — Forge/Claude project state
|
|
10
|
+
* - Skips CLAUDE.md — scratch project marker
|
|
11
|
+
* - Skips subdirectories — could be user-created project trees we don't own
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { getDataDir } from './dirs';
|
|
17
|
+
import { loadSettings } from './settings';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_RETENTION_DAYS = 7;
|
|
20
|
+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
function retentionMs(): number {
|
|
23
|
+
let days = DEFAULT_RETENTION_DAYS;
|
|
24
|
+
try {
|
|
25
|
+
const s = loadSettings() as any;
|
|
26
|
+
const v = Number(s?.scratchRetentionDays);
|
|
27
|
+
if (Number.isFinite(v) && v > 0) days = v;
|
|
28
|
+
} catch { /* settings unreadable — fall back to default */ }
|
|
29
|
+
return days * ONE_DAY_MS;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function cleanupScratch(): { scanned: number; deleted: number; freedBytes: number } {
|
|
33
|
+
const root = join(getDataDir(), 'scratch');
|
|
34
|
+
let scanned = 0, deleted = 0, freedBytes = 0;
|
|
35
|
+
const maxAge = retentionMs();
|
|
36
|
+
const cutoff = Date.now() - maxAge;
|
|
37
|
+
let entries: string[] = [];
|
|
38
|
+
try { entries = readdirSync(root); } catch { return { scanned, deleted, freedBytes }; }
|
|
39
|
+
for (const name of entries) {
|
|
40
|
+
if (name.startsWith('.')) continue;
|
|
41
|
+
if (name === 'CLAUDE.md') continue;
|
|
42
|
+
const p = join(root, name);
|
|
43
|
+
let st;
|
|
44
|
+
try { st = statSync(p); } catch { continue }
|
|
45
|
+
if (!st.isFile()) continue;
|
|
46
|
+
scanned++;
|
|
47
|
+
if (st.mtimeMs > cutoff) continue;
|
|
48
|
+
try { unlinkSync(p); deleted++; freedBytes += st.size; }
|
|
49
|
+
catch (e) { console.warn('[scratch-cleanup] unlink failed:', p, (e as Error).message); }
|
|
50
|
+
}
|
|
51
|
+
if (deleted > 0) {
|
|
52
|
+
console.log(`[scratch-cleanup] deleted ${deleted}/${scanned} files, freed ${freedBytes} bytes`);
|
|
53
|
+
}
|
|
54
|
+
return { scanned, deleted, freedBytes };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let started = false;
|
|
58
|
+
export function startScratchCleanup(): void {
|
|
59
|
+
if (started) return;
|
|
60
|
+
started = true;
|
|
61
|
+
// Run once shortly after boot (don't block ensureInitialized), then every 24h.
|
|
62
|
+
setTimeout(() => { try { cleanupScratch(); } catch (e) { console.warn('[scratch-cleanup]', (e as Error).message); } }, 30_000);
|
|
63
|
+
setInterval(() => { try { cleanupScratch(); } catch (e) { console.warn('[scratch-cleanup]', (e as Error).message); } }, ONE_DAY_MS);
|
|
64
|
+
}
|