@aion0/forge 0.10.67 → 0.10.69
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 +9 -4
- package/components/EnterpriseBadge.tsx +12 -23
- package/components/ScheduleCreateModal.tsx +22 -1
- package/components/WebTerminal.tsx +25 -20
- package/lib/auth/idp-login.ts +13 -2
- package/lib/auth/login-status.ts +5 -1
- package/lib/auth/terminal-keystroke.ts +21 -5
- package/lib/help-docs/05-pipelines.md +42 -0
- package/lib/help-docs/07-projects.md +31 -0
- package/lib/init.ts +6 -1
- package/lib/notify.ts +53 -0
- package/lib/pipeline-gc.ts +62 -0
- package/lib/pipeline.ts +146 -8
- package/lib/projects.ts +76 -0
- package/lib/schedules/action-runner.ts +24 -3
- package/lib/settings.ts +5 -0
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.69
|
|
2
2
|
|
|
3
|
-
Released: 2026-06-
|
|
3
|
+
Released: 2026-06-11
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.68
|
|
6
6
|
|
|
7
|
+
### Other
|
|
8
|
+
- fix(idp): decouple probe host from IdP login host (probe_host)
|
|
9
|
+
- fix(terminal): resolve absolute claude binary on tab restore
|
|
10
|
+
- feat(pipeline): git OTP/2FA preflight + auth-blocked alerts
|
|
7
11
|
|
|
8
|
-
|
|
12
|
+
|
|
13
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.68...v0.10.69
|
|
@@ -98,7 +98,6 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
|
|
|
98
98
|
// creds and prefill so the user only types OTP.
|
|
99
99
|
const [idpSaveCreds, setIdpSaveCreds] = useState(false);
|
|
100
100
|
const [idpHasSaved, setIdpHasSaved] = useState(false);
|
|
101
|
-
const [idpOtp, setIdpOtp] = useState('');
|
|
102
101
|
const [idpBusy, setIdpBusy] = useState(false);
|
|
103
102
|
const [idpErr, setIdpErr] = useState('');
|
|
104
103
|
const [idpStatus, setIdpStatus] = useState('');
|
|
@@ -190,8 +189,8 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
|
|
|
190
189
|
|
|
191
190
|
const handleIdpLogin = async () => {
|
|
192
191
|
if (idpBusy) return;
|
|
193
|
-
if (!idpUser.trim() || !idpPass
|
|
194
|
-
setIdpErr('Username
|
|
192
|
+
if (!idpUser.trim() || !idpPass) {
|
|
193
|
+
setIdpErr('Username and password are required');
|
|
195
194
|
return;
|
|
196
195
|
}
|
|
197
196
|
setIdpBusy(true); setIdpErr(''); setIdpStatus(''); setIdpManualCommands([]); setIdpManualUrls([]);
|
|
@@ -199,7 +198,10 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
|
|
|
199
198
|
const r = await fetch('/api/auth/idp-login', {
|
|
200
199
|
method: 'POST',
|
|
201
200
|
headers: { 'Content-Type': 'application/json' },
|
|
202
|
-
|
|
201
|
+
// No OTP field — FortiToken push: the IdP prompts the user's phone
|
|
202
|
+
// and the extension/terminal wait for the approval (or a manually
|
|
203
|
+
// typed code) instead of Forge pasting one.
|
|
204
|
+
body: JSON.stringify({ username: idpUser.trim(), password: idpPass }),
|
|
203
205
|
});
|
|
204
206
|
const data = await r.json();
|
|
205
207
|
// Template may declare multiple IdPs (e.g. FAC for mantis/pmdb/tp +
|
|
@@ -253,9 +255,6 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
|
|
|
253
255
|
setIdpHasSaved(true);
|
|
254
256
|
} catch { /* non-fatal */ }
|
|
255
257
|
}
|
|
256
|
-
// Only wipe OTP after success — keep password for follow-up Login
|
|
257
|
-
// All Sites within the same session (OTP is single-use anyway).
|
|
258
|
-
setIdpOtp('');
|
|
259
258
|
if (!idpSaveCreds) setIdpPass('');
|
|
260
259
|
setIdpStatus(`${parts.join(' · ')}. Re-probing sessions…`);
|
|
261
260
|
await handleCheckAllWeb();
|
|
@@ -485,26 +484,16 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
|
|
|
485
484
|
</button>
|
|
486
485
|
</div>
|
|
487
486
|
</div>
|
|
488
|
-
<div className="flex gap-1">
|
|
489
|
-
<
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
pattern="\d*"
|
|
493
|
-
autoComplete="one-time-code"
|
|
494
|
-
maxLength={8}
|
|
495
|
-
placeholder="6-digit OTP"
|
|
496
|
-
value={idpOtp}
|
|
497
|
-
onChange={(e) => setIdpOtp(e.target.value.replace(/\D/g, ''))}
|
|
498
|
-
onKeyDown={(e) => { if (e.key === 'Enter') handleIdpLogin(); }}
|
|
499
|
-
className="flex-1 text-xs px-2 py-1 bg-[var(--bg)] border border-[var(--border)] rounded font-mono tracking-wider"
|
|
500
|
-
disabled={idpBusy}
|
|
501
|
-
/>
|
|
487
|
+
<div className="flex items-center gap-1.5">
|
|
488
|
+
<span className="flex-1 text-[9px] text-[var(--text-secondary)]">
|
|
489
|
+
OTP via phone push — approve on your FortiToken when prompted
|
|
490
|
+
</span>
|
|
502
491
|
<button
|
|
503
492
|
onClick={handleIdpLogin}
|
|
504
|
-
disabled={idpBusy || !idpUser.trim() || !idpPass
|
|
493
|
+
disabled={idpBusy || !idpUser.trim() || !idpPass}
|
|
505
494
|
className="text-[10px] px-2 py-1 rounded bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
506
495
|
>
|
|
507
|
-
{idpBusy ? '…' : 'Login all sites'}
|
|
496
|
+
{idpBusy ? 'Waiting for approval…' : 'Login all sites'}
|
|
508
497
|
</button>
|
|
509
498
|
</div>
|
|
510
499
|
<div className="flex items-center gap-2 text-[10px] text-[var(--text-secondary)]">
|
|
@@ -148,6 +148,9 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
|
|
|
148
148
|
? String(existing.action_config?.body_template ?? '{body_output}')
|
|
149
149
|
: '{body_output}'
|
|
150
150
|
);
|
|
151
|
+
const [emailTransport, setEmailTransport] = useState<'smtp' | 'owa'>(
|
|
152
|
+
existing?.action_kind === 'email' && existing.action_config?.transport === 'owa' ? 'owa' : 'smtp'
|
|
153
|
+
);
|
|
151
154
|
const [telegramChatId, setTelegramChatId] = useState(
|
|
152
155
|
existing?.action_kind === 'telegram' ? String(existing.action_config?.chat_id ?? '') : ''
|
|
153
156
|
);
|
|
@@ -340,6 +343,7 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
|
|
|
340
343
|
action_config.to = to.length > 1 ? to : (to[0] || '');
|
|
341
344
|
if (emailSubjectTpl) action_config.subject_template = emailSubjectTpl;
|
|
342
345
|
if (emailBodyTpl) action_config.body_template = emailBodyTpl;
|
|
346
|
+
action_config.transport = emailTransport;
|
|
343
347
|
} else if (actionKind === 'telegram') {
|
|
344
348
|
if (telegramChatId.trim()) action_config.chat_id = telegramChatId.trim();
|
|
345
349
|
if (telegramPrefix) action_config.prefix = telegramPrefix;
|
|
@@ -437,6 +441,7 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
|
|
|
437
441
|
emailTo={emailTo} onEmailTo={setEmailTo}
|
|
438
442
|
emailSubjectTpl={emailSubjectTpl} onEmailSubjectTpl={setEmailSubjectTpl}
|
|
439
443
|
emailBodyTpl={emailBodyTpl} onEmailBodyTpl={setEmailBodyTpl}
|
|
444
|
+
emailTransport={emailTransport} onEmailTransport={setEmailTransport}
|
|
440
445
|
telegramChatId={telegramChatId} onTelegramChatId={setTelegramChatId}
|
|
441
446
|
telegramPrefix={telegramPrefix} onTelegramPrefix={setTelegramPrefix}
|
|
442
447
|
/>
|
|
@@ -855,6 +860,7 @@ function Step3({
|
|
|
855
860
|
emailTo, onEmailTo,
|
|
856
861
|
emailSubjectTpl, onEmailSubjectTpl,
|
|
857
862
|
emailBodyTpl, onEmailBodyTpl,
|
|
863
|
+
emailTransport, onEmailTransport,
|
|
858
864
|
telegramChatId, onTelegramChatId,
|
|
859
865
|
telegramPrefix, onTelegramPrefix,
|
|
860
866
|
}: {
|
|
@@ -871,6 +877,7 @@ function Step3({
|
|
|
871
877
|
emailTo: string; onEmailTo: (v: string) => void;
|
|
872
878
|
emailSubjectTpl: string; onEmailSubjectTpl: (v: string) => void;
|
|
873
879
|
emailBodyTpl: string; onEmailBodyTpl: (v: string) => void;
|
|
880
|
+
emailTransport: 'smtp' | 'owa'; onEmailTransport: (v: 'smtp' | 'owa') => void;
|
|
874
881
|
telegramChatId: string; onTelegramChatId: (v: string) => void;
|
|
875
882
|
telegramPrefix: string; onTelegramPrefix: (v: string) => void;
|
|
876
883
|
}) {
|
|
@@ -962,7 +969,7 @@ function Step3({
|
|
|
962
969
|
)}
|
|
963
970
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
964
971
|
<input type="radio" name="action" checked={actionKind === 'email'} onChange={() => onActionKind('email')} />
|
|
965
|
-
<span className="text-[11px] w-44">Send Email
|
|
972
|
+
<span className="text-[11px] w-44">Send Email</span>
|
|
966
973
|
<input
|
|
967
974
|
type="text"
|
|
968
975
|
value={emailTo}
|
|
@@ -974,6 +981,20 @@ function Step3({
|
|
|
974
981
|
</label>
|
|
975
982
|
{actionKind === 'email' && (
|
|
976
983
|
<>
|
|
984
|
+
<label className="flex items-center gap-2">
|
|
985
|
+
<span className="text-[11px] w-44 ml-6">transport</span>
|
|
986
|
+
<select
|
|
987
|
+
value={emailTransport}
|
|
988
|
+
onChange={(e) => onEmailTransport(e.target.value as 'smtp' | 'owa')}
|
|
989
|
+
className="text-[11px] px-2 py-0.5 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
990
|
+
>
|
|
991
|
+
<option value="smtp">SMTP (Settings → SMTP)</option>
|
|
992
|
+
<option value="owa">OWA connector (your mailbox, no SMTP)</option>
|
|
993
|
+
</select>
|
|
994
|
+
{emailTransport === 'owa' && (
|
|
995
|
+
<span className="text-[10px] text-[var(--text-secondary)]">needs the browser extension connected</span>
|
|
996
|
+
)}
|
|
997
|
+
</label>
|
|
977
998
|
<label className="flex items-center gap-2">
|
|
978
999
|
<span className="text-[11px] w-44 ml-6">subject template</span>
|
|
979
1000
|
<input
|
|
@@ -1572,26 +1572,31 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
1572
1572
|
if (isNewlyCreated && projectPathRef.current && !pendingCommands.has(id)) {
|
|
1573
1573
|
isNewlyCreated = false;
|
|
1574
1574
|
const pp = projectPathRef.current;
|
|
1575
|
-
import('@/lib/session-utils').then(({ resolveFixedSession, buildResumeFlag, getMcpFlag }) => {
|
|
1576
|
-
|
|
1577
|
-
//
|
|
1578
|
-
//
|
|
1579
|
-
// stale global @anthropic-ai/claude-code (e.g.
|
|
1580
|
-
//
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
}
|
|
1575
|
+
import('@/lib/session-utils').then(async ({ resolveFixedSession, buildResumeFlag, getMcpFlag }) => {
|
|
1576
|
+
const [fixedId, mcpFlag] = await Promise.all([resolveFixedSession(pp), getMcpFlag(pp)]);
|
|
1577
|
+
// Resolve the agent's ABSOLUTE claude binary. Bare `claude`
|
|
1578
|
+
// rides the tmux pane PATH, which on this machine can hit a
|
|
1579
|
+
// stale global @anthropic-ai/claude-code (e.g. /opt/homebrew,
|
|
1580
|
+
// conda base) that fails on --resume. On restart the resolve
|
|
1581
|
+
// can lose a race with worker boot, so retry a few times
|
|
1582
|
+
// rather than launching bare claude — launching the wrong
|
|
1583
|
+
// binary is the actual restart-failure bug.
|
|
1584
|
+
let agentCmd = 'claude';
|
|
1585
|
+
for (let i = 0; i < 3; i++) {
|
|
1586
|
+
try {
|
|
1587
|
+
const info = await fetch('/api/agents?resolve=').then(r => r.ok ? r.json() : null);
|
|
1588
|
+
if (info?.cliCmd) { agentCmd = info.cliCmd; break; }
|
|
1589
|
+
} catch {}
|
|
1590
|
+
await new Promise(res => setTimeout(res, 400));
|
|
1591
|
+
}
|
|
1592
|
+
const resumeFlag = buildResumeFlag(fixedId, true);
|
|
1593
|
+
const skipFlag = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
|
|
1594
|
+
const claudeCmd = `"${agentCmd}"`;
|
|
1595
|
+
setTimeout(() => {
|
|
1596
|
+
if (!disposed && ws?.readyState === WebSocket.OPEN) {
|
|
1597
|
+
ws.send(JSON.stringify({ type: 'input', data: `cd "${pp}" && ${claudeCmd}${resumeFlag}${skipFlag}${mcpFlag}\n` }));
|
|
1598
|
+
}
|
|
1599
|
+
}, 300);
|
|
1595
1600
|
});
|
|
1596
1601
|
}
|
|
1597
1602
|
isNewlyCreated = false;
|
package/lib/auth/idp-login.ts
CHANGED
|
@@ -56,6 +56,14 @@ export interface IdpTemplateBlock {
|
|
|
56
56
|
* `https://<host>/`. Override when the IdP's logged-in landing page
|
|
57
57
|
* is at a specific path (e.g. `/saml-idp/portal/` for FAC). */
|
|
58
58
|
probe_url?: string;
|
|
59
|
+
/** Hostname the Login Status probe expects to land on when signed in.
|
|
60
|
+
* Defaults to `host`. Set when login and logged-in detection live on
|
|
61
|
+
* DIFFERENT hosts: e.g. the SCAP SP logs in via the sso.frval IdP form
|
|
62
|
+
* (host = sso.frval, where the extension fills credentials) but a
|
|
63
|
+
* signed-in probe of scap.fortinet.com lands back on scap
|
|
64
|
+
* (probe_host = scap.fortinet.com). Without this the host-only probe
|
|
65
|
+
* would mismatch and falsely report "not signed in". */
|
|
66
|
+
probe_host?: string;
|
|
59
67
|
/** DOM selector present ONLY on the login page (e.g. 'input[type=password]').
|
|
60
68
|
* Required for IdPs that serve their login form on the SAME host as the
|
|
61
69
|
* logged-in page (sso.frval.fortinet-emea.com) — without it the host-only
|
|
@@ -223,8 +231,11 @@ async function runOneIdp(block: IdpTemplateBlock, req: IdpLoginRequest): Promise
|
|
|
223
231
|
}
|
|
224
232
|
|
|
225
233
|
export async function performIdpLogin(req: IdpLoginRequest): Promise<IdpLoginResult> {
|
|
226
|
-
|
|
227
|
-
|
|
234
|
+
// OTP is optional: when absent, the extension waits for the user to
|
|
235
|
+
// approve the FortiToken push (or type the code by hand) instead of
|
|
236
|
+
// Forge pasting one — see token_push_wait in the extension's idpLogin.
|
|
237
|
+
if (!req.username || !req.password) {
|
|
238
|
+
return { ok: false, idp_logins: [], error: 'username and password are required' };
|
|
228
239
|
}
|
|
229
240
|
const blocks = readIdpBlocks();
|
|
230
241
|
if (blocks.length === 0) {
|
package/lib/auth/login-status.ts
CHANGED
|
@@ -234,8 +234,12 @@ async function checkIdp(
|
|
|
234
234
|
): Promise<Omit<LoginCheckResult, 'checked_at' | 'duration_ms'>> {
|
|
235
235
|
const block = readIdpBlocks().find((b) => b.host === host);
|
|
236
236
|
const probe_url = block ? idpProbeUrl(block) : `https://${host}/`;
|
|
237
|
+
// Host the signed-in probe should land on — defaults to the IdP host,
|
|
238
|
+
// but SP-fronted IdPs (login at host, logged-in lands on the SP) set
|
|
239
|
+
// probe_host to the SP so hostnameMatch doesn't falsely fail.
|
|
240
|
+
const expectHost = (block?.probe_host && block.probe_host.trim()) || host;
|
|
237
241
|
try {
|
|
238
|
-
const r = await runBrowserUrlProbe({ id: `idp:${host}`, host, probe_url, auth_required_selector: block?.auth_required_selector });
|
|
242
|
+
const r = await runBrowserUrlProbe({ id: `idp:${host}`, host: expectHost, probe_url, auth_required_selector: block?.auth_required_selector });
|
|
239
243
|
return {
|
|
240
244
|
ok: !!r.ok,
|
|
241
245
|
message: r.ok ? (r.message || 'ok') : (r.error || `HTTP ${r.status || '?'}`),
|
|
@@ -74,7 +74,13 @@ export async function runTerminalKeystroke(
|
|
|
74
74
|
if (process.platform !== 'darwin') {
|
|
75
75
|
return { name: spec.name, command: spec.command, ok: false, error: 'Keystroke mode is macOS-only.' };
|
|
76
76
|
}
|
|
77
|
-
|
|
77
|
+
// OTP is optional: absent on a needs_otp command → "push mode". We type
|
|
78
|
+
// the command and then WAIT for the human to finish it — approve the
|
|
79
|
+
// FortiToken push on their phone, or type the code into the Terminal
|
|
80
|
+
// themselves. Completion is still detected via the OK/FAIL echo markers,
|
|
81
|
+
// so it works regardless of how the prompt gets satisfied.
|
|
82
|
+
const pushMode = !!spec.needs_otp && !String(otp || '').trim();
|
|
83
|
+
if (spec.needs_otp && !pushMode) {
|
|
78
84
|
const cleaned = String(otp || '').replace(/\s+/g, '');
|
|
79
85
|
if (!/^\d{6,8}$/.test(cleaned)) {
|
|
80
86
|
return { name: spec.name, command: spec.command, ok: false, error: 'OTP must be 6–8 digits' };
|
|
@@ -115,7 +121,7 @@ export async function runTerminalKeystroke(
|
|
|
115
121
|
'end tell',
|
|
116
122
|
];
|
|
117
123
|
|
|
118
|
-
if (spec.needs_otp) {
|
|
124
|
+
if (spec.needs_otp && !pushMode) {
|
|
119
125
|
lines.push(
|
|
120
126
|
'set promptDeadline to (current date) + 20',
|
|
121
127
|
'set sawPrompt to false',
|
|
@@ -153,12 +159,15 @@ export async function runTerminalKeystroke(
|
|
|
153
159
|
);
|
|
154
160
|
}
|
|
155
161
|
|
|
162
|
+
// Push mode waits for a human (phone approve / manual code entry), so
|
|
163
|
+
// give it minutes, not seconds. Normal mode keeps the snappy 30s cap.
|
|
164
|
+
const waitSecs = pushMode ? 180 : 30;
|
|
156
165
|
lines.push(
|
|
157
166
|
'set t0 to current date',
|
|
158
167
|
'set verdict to "timeout"',
|
|
159
168
|
'set tail to ""',
|
|
160
169
|
'repeat',
|
|
161
|
-
|
|
170
|
+
` if ((current date) - t0) > ${waitSecs} then exit repeat`,
|
|
162
171
|
' delay 0.4',
|
|
163
172
|
' try',
|
|
164
173
|
' tell application "Terminal"',
|
|
@@ -193,7 +202,7 @@ export async function runTerminalKeystroke(
|
|
|
193
202
|
'return verdict & "|" & tail',
|
|
194
203
|
);
|
|
195
204
|
|
|
196
|
-
const r = await runAppleScript(lines.join('\n'),
|
|
205
|
+
const r = await runAppleScript(lines.join('\n'), (waitSecs + 40) * 1000);
|
|
197
206
|
const out = (r.stdout || '').trim();
|
|
198
207
|
if (r.code !== 0) {
|
|
199
208
|
const err = (r.stderr || '').trim();
|
|
@@ -241,5 +250,12 @@ export async function runTerminalKeystroke(
|
|
|
241
250
|
if (verdict === 'fail') return { name: spec.name, command: spec.command, ok: false, error: 'command exited non-zero', transcript: tail };
|
|
242
251
|
if (verdict === 'noprompt') return { name: spec.name, command: spec.command, ok: false, error: tail || 'No prompt appeared' };
|
|
243
252
|
if (verdict.startsWith('error:')) return { name: spec.name, command: spec.command, ok: false, error: verdict.slice(6) };
|
|
244
|
-
return {
|
|
253
|
+
return {
|
|
254
|
+
name: spec.name,
|
|
255
|
+
command: spec.command,
|
|
256
|
+
ok: false,
|
|
257
|
+
error: pushMode
|
|
258
|
+
? `No completion after ${waitSecs}s — approve the push on your phone or type the code in the Terminal, then retry`
|
|
259
|
+
: 'timed out reading Terminal',
|
|
260
|
+
};
|
|
245
261
|
}
|
|
@@ -49,6 +49,48 @@ nodes:
|
|
|
49
49
|
prompt: "Review the changes"
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
## Git OTP / 2FA preflight (`git_push: true`)
|
|
53
|
+
|
|
54
|
+
Pipelines that push to git (open MRs, push fixup commits) can declare a
|
|
55
|
+
top-level `git_push: true`. When set, Forge runs a **preflight** at pipeline
|
|
56
|
+
start — a lightweight `git ls-remote --heads origin` (no object transfer) on
|
|
57
|
+
the resolved project's repo:
|
|
58
|
+
|
|
59
|
+
```yaml
|
|
60
|
+
name: my-fix-and-mr
|
|
61
|
+
version: "1.0.0"
|
|
62
|
+
git_push: true # <-- enables the OTP/2FA preflight
|
|
63
|
+
input: { ... }
|
|
64
|
+
nodes: { ... }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- If the remote demands OTP/2FA (e.g. Fortinet's
|
|
68
|
+
`ssh git@<host> 2fa_verify`), the **entire run aborts immediately**, before
|
|
69
|
+
any code work — so a 2FA wall never wastes a full fix pass that can't push.
|
|
70
|
+
- The pipeline is marked `failed` with the exact `2fa_verify` command in its
|
|
71
|
+
error, and an **auth alert** is fired (see below).
|
|
72
|
+
- Scratch / non-git projects and unrelated remote errors (network, no origin)
|
|
73
|
+
are ignored — only a genuine OTP marker blocks. Those still surface at push
|
|
74
|
+
time as before.
|
|
75
|
+
|
|
76
|
+
## Auth-required alerts (git OTP + expired connector logins)
|
|
77
|
+
|
|
78
|
+
When a `git_push` pipeline hits the OTP wall, **or** any pipeline fails on an
|
|
79
|
+
expired connector login (Mantis / Teams / OWA session lapsed), Forge fires one
|
|
80
|
+
"auth required" alert across every channel it can, so you can fix it and re-run
|
|
81
|
+
without digging through logs:
|
|
82
|
+
|
|
83
|
+
1. **In-app notification** (always).
|
|
84
|
+
2. **Telegram** — if `telegramBotToken` + `telegramChatId` are set (Settings →
|
|
85
|
+
Telegram).
|
|
86
|
+
3. **Email to yourself** — sent to your own mailbox (your **`displayEmail`** from
|
|
87
|
+
Settings → Identity, i.e. `{user.email}`) via the OWA connector, if that's
|
|
88
|
+
configured. No SMTP or extra recipient setting needed.
|
|
89
|
+
|
|
90
|
+
The connector-login case is detected heuristically from the failed node's
|
|
91
|
+
error/output (login-page / "session expired" / "请登录" markers), one alert per
|
|
92
|
+
pipeline.
|
|
93
|
+
|
|
52
94
|
## Node Options
|
|
53
95
|
|
|
54
96
|
| Field | Description | Default |
|
|
@@ -4,6 +4,37 @@
|
|
|
4
4
|
|
|
5
5
|
Add project directories in Settings → **Project Roots** (e.g. `~/Projects`). Forge scans subdirectories automatically.
|
|
6
6
|
|
|
7
|
+
## How a project is resolved (pipelines / MR review / bug fix)
|
|
8
|
+
|
|
9
|
+
When a pipeline (or chat-dispatched MR review / bug fix) needs a project, Forge
|
|
10
|
+
resolves the given name in this order — first hit wins:
|
|
11
|
+
|
|
12
|
+
1. **Exact project name** — a scanned project whose directory name equals the
|
|
13
|
+
value passed in.
|
|
14
|
+
2. **Git repo match** — when the value looks like an `owner/repo` path (e.g.
|
|
15
|
+
derived from a GitLab MR URL), Forge compares it against each project's
|
|
16
|
+
**git `origin` remote**, which it auto-detects from `.git/config` during the
|
|
17
|
+
scan (no subprocess). Full path first, then the repo basename. This is how
|
|
18
|
+
"review this MR" finds the right local checkout even when the directory is
|
|
19
|
+
named differently — you don't need to type the project. Only an
|
|
20
|
+
**unambiguous single match** is accepted; if two local clones share the same
|
|
21
|
+
origin, Forge skips this step rather than guess.
|
|
22
|
+
3. **Fuzzy name** — basename / `owner-repo` variants of the value.
|
|
23
|
+
4. **Auto-clone** — if a GitLab connector is configured, clone the repo into
|
|
24
|
+
`<dataDir>/cloned-projects/<owner>-<repo>/` and reuse it next time.
|
|
25
|
+
5. **Scratch** — the always-writable `<dataDir>/scratch` fallback for flows
|
|
26
|
+
that don't need a real checkout.
|
|
27
|
+
|
|
28
|
+
Notes:
|
|
29
|
+
- The detected repo is internal (not shown in Settings) — it's derived live
|
|
30
|
+
from each project's git origin on every scan, so existing projects work with
|
|
31
|
+
no migration.
|
|
32
|
+
- Override detection (rare: wrong/missing origin) via
|
|
33
|
+
`settings.projectRepos: { "<projectPath>": "owner/repo" }`.
|
|
34
|
+
- Auto-cloned repos under `cloned-projects/` are swept after
|
|
35
|
+
`clonedProjectKeepDays` (default 30) of disuse, unless a running pipeline
|
|
36
|
+
still has a worktree there.
|
|
37
|
+
|
|
7
38
|
## Features
|
|
8
39
|
|
|
9
40
|
### Code Tab
|
package/lib/init.ts
CHANGED
|
@@ -220,7 +220,7 @@ export function ensureInitialized() {
|
|
|
220
220
|
// expired (failed/cancelled past retention). Interval from settings,
|
|
221
221
|
// clamped to >= 1h to avoid runaway IO. Runs in background only.
|
|
222
222
|
try {
|
|
223
|
-
const { gcPipelineTmp } = require('./pipeline-gc');
|
|
223
|
+
const { gcPipelineTmp, gcClonedProjects } = require('./pipeline-gc');
|
|
224
224
|
const { loadSettings: ls } = require('./settings');
|
|
225
225
|
const hours = Math.max(1, Number(ls().pipelineTmpGcIntervalHours) || 6);
|
|
226
226
|
setInterval(() => {
|
|
@@ -228,6 +228,11 @@ export function ensureInitialized() {
|
|
|
228
228
|
const r = gcPipelineTmp();
|
|
229
229
|
if (r.removed.length) console.log(`[pipeline-gc] swept ${r.removed.length}/${r.scanned} dir(s)`);
|
|
230
230
|
} catch (e) { console.warn('[pipeline-gc] sweep failed:', (e as Error).message); }
|
|
231
|
+
// Also sweep the auto-clone cache (long retention; conservative).
|
|
232
|
+
try {
|
|
233
|
+
const c = gcClonedProjects();
|
|
234
|
+
if (c.removed.length) console.log(`[pipeline-gc] swept ${c.removed.length}/${c.scanned} cloned repo(s)`);
|
|
235
|
+
} catch (e) { console.warn('[pipeline-gc] cloned-projects sweep failed:', (e as Error).message); }
|
|
231
236
|
}, hours * 60 * 60 * 1000);
|
|
232
237
|
} catch (e) { console.warn('[pipeline-gc] setup failed:', (e as Error).message); }
|
|
233
238
|
|
package/lib/notify.ts
CHANGED
|
@@ -76,6 +76,59 @@ export async function notifyTaskFailed(task: Task) {
|
|
|
76
76
|
);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Fan-out alert for a blocking auth wall (git OTP/2FA, expired connector
|
|
81
|
+
* login). "Notify as loudly as possible" — fires all three channels
|
|
82
|
+
* best-effort and independently; one failing never blocks the others:
|
|
83
|
+
* 1. in-app alert (always)
|
|
84
|
+
* 2. Telegram (if bot token + chat id configured)
|
|
85
|
+
* 3. email to the user's own mailbox via OWA (if idpSavedUsername set)
|
|
86
|
+
* The action line (e.g. the `ssh ... 2fa_verify` command) is carried so the
|
|
87
|
+
* user can resolve it from the notification alone.
|
|
88
|
+
*/
|
|
89
|
+
export async function notifyAuthBlocked(opts: {
|
|
90
|
+
title: string;
|
|
91
|
+
body: string;
|
|
92
|
+
/** Optional shell command the user can run to clear the block. */
|
|
93
|
+
action?: string;
|
|
94
|
+
refId?: string;
|
|
95
|
+
}): Promise<void> {
|
|
96
|
+
const settings = loadSettings();
|
|
97
|
+
const fullBody = opts.action ? `${opts.body}\n\nRun: ${opts.action}` : opts.body;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
addNotification('auth_blocked', opts.title, fullBody, opts.refId);
|
|
101
|
+
} catch {}
|
|
102
|
+
|
|
103
|
+
await sendTelegram(
|
|
104
|
+
`🔐 *Auth Required*\n\n` +
|
|
105
|
+
`${esc(opts.title)}\n\n` +
|
|
106
|
+
`${esc(opts.body)}` +
|
|
107
|
+
(opts.action ? `\n\n\`${esc(opts.action)}\`` : '')
|
|
108
|
+
).catch(() => {});
|
|
109
|
+
|
|
110
|
+
// Email to self via the logged-in OWA mailbox. Recipient is the user's
|
|
111
|
+
// own email from Settings → Identity (displayEmail / {user.email}) — read,
|
|
112
|
+
// not inferred. No SMTP config or extra notifyEmail setting needed.
|
|
113
|
+
const self = (settings.displayEmail || '').trim();
|
|
114
|
+
if (self && self.includes('@')) {
|
|
115
|
+
try {
|
|
116
|
+
const { dispatchTool } = await import('./chat/tool-dispatcher');
|
|
117
|
+
await dispatchTool({
|
|
118
|
+
id: `auth-blocked-${Date.now()}`,
|
|
119
|
+
name: 'owa.send_mail',
|
|
120
|
+
input: {
|
|
121
|
+
to: self,
|
|
122
|
+
subject: `[Forge] ${opts.title}`,
|
|
123
|
+
body: fullBody,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
} catch (e) {
|
|
127
|
+
console.warn('[notify] auth-blocked email failed:', (e as Error).message);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
79
132
|
async function sendTelegram(text: string) {
|
|
80
133
|
const settings = loadSettings();
|
|
81
134
|
const { telegramBotToken, telegramChatId } = settings;
|
package/lib/pipeline-gc.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { scanProjects, getProjectWorktreeRoot } from './projects';
|
|
|
24
24
|
import { join } from 'node:path';
|
|
25
25
|
import { getPipeline } from './pipeline';
|
|
26
26
|
import { loadSettings } from './settings';
|
|
27
|
+
import { getDataDir } from './dirs';
|
|
27
28
|
|
|
28
29
|
export interface GcResult {
|
|
29
30
|
scanned: number;
|
|
@@ -106,3 +107,64 @@ export function gcPipelineTmp(opts: { dryRun?: boolean } = {}): GcResult {
|
|
|
106
107
|
|
|
107
108
|
return { scanned, removed, kept };
|
|
108
109
|
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* GC the auto-cloned repo cache at <dataDir>/cloned-projects/<owner>-<repo>/.
|
|
113
|
+
* These are full git clones Forge makes when a pipeline runs without a local
|
|
114
|
+
* project (resolveOrCloneProject). They're reused across runs, so we keep
|
|
115
|
+
* them a long time and only sweep ones untouched for clonedProjectKeepDays
|
|
116
|
+
* (default 30). Conservative on purpose:
|
|
117
|
+
* - default 30d, and 0/negative disables the sweep entirely;
|
|
118
|
+
* - never deletes a clone that still has a worktree for a non-terminal
|
|
119
|
+
* (running/started) pipeline under it.
|
|
120
|
+
* Uses the newest mtime across the repo dir + its .forge/worktrees so an
|
|
121
|
+
* actively-used clone (recent worktree) is never considered stale.
|
|
122
|
+
*/
|
|
123
|
+
export function gcClonedProjects(opts: { dryRun?: boolean } = {}): GcResult {
|
|
124
|
+
const settings = loadSettings();
|
|
125
|
+
const keepDays = (settings as { clonedProjectKeepDays?: number }).clonedProjectKeepDays ?? 30;
|
|
126
|
+
const removed: GcResult['removed'] = [];
|
|
127
|
+
const kept: GcResult['kept'] = [];
|
|
128
|
+
let scanned = 0;
|
|
129
|
+
if (!keepDays || keepDays <= 0) return { scanned, removed, kept }; // disabled
|
|
130
|
+
|
|
131
|
+
const keepMs = keepDays * 86400_000;
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
const root = join(getDataDir(), 'cloned-projects');
|
|
134
|
+
let entries: string[];
|
|
135
|
+
try { entries = readdirSync(root); } catch { return { scanned, removed, kept }; }
|
|
136
|
+
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
const repoDir = join(root, entry);
|
|
139
|
+
let st: ReturnType<typeof statSync>;
|
|
140
|
+
try { st = statSync(repoDir); } catch { continue; }
|
|
141
|
+
if (!st.isDirectory()) continue;
|
|
142
|
+
scanned++;
|
|
143
|
+
|
|
144
|
+
// Newest signal across the repo dir + its worktree root.
|
|
145
|
+
let newest = st.mtimeMs;
|
|
146
|
+
const wtRoot = join(repoDir, '.forge', 'worktrees');
|
|
147
|
+
let liveWorktree = false;
|
|
148
|
+
try {
|
|
149
|
+
for (const wt of readdirSync(wtRoot)) {
|
|
150
|
+
const wtPath = join(wtRoot, wt);
|
|
151
|
+
try { newest = Math.max(newest, statSync(wtPath).mtimeMs); } catch {}
|
|
152
|
+
if (wt.startsWith('pipeline-')) {
|
|
153
|
+
const p = getPipeline(wt.slice('pipeline-'.length));
|
|
154
|
+
if (p && p.status !== 'done' && p.status !== 'failed' && p.status !== 'cancelled') {
|
|
155
|
+
liveWorktree = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} catch { /* no worktrees dir */ }
|
|
160
|
+
|
|
161
|
+
if (liveWorktree) { kept.push({ path: repoDir, reason: 'in-use (running pipeline worktree)' }); continue; }
|
|
162
|
+
if (now - newest > keepMs) {
|
|
163
|
+
if (!opts.dryRun) { try { rmSync(repoDir, { recursive: true, force: true }); } catch {} }
|
|
164
|
+
removed.push({ path: repoDir, reason: `clone unused >${keepDays}d` });
|
|
165
|
+
} else {
|
|
166
|
+
kept.push({ path: repoDir, reason: 'fresh' });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { scanned, removed, kept };
|
|
170
|
+
}
|
package/lib/pipeline.ts
CHANGED
|
@@ -11,11 +11,12 @@ import { execSync } from 'node:child_process';
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import YAML from 'yaml';
|
|
13
13
|
import { createTask, getTask, onTaskEvent, taskModelOverrides, taskAppendSystemPromptOverrides, cancelTask } from './task-manager';
|
|
14
|
-
import { getProjectInfo, resolveOrCloneProject, getProjectWorktreeRoot } from './projects';
|
|
14
|
+
import { getProjectInfo, resolveOrCloneProject, getProjectWorktreeRoot, ensureScratchProject } from './projects';
|
|
15
15
|
import { loadSettings } from './settings';
|
|
16
16
|
import { getAgent, listAgents } from './agents';
|
|
17
17
|
import type { Task } from '../src/types';
|
|
18
18
|
import { getDataDir } from './dirs';
|
|
19
|
+
import { notifyAuthBlocked } from './notify';
|
|
19
20
|
|
|
20
21
|
const PIPELINES_DIR = join(getDataDir(), 'pipelines');
|
|
21
22
|
const WORKFLOWS_DIR = join(getDataDir(), 'flows');
|
|
@@ -164,6 +165,9 @@ export interface Workflow {
|
|
|
164
165
|
nodes: Record<string, WorkflowNode>;
|
|
165
166
|
/** Loop over a list — each iteration runs the full DAG once. See ForEachSpec. */
|
|
166
167
|
for_each?: ForEachSpec;
|
|
168
|
+
/** Declares the pipeline pushes to git. Enables an up-front OTP/2FA preflight
|
|
169
|
+
* so a 2fa_verify wall aborts BEFORE any code work instead of at push time. */
|
|
170
|
+
git_push?: boolean;
|
|
167
171
|
// Conversation mode fields (only when type === 'conversation')
|
|
168
172
|
conversation?: ConversationConfig;
|
|
169
173
|
}
|
|
@@ -233,6 +237,9 @@ export interface Pipeline {
|
|
|
233
237
|
nodeOrder: string[]; // for UI display
|
|
234
238
|
createdAt: string;
|
|
235
239
|
completedAt?: string;
|
|
240
|
+
/** Pipeline-level failure reason (e.g. git OTP preflight abort) — distinct
|
|
241
|
+
* from per-node errors. Set when the run is settled outside a node. */
|
|
242
|
+
error?: string;
|
|
236
243
|
/**
|
|
237
244
|
* Skill names from the forge-skills registry to make available to
|
|
238
245
|
* every task this pipeline spawns. Composed into the task's
|
|
@@ -747,16 +754,22 @@ export function resolveTemplate(template: string, ctx: {
|
|
|
747
754
|
// ─── Per-run scratch dir (`{{run.tmp_dir}}`) ────────────────
|
|
748
755
|
|
|
749
756
|
/**
|
|
750
|
-
* Compute the absolute scratch-dir path for a pipeline run.
|
|
751
|
-
*
|
|
757
|
+
* Compute the absolute scratch-dir path for a pipeline run. Falls back to
|
|
758
|
+
* the scratch project's worktree root when input.project is missing or
|
|
759
|
+
* unresolvable — NEVER returns '' (an empty {{run.tmp_dir}} made YAML like
|
|
760
|
+
* `mkdir -p {{run.tmp_dir}}/mr-${IID}` resolve to `/mr-NNN`, i.e. the
|
|
761
|
+
* read-only filesystem root — that was the "mkdir: /mr-14949: Read-only
|
|
762
|
+
* file system" crash). Does NOT mkdir.
|
|
752
763
|
*/
|
|
753
764
|
function computePipelineTmpDir(pipeline: Pipeline): string {
|
|
754
765
|
const name = pipeline.input?.project;
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
766
|
+
const proj = name ? getProjectInfo(name) : null;
|
|
767
|
+
if (proj) {
|
|
768
|
+
// Worktree root differs for scratch vs real projects — see getProjectWorktreeRoot.
|
|
769
|
+
return join(getProjectWorktreeRoot(proj), `pipeline-${pipeline.id}`);
|
|
770
|
+
}
|
|
771
|
+
// No / unresolved project → writable scratch base under <dataDir>/scratch.
|
|
772
|
+
return join(ensureScratchProject(), 'worktrees', `pipeline-${pipeline.id}`);
|
|
760
773
|
}
|
|
761
774
|
|
|
762
775
|
/**
|
|
@@ -764,6 +777,21 @@ function computePipelineTmpDir(pipeline: Pipeline): string {
|
|
|
764
777
|
* non-fatal — `tmpDir` stays unset and `{{run.tmp_dir}}` renders empty.
|
|
765
778
|
*/
|
|
766
779
|
function ensurePipelineTmpDir(pipeline: Pipeline): void {
|
|
780
|
+
// Align run.tmp_dir with the project the nodes will actually use. When no
|
|
781
|
+
// project was given, resolve it through the same three-tier path the nodes
|
|
782
|
+
// use (existing → gitlab clone → scratch) and write the concrete name back
|
|
783
|
+
// to input.project, so tmp_dir lands inside that (possibly cloned) repo and
|
|
784
|
+
// downstream resolveOrCloneProject finds it as 'existing' (no double clone).
|
|
785
|
+
// Every node already calls resolveOrCloneProject, so this adds no NEW clone
|
|
786
|
+
// — it just happens once up front. Never throws (falls back to scratch).
|
|
787
|
+
if (!pipeline.input?.project) {
|
|
788
|
+
try {
|
|
789
|
+
const r = resolveOrCloneProject('');
|
|
790
|
+
if (r?.project?.name) {
|
|
791
|
+
pipeline.input = { ...(pipeline.input || {}), project: r.project.name };
|
|
792
|
+
}
|
|
793
|
+
} catch { /* leave empty → computePipelineTmpDir's scratch fallback */ }
|
|
794
|
+
}
|
|
767
795
|
const dir = computePipelineTmpDir(pipeline);
|
|
768
796
|
if (!dir) return;
|
|
769
797
|
try {
|
|
@@ -788,6 +816,83 @@ export function cleanupPipelineTmpDir(pipeline: Pipeline): void {
|
|
|
788
816
|
}
|
|
789
817
|
}
|
|
790
818
|
|
|
819
|
+
// ─── git auth preflight ───────────────────────────────────
|
|
820
|
+
|
|
821
|
+
/** Marker patterns in git's stderr that mean an OTP/2FA wall blocks pushes. */
|
|
822
|
+
const GIT_OTP_MARKER = /2fa_verify|OTP verification is required|two-factor|2fa/i;
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* For pipelines that declare `git_push: true`, probe remote access up front
|
|
826
|
+
* with a lightweight `git ls-remote` (no object transfer). If the remote
|
|
827
|
+
* demands OTP/2FA the probe fails with the marker text — we return the human
|
|
828
|
+
* message + the command to clear it, so startPipeline can abort the whole run
|
|
829
|
+
* before any expensive code work. Returns null when access is fine, the repo
|
|
830
|
+
* is scratch/unknown, or the probe itself errors for unrelated reasons (we do
|
|
831
|
+
* not block on ambiguous failures — those surface at push time as before).
|
|
832
|
+
*/
|
|
833
|
+
function preflightGitAuth(pipeline: Pipeline): { message: string; action?: string } | null {
|
|
834
|
+
let projectPath: string;
|
|
835
|
+
try {
|
|
836
|
+
const resolved = resolveOrCloneProject(pipeline.input?.project || '');
|
|
837
|
+
if (resolved.source === 'scratch') return null; // no real repo to push to
|
|
838
|
+
projectPath = resolved.project.path;
|
|
839
|
+
} catch {
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
if (!existsSync(join(projectPath, '.git'))) return null;
|
|
843
|
+
|
|
844
|
+
try {
|
|
845
|
+
execSync('git ls-remote --heads origin', {
|
|
846
|
+
cwd: projectPath,
|
|
847
|
+
stdio: 'pipe',
|
|
848
|
+
timeout: 15_000,
|
|
849
|
+
encoding: 'utf8',
|
|
850
|
+
});
|
|
851
|
+
return null; // remote reachable, no OTP wall
|
|
852
|
+
} catch (e) {
|
|
853
|
+
const err = e as { stderr?: Buffer | string; stdout?: Buffer | string; message?: string };
|
|
854
|
+
const out = `${err.stderr || ''}${err.stdout || ''}${err.message || ''}`;
|
|
855
|
+
const m = out.match(/ssh\s+git@\S+\s+2fa_verify/i);
|
|
856
|
+
if (GIT_OTP_MARKER.test(out)) {
|
|
857
|
+
return {
|
|
858
|
+
message: 'Git remote requires OTP/2FA verification before pushes are allowed. ' +
|
|
859
|
+
'Aborting before any code work to avoid wasted effort.',
|
|
860
|
+
action: m ? m[0] : 'ssh git@<git-host> 2fa_verify',
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
// Other failures (network, no origin, etc.) — don't block; let nodes handle.
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** Markers in a failed node's error/output that mean a connector hit an auth
|
|
869
|
+
* wall (expired login, sign-in page) — e.g. Mantis/Teams/OWA session lapsed. */
|
|
870
|
+
const CONNECTOR_AUTH_MARKER =
|
|
871
|
+
/not logged in|please (?:log|sign) ?in|sign[- ]?in (?:page|required)|login (?:page|required|expired)|session (?:expired|timed out)|authentication required|unauthor(?:ized|ised)|需要登录|未登录|登录(?:已)?(?:失效|过期)|请(?:重新)?登录/i;
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Scan a settled-failed pipeline for a connector auth wall. Heuristic text
|
|
875
|
+
* match over each failed node's error + its task output (the agent's report).
|
|
876
|
+
* Returns the matching node id + a snippet, or null. Used by finalizePipeline
|
|
877
|
+
* to fire one auth alert per pipeline so an expired Mantis/Teams/OWA login is
|
|
878
|
+
* surfaced loudly instead of buried in a generic "node failed".
|
|
879
|
+
*/
|
|
880
|
+
function detectConnectorAuthBlock(pipeline: Pipeline): { nodeId: string; snippet: string } | null {
|
|
881
|
+
for (const [nodeId, node] of Object.entries(pipeline.nodes)) {
|
|
882
|
+
if (node.status !== 'failed') continue;
|
|
883
|
+
let text = node.error || '';
|
|
884
|
+
if (node.taskId) {
|
|
885
|
+
const t = getTask(node.taskId);
|
|
886
|
+
if (t) text += `\n${t.error || ''}\n${t.resultSummary || ''}`;
|
|
887
|
+
}
|
|
888
|
+
if (CONNECTOR_AUTH_MARKER.test(text)) {
|
|
889
|
+
const m = text.match(CONNECTOR_AUTH_MARKER);
|
|
890
|
+
return { nodeId, snippet: (m ? text.slice(Math.max(0, (m.index ?? 0) - 40), (m.index ?? 0) + 120) : text).trim() };
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
|
|
791
896
|
// ─── for_each: source resolution ──────────────────────────
|
|
792
897
|
|
|
793
898
|
/**
|
|
@@ -954,6 +1059,25 @@ export function startPipeline(
|
|
|
954
1059
|
|
|
955
1060
|
ensurePipelineTmpDir(pipeline);
|
|
956
1061
|
|
|
1062
|
+
// Git OTP/2FA preflight — abort the whole run up front so a push wall never
|
|
1063
|
+
// wastes a full code-fix pass. Only for pipelines that declare git_push.
|
|
1064
|
+
if (workflow.git_push) {
|
|
1065
|
+
const blocked = preflightGitAuth(pipeline);
|
|
1066
|
+
if (blocked) {
|
|
1067
|
+
pipeline.status = 'failed';
|
|
1068
|
+
pipeline.error = `${blocked.message}${blocked.action ? `\nRun: ${blocked.action}` : ''}`;
|
|
1069
|
+
pipeline.completedAt = new Date().toISOString();
|
|
1070
|
+
savePipeline(pipeline);
|
|
1071
|
+
void notifyAuthBlocked({
|
|
1072
|
+
title: `Pipeline aborted — git auth required (${workflowName})`,
|
|
1073
|
+
body: blocked.message,
|
|
1074
|
+
action: blocked.action,
|
|
1075
|
+
refId: pipeline.id,
|
|
1076
|
+
});
|
|
1077
|
+
return pipeline;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
957
1081
|
// Empty for_each source → nothing to iterate; settle done immediately.
|
|
958
1082
|
// (E.g. user submitted `bug_ids: ""` or an empty array; not an error.)
|
|
959
1083
|
// Deferred (with `before:`) skips this — items get resolved post-setup.
|
|
@@ -2116,6 +2240,20 @@ function finalizePipeline(pipeline: Pipeline) {
|
|
|
2116
2240
|
|
|
2117
2241
|
notifyPipelineComplete(pipeline);
|
|
2118
2242
|
|
|
2243
|
+
// Connector auth wall (expired Mantis/Teams/OWA login etc.) — fire a loud
|
|
2244
|
+
// auth alert once per failed pipeline so it's not buried in a generic error.
|
|
2245
|
+
if (pipeline.status === 'failed') {
|
|
2246
|
+
const authBlock = detectConnectorAuthBlock(pipeline);
|
|
2247
|
+
if (authBlock) {
|
|
2248
|
+
void notifyAuthBlocked({
|
|
2249
|
+
title: `Pipeline failed — connector login required (${pipeline.workflowName})`,
|
|
2250
|
+
body: `Node "${authBlock.nodeId}" hit an authentication wall — a connector session has expired. ` +
|
|
2251
|
+
`Re-login to the affected service and re-run.\n\n${authBlock.snippet}`,
|
|
2252
|
+
refId: pipeline.id,
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2119
2257
|
// Sync run status to project pipeline runs. Dynamic import avoids the
|
|
2120
2258
|
// circular dep (pipeline-scheduler imports from pipeline.ts at its top).
|
|
2121
2259
|
import('./pipeline-scheduler').then(({ syncRunStatus }) => {
|
package/lib/projects.ts
CHANGED
|
@@ -13,6 +13,46 @@ export interface LocalProject {
|
|
|
13
13
|
hasClaudeMd: boolean;
|
|
14
14
|
language: string | null;
|
|
15
15
|
lastModified: string;
|
|
16
|
+
/** Normalized "owner/repo" derived from the git origin remote (or a
|
|
17
|
+
* settings.projectRepos override). Lets MR/bug pipelines map a repo
|
|
18
|
+
* path back to this local checkout. Undefined when not a git repo /
|
|
19
|
+
* no origin. */
|
|
20
|
+
repo?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Normalize a git remote URL to an "owner/repo" path (lowercased), so it
|
|
24
|
+
* can be matched against a gitlab MR path. Handles ssh + https forms:
|
|
25
|
+
* git@host:owner/repo.git → owner/repo
|
|
26
|
+
* https://host/owner/repo.git → owner/repo
|
|
27
|
+
* https://host/group/sub/repo → group/sub/repo
|
|
28
|
+
* Returns '' if it can't parse one. */
|
|
29
|
+
function normalizeRepoPath(url: string): string {
|
|
30
|
+
let s = (url || '').trim();
|
|
31
|
+
if (!s) return '';
|
|
32
|
+
s = s.replace(/\.git$/i, '');
|
|
33
|
+
// scp-style: git@host:owner/repo
|
|
34
|
+
const scp = s.match(/^[^@]+@[^:]+:(.+)$/);
|
|
35
|
+
if (scp) return scp[1].replace(/^\/+|\/+$/g, '').toLowerCase();
|
|
36
|
+
// url-style: scheme://[user@]host/owner/repo
|
|
37
|
+
const m = s.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]+\/(.+)$/i);
|
|
38
|
+
if (m) return m[1].replace(/^\/+|\/+$/g, '').toLowerCase();
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read a project's origin remote from .git/config WITHOUT spawning git
|
|
43
|
+
* (scanProjects runs often; a subprocess per project would be costly).
|
|
44
|
+
* Returns the normalized "owner/repo" or '' . */
|
|
45
|
+
function readGitOriginRepo(projectPath: string): string {
|
|
46
|
+
try {
|
|
47
|
+
const cfg = readFileSync(join(projectPath, '.git', 'config'), 'utf-8');
|
|
48
|
+
// Find the [remote "origin"] section then its `url = ...` line.
|
|
49
|
+
const sec = cfg.match(/\[remote "origin"\]([\s\S]*?)(?:\n\[|$)/);
|
|
50
|
+
if (!sec) return '';
|
|
51
|
+
const u = sec[1].match(/^\s*url\s*=\s*(.+)\s*$/m);
|
|
52
|
+
return u ? normalizeRepoPath(u[1]) : '';
|
|
53
|
+
} catch {
|
|
54
|
+
return '';
|
|
55
|
+
}
|
|
16
56
|
}
|
|
17
57
|
|
|
18
58
|
/** Reserved name for the synthetic "scratch" project that lives under
|
|
@@ -112,6 +152,13 @@ export function scanProjects(): LocalProject[] {
|
|
|
112
152
|
const language = detectLanguage(projectPath);
|
|
113
153
|
const stat = statSync(projectPath);
|
|
114
154
|
|
|
155
|
+
// repo: manual settings.projectRepos override wins; else auto-detect
|
|
156
|
+
// from the git origin remote. Cheap (file read, no subprocess).
|
|
157
|
+
const repoOverride = (settings.projectRepos || {})[projectPath];
|
|
158
|
+
const repo = (typeof repoOverride === 'string' && repoOverride.trim())
|
|
159
|
+
? normalizeRepoPath(repoOverride) || repoOverride.trim().toLowerCase()
|
|
160
|
+
: (hasGit ? readGitOriginRepo(projectPath) : '');
|
|
161
|
+
|
|
115
162
|
projects.push({
|
|
116
163
|
name: entry.name,
|
|
117
164
|
path: projectPath,
|
|
@@ -120,6 +167,7 @@ export function scanProjects(): LocalProject[] {
|
|
|
120
167
|
hasClaudeMd,
|
|
121
168
|
language,
|
|
122
169
|
lastModified: stat.mtime.toISOString(),
|
|
170
|
+
repo: repo || undefined,
|
|
123
171
|
});
|
|
124
172
|
} catch {
|
|
125
173
|
// Skip inaccessible directories
|
|
@@ -301,6 +349,34 @@ export function resolveOrCloneProject(name: string | undefined): ResolveResult {
|
|
|
301
349
|
if (trimmed) {
|
|
302
350
|
const hit = getProjectInfo(trimmed);
|
|
303
351
|
if (hit) return { project: hit, source: 'existing' };
|
|
352
|
+
|
|
353
|
+
const projects = scanProjects();
|
|
354
|
+
|
|
355
|
+
// Repo match (best signal): when the caller passes an "owner/repo" path
|
|
356
|
+
// (e.g. derived from an MR URL), match it against each project's git
|
|
357
|
+
// origin remote (LocalProject.repo, auto-detected in scanProjects).
|
|
358
|
+
// This is how "review this MR" finds the right local checkout without
|
|
359
|
+
// the user naming the project. Compares full path then basename; only
|
|
360
|
+
// an UNAMBIGUOUS single match wins.
|
|
361
|
+
if (trimmed.includes('/')) {
|
|
362
|
+
const want = normalizeRepoPath(trimmed) || trimmed.toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
363
|
+
const wantBase = want.split('/').pop()!;
|
|
364
|
+
const byRepo = projects.filter((p) => p.repo && p.repo === want);
|
|
365
|
+
if (byRepo.length === 1) return { project: byRepo[0], source: 'existing' };
|
|
366
|
+
const byRepoBase = projects.filter((p) => p.repo && p.repo.split('/').pop() === wantBase);
|
|
367
|
+
if (byRepoBase.length === 1) return { project: byRepoBase[0], source: 'existing' };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Fuzzy name match across the configured project roots before cloning:
|
|
371
|
+
// the caller may pass a gitlab path ("owner/repo") or the cloned-projects
|
|
372
|
+
// dir name ("owner-repo") while the local checkout is just "repo". Try
|
|
373
|
+
// the basename and the owner-repo form; only accept an UNAMBIGUOUS
|
|
374
|
+
// single match so we never silently pick the wrong repo.
|
|
375
|
+
const base = trimmed.split('/').pop()!.trim();
|
|
376
|
+
const ownerRepo = trimmed.replace(/\//g, '-');
|
|
377
|
+
const cands = projects.filter((p) =>
|
|
378
|
+
p.name === base || p.name === ownerRepo || p.name.toLowerCase() === base.toLowerCase());
|
|
379
|
+
if (cands.length === 1) return { project: cands[0], source: 'existing' };
|
|
304
380
|
}
|
|
305
381
|
|
|
306
382
|
// Fallback to gitlab connector when no local match. Two cases worth
|
|
@@ -155,9 +155,6 @@ async function runChatAction(schedule: Schedule, run: ScheduleRun, output: strin
|
|
|
155
155
|
*/
|
|
156
156
|
async function runEmailAction(schedule: Schedule, _run: ScheduleRun, output: string): Promise<void> {
|
|
157
157
|
const settings = loadSettings();
|
|
158
|
-
if (!settings.smtpHost) {
|
|
159
|
-
throw new Error('SMTP not configured (Settings → SMTP)');
|
|
160
|
-
}
|
|
161
158
|
const cfg = schedule.action_config || {};
|
|
162
159
|
const toRaw = cfg.to;
|
|
163
160
|
const to = Array.isArray(toRaw)
|
|
@@ -178,6 +175,30 @@ async function runEmailAction(schedule: Schedule, _run: ScheduleRun, output: str
|
|
|
178
175
|
const bodyText = bodyTpl.replace('{date}', today).replace('{body_output}', output);
|
|
179
176
|
const asHtml = !!cfg.html;
|
|
180
177
|
|
|
178
|
+
// Transport: 'smtp' (default, nodemailer) or 'owa' (send via the OWA
|
|
179
|
+
// browser connector through the extension bridge — no SMTP server needed,
|
|
180
|
+
// rides the user's logged-in mailbox; requires the extension connected,
|
|
181
|
+
// same as any browser connector in a scheduled run).
|
|
182
|
+
const emailTransport = String(cfg.transport || 'smtp').toLowerCase();
|
|
183
|
+
if (emailTransport === 'owa') {
|
|
184
|
+
const { dispatchTool } = await import('../chat/tool-dispatcher');
|
|
185
|
+
const r = await dispatchTool({
|
|
186
|
+
id: `sched-email-${Date.now()}`,
|
|
187
|
+
name: 'owa.send_mail',
|
|
188
|
+
input: { to: to.join(', '), subject, body: bodyText, body_html: asHtml },
|
|
189
|
+
});
|
|
190
|
+
let parsed: any = r.content;
|
|
191
|
+
try { parsed = JSON.parse(r.content); } catch { /* keep string */ }
|
|
192
|
+
if (r.is_error || (parsed && parsed.error)) {
|
|
193
|
+
throw new Error(`OWA send failed: ${(parsed && parsed.error) || r.content}`);
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!settings.smtpHost) {
|
|
199
|
+
throw new Error("SMTP not configured (Settings → SMTP). For mailbox send without SMTP, set action_config.transport='owa'.");
|
|
200
|
+
}
|
|
201
|
+
|
|
181
202
|
// Lazy import keeps the SMTP client out of the main bundle until the
|
|
182
203
|
// first email action actually fires.
|
|
183
204
|
const nodemailer = (await import('nodemailer')).default;
|
package/lib/settings.ts
CHANGED
|
@@ -93,6 +93,11 @@ export interface McpServerConfig {
|
|
|
93
93
|
|
|
94
94
|
export interface Settings {
|
|
95
95
|
projectRoots: string[];
|
|
96
|
+
/** Optional per-project repo override: { <projectPath>: "owner/repo" }.
|
|
97
|
+
* Normally the repo is auto-detected from the project's git origin remote
|
|
98
|
+
* (see lib/projects.ts); this only overrides when detection is wrong or
|
|
99
|
+
* the checkout has no origin. */
|
|
100
|
+
projectRepos?: Record<string, string>;
|
|
96
101
|
docRoots: string[];
|
|
97
102
|
claudePath: string;
|
|
98
103
|
// claudeHome removed in 0.10.10 — was a never-UI-exposed override that
|
package/package.json
CHANGED