@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 CHANGED
@@ -1,8 +1,13 @@
1
- # Forge v0.10.67
1
+ # Forge v0.10.69
2
2
 
3
- Released: 2026-06-10
3
+ Released: 2026-06-11
4
4
 
5
- ## Changes since v0.10.66
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
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.66...v0.10.67
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 || !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)]">
@@ -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 (SMTP)</span>
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
- // 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 |
@@ -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;
@@ -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. Returns
751
- * empty string if input.project doesn't resolve. Does NOT mkdir.
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
- if (!name) return '';
756
- const proj = getProjectInfo(name);
757
- if (!proj) return '';
758
- // Worktree root differs for scratch vs real projects — see getProjectWorktreeRoot.
759
- return join(getProjectWorktreeRoot(proj), `pipeline-${pipeline.id}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.67",
3
+ "version": "0.10.69",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {