@aion0/forge 0.10.68 → 0.10.70
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 +19 -4
- package/bin/forge-server.mjs +16 -0
- package/components/EnterpriseBadge.tsx +12 -23
- 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/notify.ts +53 -0
- package/lib/pipeline.ts +121 -0
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.70
|
|
2
2
|
|
|
3
|
-
Released: 2026-06-
|
|
3
|
+
Released: 2026-06-11
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.69
|
|
6
6
|
|
|
7
|
+
### Other
|
|
8
|
+
- revert: remove CLI quick-setup wizard + AUTH_SECRET persist (keep BRIDGE/CHAT port fixes)
|
|
9
|
+
- feat(setup): ask for department in enterprise wizard
|
|
10
|
+
- fix(auth): persist AUTH_SECRET so sessions survive refresh/restart
|
|
11
|
+
- fix(server): per-instance CHAT_PORT (webPort+5) — fixes 2nd-instance settings/sync
|
|
12
|
+
- feat(server): minimal first-run quick setup (public/enterprise)
|
|
13
|
+
- revert: remove first-run wizard + AUTH_SECRET persist + onboarding tweaks
|
|
14
|
+
- fix(setup): skip first-run wizard when instance already configured
|
|
15
|
+
- fix(setup): normalize half-typed email against tenant domain
|
|
16
|
+
- fix(auth): persist AUTH_SECRET so sessions survive refresh/restart
|
|
17
|
+
- fix(onboarding): show banner only, don't auto-open the modal each load
|
|
18
|
+
- fix(server): per-instance BRIDGE_PORT (webPort+4) + wizard sets onboardingCompleted
|
|
19
|
+
- feat(server): first-run public/enterprise quick-setup wizard
|
|
20
|
+
- fix(pipeline): run finalizePipeline on git-preflight abort
|
|
7
21
|
|
|
8
|
-
|
|
22
|
+
|
|
23
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.69...v0.10.70
|
package/bin/forge-server.mjs
CHANGED
|
@@ -123,6 +123,19 @@ const workspacePort = parseInt(getArg('--workspace-port')) || (webPort + 2);
|
|
|
123
123
|
// second instance (dev-test on a different webPort) collided with the
|
|
124
124
|
// main instance. Offset from webPort like every other service.
|
|
125
125
|
const mcpPort = parseInt(getArg('--mcp-port')) || (webPort + 3);
|
|
126
|
+
// Browser-bridge standalone. Was hardcoded to 8407 everywhere (standalone,
|
|
127
|
+
// bridge-info route, extension fallback), so a second instance (e.g. --port
|
|
128
|
+
// 4000) reused the 8403 instance's bridge → the extension connected to the
|
|
129
|
+
// wrong instance and its token was rejected. Offset from webPort like the
|
|
130
|
+
// others; default 8403+4 = 8407 keeps existing single-instance pairings.
|
|
131
|
+
const bridgePort = parseInt(getArg('--bridge-port')) || (webPort + 4);
|
|
132
|
+
// Chat standalone. Was hardcoded to 8408 everywhere — a second instance
|
|
133
|
+
// (--port 4000) failed to bind it, then its next-server fell back to the
|
|
134
|
+
// 8408 default and hit the FIRST instance's chat standalone with a token
|
|
135
|
+
// signed for the wrong instance → "Sync failed: unauthorized" on
|
|
136
|
+
// settings/marketplace. Offset like bridge; default 8403+5 = 8408 keeps
|
|
137
|
+
// existing single-instance behaviour.
|
|
138
|
+
const chatPort = parseInt(getArg('--chat-port')) || (webPort + 5);
|
|
126
139
|
const DATA_DIR = getArg('--dir')?.replace(/^~/, homedir()) || join(homedir(), '.forge', 'data');
|
|
127
140
|
|
|
128
141
|
const PID_FILE = join(DATA_DIR, 'forge.pid');
|
|
@@ -245,6 +258,8 @@ process.env.PORT = String(webPort);
|
|
|
245
258
|
process.env.TERMINAL_PORT = String(terminalPort);
|
|
246
259
|
process.env.WORKSPACE_PORT = String(workspacePort);
|
|
247
260
|
process.env.MCP_PORT = String(mcpPort);
|
|
261
|
+
process.env.BRIDGE_PORT = String(bridgePort);
|
|
262
|
+
process.env.CHAT_PORT = String(chatPort);
|
|
248
263
|
process.env.FORGE_DATA_DIR = DATA_DIR;
|
|
249
264
|
|
|
250
265
|
// ── Password setup (first run or --reset-password) ──
|
|
@@ -318,6 +333,7 @@ if (!isStop && !addEnterpriseKeyOnly) {
|
|
|
318
333
|
}
|
|
319
334
|
}
|
|
320
335
|
|
|
336
|
+
|
|
321
337
|
// ── Enterprise keys (--enterprise-key=…) ──
|
|
322
338
|
//
|
|
323
339
|
// Each key is encrypted with AES-256-GCM via the same `.encrypt-key`
|
|
@@ -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)]">
|
|
@@ -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 |
|
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.ts
CHANGED
|
@@ -16,6 +16,7 @@ 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
|
|
@@ -809,6 +816,83 @@ export function cleanupPipelineTmpDir(pipeline: Pipeline): void {
|
|
|
809
816
|
}
|
|
810
817
|
}
|
|
811
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
|
+
|
|
812
896
|
// ─── for_each: source resolution ──────────────────────────
|
|
813
897
|
|
|
814
898
|
/**
|
|
@@ -975,6 +1059,29 @@ export function startPipeline(
|
|
|
975
1059
|
|
|
976
1060
|
ensurePipelineTmpDir(pipeline);
|
|
977
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
|
+
// Same settle housekeeping every other failure path gets — without it
|
|
1072
|
+
// schedule/binding-fired runs linger as "running" in pipeline_runs
|
|
1073
|
+
// until zombie reconciliation catches them.
|
|
1074
|
+
finalizePipeline(pipeline);
|
|
1075
|
+
void notifyAuthBlocked({
|
|
1076
|
+
title: `Pipeline aborted — git auth required (${workflowName})`,
|
|
1077
|
+
body: blocked.message,
|
|
1078
|
+
action: blocked.action,
|
|
1079
|
+
refId: pipeline.id,
|
|
1080
|
+
});
|
|
1081
|
+
return pipeline;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
978
1085
|
// Empty for_each source → nothing to iterate; settle done immediately.
|
|
979
1086
|
// (E.g. user submitted `bug_ids: ""` or an empty array; not an error.)
|
|
980
1087
|
// Deferred (with `before:`) skips this — items get resolved post-setup.
|
|
@@ -2137,6 +2244,20 @@ function finalizePipeline(pipeline: Pipeline) {
|
|
|
2137
2244
|
|
|
2138
2245
|
notifyPipelineComplete(pipeline);
|
|
2139
2246
|
|
|
2247
|
+
// Connector auth wall (expired Mantis/Teams/OWA login etc.) — fire a loud
|
|
2248
|
+
// auth alert once per failed pipeline so it's not buried in a generic error.
|
|
2249
|
+
if (pipeline.status === 'failed') {
|
|
2250
|
+
const authBlock = detectConnectorAuthBlock(pipeline);
|
|
2251
|
+
if (authBlock) {
|
|
2252
|
+
void notifyAuthBlocked({
|
|
2253
|
+
title: `Pipeline failed — connector login required (${pipeline.workflowName})`,
|
|
2254
|
+
body: `Node "${authBlock.nodeId}" hit an authentication wall — a connector session has expired. ` +
|
|
2255
|
+
`Re-login to the affected service and re-run.\n\n${authBlock.snippet}`,
|
|
2256
|
+
refId: pipeline.id,
|
|
2257
|
+
});
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2140
2261
|
// Sync run status to project pipeline runs. Dynamic import avoids the
|
|
2141
2262
|
// circular dep (pipeline-scheduler imports from pipeline.ts at its top).
|
|
2142
2263
|
import('./pipeline-scheduler').then(({ syncRunStatus }) => {
|
package/package.json
CHANGED