@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 CHANGED
@@ -1,8 +1,23 @@
1
- # Forge v0.10.68
1
+ # Forge v0.10.70
2
2
 
3
- Released: 2026-06-10
3
+ Released: 2026-06-11
4
4
 
5
- ## Changes since v0.10.67
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
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.67...v0.10.68
22
+
23
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.69...v0.10.70
@@ -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 || !idpOtp.trim()) {
194
- setIdpErr('Username, password, and OTP are all required');
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
- body: JSON.stringify({ username: idpUser.trim(), password: idpPass, otp: idpOtp.trim() }),
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
- <input
490
- type="text"
491
- inputMode="numeric"
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 || !idpOtp.trim()}
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
- // Resolve the agent's absolute claude path — bare
1577
- // `claude` rides on the tmux pane PATH, which on a
1578
- // fresh-install machine still points at the user's
1579
- // stale global @anthropic-ai/claude-code (e.g.
1580
- // /opt/homebrew/...) and crashes on resume.
1581
- Promise.all([
1582
- resolveFixedSession(pp),
1583
- getMcpFlag(pp),
1584
- fetch('/api/agents?resolve=').then(r => r.ok ? r.json() : null).catch(() => null),
1585
- ]).then(([fixedId, mcpFlag, agentInfo]) => {
1586
- const resumeFlag = buildResumeFlag(fixedId, true);
1587
- const skipFlag = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
1588
- const claudeCmd = `"${agentInfo?.cliCmd || 'claude'}"`;
1589
- setTimeout(() => {
1590
- if (!disposed && ws?.readyState === WebSocket.OPEN) {
1591
- ws.send(JSON.stringify({ type: 'input', data: `cd "${pp}" && ${claudeCmd}${resumeFlag}${skipFlag}${mcpFlag}\n` }));
1592
- }
1593
- }, 300);
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;
@@ -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
- if (!req.username || !req.password || !req.otp) {
227
- return { ok: false, idp_logins: [], error: 'username, password, and otp are all required' };
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) {
@@ -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
- if (spec.needs_otp) {
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
- ' if ((current date) - t0) > 30 then exit repeat',
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'), 60_000);
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 { name: spec.name, command: spec.command, ok: false, error: 'timed out reading Terminal' };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.68",
3
+ "version": "0.10.70",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {